From 5a0c1f06b16e422c86d7edf3371d2b7b7a911659 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 9 Aug 2026 18:31:49 -0400 Subject: [PATCH] fix(federation): anisotropic scene fill + centred key on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/federation/NetworkMap3D.vue | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue index 2001f7c0..29b44794 100644 --- a/neode-ui/src/components/federation/NetworkMap3D.vue +++ b/neode-ui/src/components/federation/NetworkMap3D.vue @@ -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; }