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.untrusted }"></span>Untrusted</span>
</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">
<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>
@@ -62,12 +75,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'
* ------------------------------------------------------------------ */
const cam = {
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
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)
}
/** 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. */
interface GlobeDot {
el: SVGCircleElement
@@ -104,16 +126,17 @@ let ringsLayer: SVGGElement | null = null
let width = 0
let height = 0
let unit = 1
let centerY = 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 }
let elapsed = 0
let frame = 0
let selfDots: GlobeDot[] = []
let selfRadiusPx = 15
let modeTween: gsap.core.Tween[] = []
let modeInitialized = false
/** Perspective strength — measure() sets it per form factor: 3.2 gives the
* desktop scene real depth; a large value is near-orthographic (flat). */
let persp = 3.2
const viewMode = ref<MapMode>('3d')
let resizeObserver: ResizeObserver | null = null
let intro: gsap.core.Timeline | 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 y2 = y * ct - z1 * st
const z2 = y * st + z1 * ct
const s = persp / (persp + z2)
const s = cam.persp / (cam.persp + z2)
return {
x: width / 2 + x1 * unit * s * cam.zoom,
y: centerY + y2 * unit * s * cam.zoom,
x: width / 2 + x1 * fit.unit * s * cam.zoom,
y: fit.centerY + y2 * fit.unit * s * cam.zoom,
s,
z: z2,
}
@@ -511,12 +534,21 @@ let dragDistance = 0
function onPointerDown(e: PointerEvent) {
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
lastX = e.clientX
dragVel = 0
dragDistance = 0
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) {
@@ -534,13 +566,14 @@ function onClickCapture(e: MouseEvent) {
if (dragDistance > 6) {
e.stopPropagation()
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
dragging = false
try { containerRef.value?.releasePointerCapture(e.pointerId) } catch { /* already released */ }
// Inertia: carry the fling velocity, then settle to a full stop — the
// scene never idle-orbits on its own.
cam.spin = Math.max(-3, Math.min(3, dragVel))
@@ -549,28 +582,15 @@ function onPointerUp(e: PointerEvent) {
/* ----------------------------- lifecycle --------------------------- */
function measure() {
const c = containerRef.value
if (!c) return
width = c.clientWidth
height = c.clientHeight
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
// 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.
/** Auto-fit for a given projection: sample the outermost orbit through the
* 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 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 } {
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 yMin = Infinity
let yMax = -Infinity
@@ -581,22 +601,68 @@ function measure() {
for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude
const y2 = y * ct - z * st
const z2 = y * st + z * ct
const s = persp / (persp + z2)
const s = perspVal / (perspVal + z2)
maxAbsX = Math.max(maxAbsX, Math.abs(x * s))
yMin = Math.min(yMin, y2 * s)
yMax = Math.max(yMax, y2 * s)
}
}
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 bandH = Math.max(80, height - marginTop - marginBottom)
const unitX = (width / 2 - marginX) / maxAbsX
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
centerY = marginTop + bandH / 2 - ((yMax + yMin) / 2) * unit
render()
return { unit: u, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * u }
}
/** 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({
@@ -633,10 +699,11 @@ onMounted(() => {
if (containerRef.value) {
resizeObserver.observe(containerRef.value)
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)
// 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()
intro?.kill()
spinTween?.kill()
for (const t of modeTween) t.kill()
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
@@ -712,6 +783,38 @@ watch(graphSignature, () => rebuild(false))
border-radius: 9999px;
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 {
position: absolute;
bottom: 10px;
@@ -760,16 +863,9 @@ watch(graphSignature, () => rebuild(false))
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at
runtime, so Vue's scoped-style data attributes never land on them. -->
<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) {
.node-map-stage text.nm-label {
font-size: 12.5px;
font-size: 10px;
}
}
</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);
}
}
/* 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>
</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 -->
<div v-if="mapActive" class="flex-1 min-h-0">
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
@@ -250,6 +259,7 @@ import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
import FederationHeader from './federation/FederationHeader.vue'
import DidCardMobile from './federation/DidCardMobile.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.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>
<!-- Mobile: DID below title -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 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>
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
Federation.vue below the view tabs (hidden on the map tab). -->
</div>
</template>