fix(federation): anisotropic scene fill + centred key on mobile

- The fit now scales screen X and Y independently (capped at 1.75x
  anisotropy), so both 2D and 3D stretch to the container's aspect ratio —
  a portrait phone uses its full height instead of shrinking the orbit to
  the narrow width, and wide desktop panels spread horizontally.
- Legend/key centres at the top on mobile, mirroring the bottom-centre
  2D/3D toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 18:31:49 -04:00
co-authored by Claude Fable 5
parent a5d81fd5ff
commit 5a0c1f06b1
@@ -224,8 +224,10 @@ let ringsLayer: SVGGElement | null = null
let width = 0
let height = 0
/** Scene scale + vertical centring — tweened alongside cam.tilt/persp during
* a 2D/3D toggle so the layout re-fits as it folds. */
const fit = { unit: 1, centerY: 0 }
* a 2D/3D toggle so the layout re-fits as it folds. unitX/unitY scale the
* two screen axes independently (capped anisotropy) so the orbit fills the
* container's aspect ratio instead of being bound by the narrow axis. */
const fit = { unitX: 1, unitY: 1, centerY: 0 }
let elapsed = 0
let frame = 0
let modeTween: gsap.core.Tween[] = []
@@ -328,8 +330,8 @@ function project(x: number, y: number, z: number) {
const z2 = y * st + z1 * ct
const s = cam.persp / (cam.persp + z2)
return {
x: width / 2 + x1 * fit.unit * s * cam.zoom,
y: fit.centerY + y2 * fit.unit * s * cam.zoom,
x: width / 2 + x1 * fit.unitX * s * cam.zoom,
y: fit.centerY + y2 * fit.unitY * s * cam.zoom,
s,
z: z2,
}
@@ -1056,7 +1058,7 @@ function onPointerUp(_e: PointerEvent) {
* scale to fill the container and centre vertically between the overlays.
* Pure with respect to tilt/persp so a mode toggle can compute its target
* fit and tween towards it. */
function computeFit(tilt: number, perspVal: number): { unit: number; centerY: number } {
function computeFit(tilt: number, perspVal: number): { unitX: number; unitY: number; centerY: number } {
let maxRing = 1
for (const r of ringPaths) maxRing = Math.max(maxRing, r.radius)
// Requests hover loosely up to ~0.12 beyond their guide ring
@@ -1085,11 +1087,17 @@ function computeFit(tilt: number, perspVal: number): { unit: number; centerY: nu
const marginTop = compact ? 42 : 54 // legend / toggle chips
const marginBottom = compact ? 58 : 66 // hint + bottom-centre toggle (mobile)
const bandH = Math.max(80, height - marginTop - marginBottom)
const unitX = (width / 2 - marginX) / maxAbsX
const unitY = bandH / Math.max(yMax - yMin, 0.001)
const u = Math.max(20, Math.min(unitX, unitY))
const rawX = (width / 2 - marginX) / maxAbsX
const rawY = bandH / Math.max(yMax - yMin, 0.001)
// Anisotropic fill: each axis takes its own bound, capped at 1.75× the
// other so the orbit stretches to the container's aspect ratio without
// degenerating. This is what lets a portrait phone use its full height in
// both 2D and 3D instead of shrinking to the narrow width.
const ANISO = 1.75
const unitX = Math.max(20, Math.min(rawX, rawY * ANISO))
const unitY = Math.max(20, Math.min(rawY, rawX * ANISO))
// Place the projected ellipse's midpoint at the centre of the available band
return { unit: u, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * u }
return { unitX, unitY, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * unitY }
}
/** Re-fit after a graph change (e.g. the outermost orbit appeared/vanished).
@@ -1098,13 +1106,14 @@ function refit() {
if (!modeInitialized) return
const m = modeParams(viewMode.value)
const target = computeFit(m.tilt, m.persp)
if (Math.abs(target.unit - fit.unit) < 0.5 && Math.abs(target.centerY - fit.centerY) < 0.5) return
if (Math.abs(target.unitX - fit.unitX) < 0.5
&& Math.abs(target.unitY - fit.unitY) < 0.5
&& Math.abs(target.centerY - fit.centerY) < 0.5) return
gsap.killTweensOf(fit)
if (introPlayed && !staticMode) {
gsap.to(fit, { ...target, duration: 0.6, ease: motionTokens.ease.inOut })
} else {
fit.unit = target.unit
fit.centerY = target.centerY
Object.assign(fit, target)
}
}
@@ -1123,8 +1132,7 @@ function applyMode(mode: MapMode, animate: boolean) {
} else {
cam.tilt = target.tilt
cam.persp = target.persp
fit.unit = targetFit.unit
fit.centerY = targetFit.centerY
Object.assign(fit, targetFit)
render()
}
}
@@ -1458,9 +1466,13 @@ onActivated(() => { if (built && !staticMode) attachTicker() })
}
@media (max-width: 767px) {
/* Key centred on mobile, mirroring the bottom-centre toggle */
.node-map-legend {
top: 8px;
left: 8px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: calc(100% - 16px);
padding: 5px 10px;
gap: 8px;
}