fix(federation): mobile map readability — fill the space, taller 3D, toggle bottom-centre

- Compact containers (<480px) shrink the fit margins so the scene fills the
  phone screen instead of floating in padding.
- 3D mode steepens its tilt on portrait (-0.95 vs -0.5) so the orbit reads
  as a tall ellipse with depth, not a squashed horizontal band; projection
  params are resolved per-aspect via modeParams() and re-resolve on resize.
- The 2D/3D toggle moves to bottom-centre on mobile (thumb reach, frees the
  top edge); the hint line tucks above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 18:29:54 -04:00
co-authored by Claude Fable 5
parent 4ffd632506
commit a5d81fd5ff
@@ -132,13 +132,15 @@ const cam = {
// idle-orbit (nodes hold position so the map stays readable)
}
/** The two projections the top-right toggle tweens between: '2d' is the
* original flat radial map (top-down, orthographic); '3d' is the depth view. */
const MODES = {
'2d': { tilt: -1.45, persp: 18 },
'3d': { tilt: -0.5, persp: 3.2 },
} as const
type MapMode = keyof typeof MODES
/** The two projections the toggle tweens between: '2d' is the original flat
* radial map (top-down, orthographic); '3d' is the depth view. Resolved via
* modeParams() — the 3D tilt steepens on portrait containers so the orbit
* reads as a tall ellipse instead of a squashed horizontal band. */
type MapMode = '2d' | '3d'
function modeParams(mode: MapMode): { tilt: number; persp: number } {
if (mode === '2d') return { tilt: -1.45, persp: 18 }
return { tilt: height > width ? -0.95 : -0.5, persp: 3.2 }
}
/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */
interface GlobeDot {
@@ -1076,9 +1078,12 @@ function computeFit(tilt: number, perspVal: number): { unit: number; centerY: nu
yMax = Math.max(yMax, y2 * s)
}
}
const marginX = 46 // node radius + label half-width clearance
const marginTop = 54 // legend / toggle chips
const marginBottom = 66 // hint line + node label below the front edge
// Compact containers (phones/companion) trade breathing room for scene
// size — the map must fill the screen to stay readable.
const compact = width < 480 || height < 480
const marginX = compact ? 22 : 46 // node radius + label half-width clearance
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)
@@ -1091,7 +1096,7 @@ function computeFit(tilt: number, perspVal: number): { unit: number; centerY: nu
* Tweens once the scene is live so the reframe glides instead of jumping. */
function refit() {
if (!modeInitialized) return
const m = MODES[viewMode.value]
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
gsap.killTweensOf(fit)
@@ -1105,7 +1110,7 @@ function refit() {
/** Snap (or tween, via setMode) the camera + fit to the given mode. */
function applyMode(mode: MapMode, animate: boolean) {
const target = MODES[mode]
const target = modeParams(mode)
const targetFit = computeFit(target.tilt, target.persp)
for (const t of modeTween) t.kill()
modeTween = []
@@ -1459,6 +1464,19 @@ onActivated(() => { if (built && !staticMode) attachTicker() })
padding: 5px 10px;
gap: 8px;
}
/* Toggle drops to bottom-centre on mobile — thumb reach, and it frees the
top edge so the scene can climb higher */
.node-map-mode-toggle {
top: auto;
right: auto;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
}
.node-map-hint {
bottom: 44px;
font-size: 10px;
}
}
</style>