feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish

- Small glass 2D/3D toggle top-right of the map: tweens tilt/perspective/fit
  (0.9s inOut) so the scene folds flat into the original radial 2D layout and
  back. Default: portrait/mobile opens 2D, desktop 3D; last choice saved to
  localStorage.
- Fix: setPointerCapture retargeted pointerup to the container, suppressing
  click synthesis on children — node taps and the toggle never fired. Drag
  now tracks via window listeners, no capture.
- Fix: stale post-drag distance made the click-suppressor swallow toggle taps
  indefinitely ('stuck' toggle). Distance resets on every pointerdown and the
  suppressor is one-shot.
- Map no longer slides under the floating mobile back pill: the
  mobile-scroll-pad-back panel keeps its full 64px clearance when filled.
- Mobile DID copy/rotate card moved out of the header to below the view tabs
  (new DidCardMobile.vue) and hidden on the Network Map tab.
- Node labels: dark stroke halo removed; 10px on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 17:31:07 -04:00
co-authored by Claude Fable 5
parent aea90984d6
commit e0aebf50f8
5 changed files with 205 additions and 58 deletions
@@ -13,6 +13,19 @@
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span> <span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span> <span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
</div> </div>
<!-- 2D/3D projection toggle tweens the camera between the flat radial
layout and the depth view -->
<div class="node-map-mode-toggle" role="group" aria-label="Map projection">
<button
v-for="m in (['2d', '3d'] as const)"
:key="m"
class="node-map-mode-btn"
:class="{ 'node-map-mode-btn-active': viewMode === m }"
:aria-pressed="viewMode === m"
@click="setMode(m)"
>{{ m.toUpperCase() }}</button>
</div>
<div v-if="peerCount === 0" class="node-map-empty"> <div v-if="peerCount === 0" class="node-map-empty">
<p class="node-map-empty-title">No peers yet</p> <p class="node-map-empty-title">No peers yet</p>
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p> <p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
@@ -62,12 +75,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
const cam = { const cam = {
rotY: -1.1, // intro starts swung around; settles to 0 rotY: -1.1, // intro starts swung around; settles to 0
tilt: -0.5, // fixed downward tilt so the orbit reads as a plane tilt: -0.5, // downward tilt; tweened between the 2D and 3D projections
persp: 3.2, // perspective strength; large = near-orthographic (flat 2D)
zoom: 0.82, // intro dollies in to 1 zoom: 0.82, // intro dollies in to 1
spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not
// idle-orbit (nodes hold position so the map stays readable) // 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
/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */ /** One dot on a node's point-cloud sphere, in unit-sphere local coords. */
interface GlobeDot { interface GlobeDot {
el: SVGCircleElement el: SVGCircleElement
@@ -104,16 +126,17 @@ let ringsLayer: SVGGElement | null = null
let width = 0 let width = 0
let height = 0 let height = 0
let unit = 1 /** Scene scale + vertical centring — tweened alongside cam.tilt/persp during
let centerY = 0 * a 2D/3D toggle so the layout re-fits as it folds. */
const fit = { unit: 1, centerY: 0 }
let elapsed = 0 let elapsed = 0
let frame = 0 let frame = 0
let selfDots: GlobeDot[] = [] let selfDots: GlobeDot[] = []
let selfRadiusPx = 15 let selfRadiusPx = 15
let modeTween: gsap.core.Tween[] = []
let modeInitialized = false
/** Perspective strength — measure() sets it per form factor: 3.2 gives the const viewMode = ref<MapMode>('3d')
* desktop scene real depth; a large value is near-orthographic (flat). */
let persp = 3.2
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let intro: gsap.core.Timeline | null = null let intro: gsap.core.Timeline | null = null
let spinTween: gsap.core.Tween | null = null let spinTween: gsap.core.Tween | null = null
@@ -157,10 +180,10 @@ function project(x: number, y: number, z: number) {
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt) const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
const y2 = y * ct - z1 * st const y2 = y * ct - z1 * st
const z2 = y * st + z1 * ct const z2 = y * st + z1 * ct
const s = persp / (persp + z2) const s = cam.persp / (cam.persp + z2)
return { return {
x: width / 2 + x1 * unit * s * cam.zoom, x: width / 2 + x1 * fit.unit * s * cam.zoom,
y: centerY + y2 * unit * s * cam.zoom, y: fit.centerY + y2 * fit.unit * s * cam.zoom,
s, s,
z: z2, z: z2,
} }
@@ -511,12 +534,21 @@ let dragDistance = 0
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (staticMode) return if (staticMode) return
// Reset BEFORE the toggle early-return: a stale post-drag distance would
// otherwise make onClickCapture swallow toggle taps forever ("stuck" toggle)
dragDistance = 0
// Never hijack the projection toggle's taps
if ((e.target as Element | null)?.closest?.('.node-map-mode-toggle')) return
dragging = true dragging = true
lastX = e.clientX lastX = e.clientX
dragVel = 0 dragVel = 0
dragDistance = 0 dragDistance = 0
spinTween?.kill() spinTween?.kill()
containerRef.value?.setPointerCapture(e.pointerId) // Deliberately NO setPointerCapture: capture retargets pointerup to the
// container, which suppresses the browser's click synthesis on child
// elements — it silently killed node taps and the 2D/3D toggle. Drag
// continuity outside the container comes from window-level move/up
// listeners instead.
} }
function onPointerMove(e: PointerEvent) { function onPointerMove(e: PointerEvent) {
@@ -534,13 +566,14 @@ function onClickCapture(e: MouseEvent) {
if (dragDistance > 6) { if (dragDistance > 6) {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
// One-shot: only the click synthesized from THIS drag is swallowed
dragDistance = 0
} }
} }
function onPointerUp(e: PointerEvent) { function onPointerUp(_e: PointerEvent) {
if (!dragging) return if (!dragging) return
dragging = false dragging = false
try { containerRef.value?.releasePointerCapture(e.pointerId) } catch { /* already released */ }
// Inertia: carry the fling velocity, then settle to a full stop — the // Inertia: carry the fling velocity, then settle to a full stop — the
// scene never idle-orbits on its own. // scene never idle-orbits on its own.
cam.spin = Math.max(-3, Math.min(3, dragVel)) cam.spin = Math.max(-3, Math.min(3, dragVel))
@@ -549,28 +582,15 @@ function onPointerUp(e: PointerEvent) {
/* ----------------------------- lifecycle --------------------------- */ /* ----------------------------- lifecycle --------------------------- */
function measure() { /** Auto-fit for a given projection: sample the outermost orbit through the
const c = containerRef.value * camera math (rotation-invariant — a ring about the Y axis projects
if (!c) return * identically at any rotY) to get the scene's true projected bounds, then
width = c.clientWidth * scale to fill the container and centre vertically between the overlays.
height = c.clientHeight * Pure with respect to tilt/persp so a mode toggle can compute its target
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`) * fit and tween towards it. */
function computeFit(tilt: number, perspVal: number): { unit: number; centerY: number } {
// Portrait (phone/companion): go near top-down and near-orthographic so
// the map reads like the old 2D radial view — rings become circles, no
// perspective squash, maximum label spread. Desktop keeps the 3D depth.
const portrait = height > width
cam.tilt = portrait ? -1.35 : -0.5
persp = portrait ? 16 : 3.2
// Auto-fit: sample the outermost orbit through the real camera math
// (rotation-invariant — a ring about the Y axis projects identically at any
// rotY) to get the scene's true projected bounds, then scale to fill the
// container and centre vertically in the space between the overlays. This
// is what makes the map fill a phone, the companion, and a desktop panel
// equally well instead of assuming one aspect ratio.
const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1 const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt) const ct = Math.cos(tilt), st = Math.sin(tilt)
let maxAbsX = 0 let maxAbsX = 0
let yMin = Infinity let yMin = Infinity
let yMax = -Infinity let yMax = -Infinity
@@ -581,22 +601,68 @@ function measure() {
for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude
const y2 = y * ct - z * st const y2 = y * ct - z * st
const z2 = y * st + z * ct const z2 = y * st + z * ct
const s = persp / (persp + z2) const s = perspVal / (perspVal + z2)
maxAbsX = Math.max(maxAbsX, Math.abs(x * s)) maxAbsX = Math.max(maxAbsX, Math.abs(x * s))
yMin = Math.min(yMin, y2 * s) yMin = Math.min(yMin, y2 * s)
yMax = Math.max(yMax, y2 * s) yMax = Math.max(yMax, y2 * s)
} }
} }
const marginX = 46 // node radius + label half-width clearance const marginX = 46 // node radius + label half-width clearance
const marginTop = 54 // legend chip const marginTop = 54 // legend / toggle chips
const marginBottom = 66 // hint line + node label below the front edge const marginBottom = 66 // hint line + node label below the front edge
const bandH = Math.max(80, height - marginTop - marginBottom) const bandH = Math.max(80, height - marginTop - marginBottom)
const unitX = (width / 2 - marginX) / maxAbsX const unitX = (width / 2 - marginX) / maxAbsX
const unitY = bandH / Math.max(yMax - yMin, 0.001) const unitY = bandH / Math.max(yMax - yMin, 0.001)
unit = Math.max(20, Math.min(unitX, unitY)) const u = Math.max(20, Math.min(unitX, unitY))
// Place the projected ellipse's midpoint at the centre of the available band // Place the projected ellipse's midpoint at the centre of the available band
centerY = marginTop + bandH / 2 - ((yMax + yMin) / 2) * unit return { unit: u, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * u }
render() }
/** Snap (or tween, via setMode) the camera + fit to the given mode. */
function applyMode(mode: MapMode, animate: boolean) {
const target = MODES[mode]
const targetFit = computeFit(target.tilt, target.persp)
for (const t of modeTween) t.kill()
modeTween = []
if (animate && !staticMode) {
const opts = { duration: 0.9, ease: motionTokens.ease.inOut }
modeTween.push(
gsap.to(cam, { tilt: target.tilt, persp: target.persp, ...opts }),
gsap.to(fit, { ...targetFit, ...opts }),
)
} else {
cam.tilt = target.tilt
cam.persp = target.persp
fit.unit = targetFit.unit
fit.centerY = targetFit.centerY
render()
}
}
function setMode(mode: MapMode) {
if (viewMode.value === mode) return
viewMode.value = mode
try { localStorage.setItem('federation-map-projection', mode) } catch { /* private mode */ }
applyMode(mode, true)
}
function measure() {
const c = containerRef.value
if (!c) return
width = c.clientWidth
height = c.clientHeight
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
// First measurement decides the default projection: saved preference wins,
// otherwise portrait containers (phone/companion) open in the flat 2D view.
if (!modeInitialized) {
modeInitialized = true
let saved: string | null = null
try { saved = localStorage.getItem('federation-map-projection') } catch { /* private mode */ }
viewMode.value = saved === '2d' || saved === '3d' ? saved : (height > width ? '2d' : '3d')
}
// Resize: snap to the current mode's fit (no tween — tracks the drag)
applyMode(viewMode.value, false)
} }
const graphSignature = computed(() => JSON.stringify({ const graphSignature = computed(() => JSON.stringify({
@@ -633,10 +699,11 @@ onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
resizeObserver.observe(containerRef.value) resizeObserver.observe(containerRef.value)
containerRef.value.addEventListener('pointerdown', onPointerDown) containerRef.value.addEventListener('pointerdown', onPointerDown)
containerRef.value.addEventListener('pointermove', onPointerMove)
containerRef.value.addEventListener('pointerup', onPointerUp)
containerRef.value.addEventListener('pointercancel', onPointerUp)
containerRef.value.addEventListener('click', onClickCapture, true) containerRef.value.addEventListener('click', onClickCapture, true)
// Window-level so a drag that leaves the container keeps tracking
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
} }
}) })
@@ -644,7 +711,11 @@ onUnmounted(() => {
detachTicker() detachTicker()
intro?.kill() intro?.kill()
spinTween?.kill() spinTween?.kill()
for (const t of modeTween) t.kill()
resizeObserver?.disconnect() resizeObserver?.disconnect()
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
}) })
// KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return // KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return
@@ -712,6 +783,38 @@ watch(graphSignature, () => rebuild(false))
border-radius: 9999px; border-radius: 9999px;
display: inline-block; display: inline-block;
} }
.node-map-mode-toggle {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 2px;
padding: 3px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.node-map-mode-btn {
/* min-height !important: the global ≤767px 44px touch-target rule would
deform this deliberately-compact control */
min-height: 0 !important;
padding: 3px 10px;
border-radius: 9999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.5);
transition: color 0.2s ease, background-color 0.2s ease;
}
.node-map-mode-btn:hover {
color: rgba(255, 255, 255, 0.85);
}
.node-map-mode-btn-active {
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.95);
}
.node-map-hint { .node-map-hint {
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;
@@ -760,16 +863,9 @@ watch(graphSignature, () => rebuild(false))
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at <!-- Unscoped on purpose: the SVG labels are created with createElementNS at
runtime, so Vue's scoped-style data attributes never land on them. --> runtime, so Vue's scoped-style data attributes never land on them. -->
<style> <style>
/* Dark halo behind node labels — keeps them legible over dots and links */
.node-map-stage text.nm-label {
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.6);
stroke-width: 2.5px;
stroke-linejoin: round;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.node-map-stage text.nm-label { .node-map-stage text.nm-label {
font-size: 12.5px; font-size: 10px;
} }
} }
</style> </style>
+9
View File
@@ -3187,3 +3187,12 @@ select::-ms-expand {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
} }
} }
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
its full clearance under the filled map so the stage never slides beneath
the button. */
@media (max-width: 920px) {
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
}
}
+10
View File
@@ -35,6 +35,15 @@
</button> </button>
</div> </div>
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
vertical space belongs to the map (desktop keeps the header card) -->
<DidCardMobile
v-if="!mapActive"
:self-did="selfDid"
:server-name="appStore.serverName"
@rotate="showRotateModal = true"
/>
<!-- Network Map View fills all remaining height to the bottom edge --> <!-- Network Map View fills all remaining height to the bottom edge -->
<div v-if="mapActive" class="flex-1 min-h-0"> <div v-if="mapActive" class="flex-1 min-h-0">
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" /> <NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
@@ -250,6 +259,7 @@ import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync' import { useSyncStore } from '@/stores/sync'
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue' import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
import FederationHeader from './federation/FederationHeader.vue' import FederationHeader from './federation/FederationHeader.vue'
import DidCardMobile from './federation/DidCardMobile.vue'
import RotateDidModal from './federation/RotateDidModal.vue' import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue' import QuickActions from './federation/QuickActions.vue'
import NodeList from './federation/NodeList.vue' import NodeList from './federation/NodeList.vue'
@@ -0,0 +1,39 @@
<template>
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
Federation.vue (not in the header) and is hidden by the parent on the
Network Map tab, where vertical space belongs to the map. -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
const props = defineProps<{
selfDid: string
serverName: string
}>()
defineEmits<{
rotate: []
}>()
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
function handleCopy() {
if (props.selfDid) {
safeClipboardWrite(props.selfDid)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
}
</script>
@@ -18,15 +18,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile: DID below title --> <!-- Mobile DID card moved to DidCardMobile.vue, rendered by
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3"> Federation.vue below the view tabs (hidden on the map tab). -->
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</div> </div>
</template> </template>