feat(federation): GSAP-powered 3D orbital node map, fills viewport to bottom

- Add gsap 3.15 + design-system-aware motion module (src/utils/motion.ts):
  shared colour/duration/ease tokens mirrored from style.css, gsap.defaults,
  live prefers-reduced-motion check.
- Replace the d3 force NetworkMap with NetworkMap3D.vue: peers on projected
  3D orbital rings around the self node, cinematic intro (camera dolly +
  staggered fly-in + ring draw), idle rotation with drag-to-orbit inertia,
  depth-sorted painter's order, trust-colour palette, online/offline states,
  sonar pulse on self, tap-a-node opens the detail modal.
- Map view now fills the dashboard panel to the bottom edge on desktop,
  mobile and companion: .dashboard-scroll-panel:has(.node-map-stage) turns
  the panel into a column (tab-bar/safe-area/audio-player aware padding)
  instead of leaving the old dead bottom margin.
- Reduced motion: intro/idle skipped, scene renders static.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 17:09:09 -04:00
co-authored by Claude Fable 5
parent 7fbdabf136
commit fa14f6ebaa
9 changed files with 770 additions and 189 deletions
+7
View File
@@ -16,6 +16,7 @@
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
@@ -7293,6 +7294,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+1
View File
@@ -33,6 +33,7 @@
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
@@ -12,7 +12,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
// this plan's scope). This file therefore only covers the Leaflet map's
// activate/deactivate lifecycle — the D3-specific truths from the plan are
// vacuously satisfied (there is nothing to leak).
@@ -1,182 +0,0 @@
<template>
<div ref="containerRef" class="network-map-container">
<svg ref="svgRef" class="w-full h-full"></svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
type SimNode = MapNode & d3.SimulationNodeDatum
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
case 'observer': return '#fb923c'
case 'untrusted': return '#ef4444'
default: return '#9ca3af'
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
const container = containerRef.value!
const width = container.clientWidth
const height = container.clientHeight
svg.attr('viewBox', `0 0 ${width} ${height}`)
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
// Center the self-node
const selfNode = simNodes.find(n => n.is_self)
if (selfNode) {
selfNode.fx = width / 2
selfNode.fy = height / 2
}
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
const g = svg.append('g')
// Links
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
})
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
})
// Node groups
const node = g.append('g')
.selectAll<SVGGElement, SimNode>('g')
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0)
if (!d.is_self) { d.fx = null; d.fy = null }
})
)
// Node circles
node.append('circle')
.attr('r', d => nodeRadius(d))
.attr('fill', d => trustColor(d.trust_level))
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
// Node labels
node.append('text')
.text(d => d.label)
.attr('dy', d => nodeRadius(d) + 14)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.7)')
.attr('font-size', '11px')
.attr('font-family', "'Avenir Next', sans-serif")
// Tooltip
node.append('title')
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
}
onMounted(() => {
render()
resizeObserver = new ResizeObserver(() => render())
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
simulation?.stop()
resizeObserver?.disconnect()
})
watch(graphSignature, () => render())
</script>
<style scoped>
.network-map-container {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
min-height: 400px;
width: 100%;
}
</style>
@@ -0,0 +1,636 @@
<template>
<div
ref="containerRef"
class="node-map-stage"
role="img"
:aria-label="`Federation map: ${peerCount} peer${peerCount === 1 ? '' : 's'}`"
>
<svg ref="svgRef" class="node-map-svg"></svg>
<!-- Legend + count overlay (HTML, not SVG, so it stays crisp and glass-styled) -->
<div class="node-map-legend" aria-hidden="true">
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</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>
</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>
</div>
<p class="node-map-hint" aria-hidden="true">Drag to orbit · tap a node for details</p>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'
export interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
export interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const emit = defineEmits<{
(e: 'select', did: string): void
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
const peerCount = computed(() => props.nodes.filter(n => !n.is_self).length)
const SVG_NS = 'http://www.w3.org/2000/svg'
/* ------------------------------------------------------------------ *
* Scene state — mutated by GSAP tweens and read by the per-frame
* render pass. Nothing here is Vue-reactive on purpose: the ticker
* repaints at 60fps and must not churn the reactivity graph.
* ------------------------------------------------------------------ */
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
zoom: 1.35, // intro dolly-in target is 1
spin: 0.05, // idle rad/s; drag inertia tweens through this
}
interface PeerVis {
node: MapNode
angle: number
ring: number
ringRadius: number
yJitter: number
bobPhase: number
/** Intro/arrival progress 0→1: drives fly-in offset, scale, opacity. */
p: number
el: SVGGElement
core: SVGCircleElement
halo: SVGCircleElement
label: SVGTextElement
linkEl: SVGPathElement | null
depth: number
}
let peers: PeerVis[] = []
let selfEl: SVGGElement | null = null
const selfState = { p: 0 }
let ringPaths: { el: SVGPathElement; radius: number }[] = []
let nodesLayer: SVGGElement | null = null
let linksLayer: SVGGElement | null = null
let ringsLayer: SVGGElement | null = null
let width = 0
let height = 0
let unit = 1
let elapsed = 0
let resizeObserver: ResizeObserver | null = null
let intro: gsap.core.Timeline | null = null
let spinTween: gsap.core.Tween | null = null
let tickerAttached = false
let staticMode = false
function trustColor(n: MapNode): string {
switch (n.trust_level) {
case 'trusted': return motionTokens.color.trusted
case 'observer': return motionTokens.color.observer
case 'untrusted': return motionTokens.color.untrusted
default: return motionTokens.color.neutral
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 15 : Math.max(8, Math.min(13, 7 + n.app_count * 0.5))
}
/** Ring layout: first 8 peers on the inner orbit, next 14 on a wider one,
* rest beyond. `countInRing` is how many peers actually landed on that ring
* (given `total` peers), so angle spacing is always even. */
const RING_CAPACITIES = [8, 14, 22]
function ringFor(i: number, total: number): { ring: number; indexInRing: number; countInRing: number } {
let start = 0
for (let r = 0; r < RING_CAPACITIES.length; r++) {
const cap = RING_CAPACITIES[r] ?? 8
if (i < start + cap) {
return { ring: r, indexInRing: i - start, countInRing: Math.min(cap, total - start) }
}
start += cap
}
return { ring: RING_CAPACITIES.length, indexInRing: i - start, countInRing: Math.max(total - start, 1) }
}
/** Project a world-space point through the camera. Returns screen coords + depth scale. */
function project(x: number, y: number, z: number) {
const cr = Math.cos(cam.rotY), sr = Math.sin(cam.rotY)
const x1 = x * cr + z * sr
const z1 = -x * sr + z * cr
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
const y2 = y * ct - z1 * st
const z2 = y * st + z1 * ct
const persp = 3.2
const s = persp / (persp + z2)
return {
x: width / 2 + x1 * unit * s * cam.zoom,
y: height / 2 + y2 * unit * s * cam.zoom,
s,
z: z2,
}
}
function clearScene() {
const svg = svgRef.value
if (svg) while (svg.firstChild) svg.removeChild(svg.firstChild)
peers = []
ringPaths = []
selfEl = null
nodesLayer = null
linksLayer = null
ringsLayer = null
}
function el<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string> = {}): SVGElementTagNameMap[K] {
const e = document.createElementNS(SVG_NS, tag)
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v)
return e
}
/** Deterministic pseudo-random in [0,1) from an index — stable across renders
* so nodes keep their orbit slot between data refreshes. */
function hash01(i: number): number {
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453
return x - Math.floor(x)
}
function buildScene() {
const svg = svgRef.value
if (!svg) return
clearScene()
ringsLayer = el('g')
linksLayer = el('g')
nodesLayer = el('g')
svg.appendChild(ringsLayer)
svg.appendChild(linksLayer)
svg.appendChild(nodesLayer)
const peerNodes = props.nodes.filter(n => !n.is_self)
const selfNode = props.nodes.find(n => n.is_self)
// Orbit guide rings — drawn per-frame as projected ellipses; they are what
// makes the plane read as 3D. Only draw rings that hold peers.
const usedRings = new Set(peerNodes.map((_, i) => ringFor(i, peerNodes.length).ring))
for (const r of usedRings) {
const radius = 1 + r * 0.65
const p = el('path', {
fill: 'none',
stroke: motionTokens.color.lineFaint,
'stroke-width': '1',
opacity: '0',
})
ringsLayer.appendChild(p)
ringPaths.push({ el: p, radius })
}
// Self node: layered halo + pulse ring + core + label
if (selfNode) {
const g = el('g', { cursor: 'pointer' }) as SVGGElement
const r = nodeRadius(selfNode)
const halo = el('circle', { r: String(r * 2.2), fill: motionTokens.color.accent, opacity: '0.10' })
const pulse = el('circle', { r: String(r), fill: 'none', stroke: motionTokens.color.accent, 'stroke-width': '1.5', opacity: '0.6' })
const core = el('circle', { r: String(r), fill: motionTokens.color.accent, 'fill-opacity': '0.9', stroke: '#fff', 'stroke-width': '1.5', 'stroke-opacity': '0.5' })
const label = el('text', {
dy: String(r + 18),
'text-anchor': 'middle',
fill: motionTokens.color.textPrimary,
'font-size': '12px',
'font-weight': '600',
'font-family': "'Avenir Next', system-ui, sans-serif",
})
label.textContent = selfNode.label || 'This node'
const title = el('title')
title.textContent = `${selfNode.did}\nThis node`
g.append(halo, pulse, core, label, title)
g.addEventListener('click', () => emit('select', selfNode.did))
nodesLayer.appendChild(g)
selfEl = g
// Sonar pulse — repeats forever, cheap (2 attrs on one circle)
if (!staticMode) {
gsap.fromTo(pulse,
{ attr: { r: r } , opacity: 0.55 },
{ attr: { r: r * 2.6 }, opacity: 0, duration: 2.4, repeat: -1, ease: 'sine.out', repeatDelay: 0.6 })
}
}
// Peers + their link back to self
peerNodes.forEach((node, i) => {
const { ring, indexInRing, countInRing } = ringFor(i, peerNodes.length)
const ringRadius = 1 + ring * 0.65
// Even spacing around the ring, offset per ring so rings interleave
const angle = (indexInRing / countInRing) * Math.PI * 2 + ring * 0.5
const r = nodeRadius(node)
const color = trustColor(node)
const g = el('g', { cursor: 'pointer' }) as SVGGElement
const halo = el('circle', { r: String(r * 1.9), fill: color, opacity: node.online ? '0.12' : '0.04' })
const core = el('circle', {
r: String(r),
fill: color,
'fill-opacity': node.online ? '0.85' : '0.25',
stroke: color,
'stroke-width': '1.5',
'stroke-opacity': node.online ? '1' : '0.4',
'stroke-dasharray': node.online ? 'none' : '3 3',
})
const label = el('text', {
dy: String(r + 15),
'text-anchor': 'middle',
fill: motionTokens.color.textSecondary,
'font-size': '11px',
'font-family': "'Avenir Next', system-ui, sans-serif",
})
label.textContent = node.label
const title = el('title')
title.textContent = `${node.did}\nApps: ${node.app_count}\n${node.online ? 'Online' : 'Offline'}`
g.append(halo, core, label, title)
g.addEventListener('click', () => emit('select', node.did))
nodesLayer!.appendChild(g)
const hasLink = props.links.some(l => l.target === node.did || l.source === node.did)
let linkEl: SVGPathElement | null = null
if (hasLink) {
linkEl = el('path', {
fill: 'none',
stroke: node.online ? color : motionTokens.color.neutral,
'stroke-width': '1.5',
'stroke-opacity': node.online ? '0.35' : '0.15',
'stroke-dasharray': node.online ? 'none' : '5 4',
})
linksLayer!.appendChild(linkEl)
}
peers.push({
node,
angle,
ring,
ringRadius,
yJitter: (hash01(i) - 0.5) * 0.22,
bobPhase: hash01(i + 100) * Math.PI * 2,
p: 0,
el: g,
core,
halo,
label,
linkEl,
depth: 0,
})
})
}
/** One frame: project every element through the camera and write attrs. */
function render() {
if (!width || !height) return
// Orbit guide rings
for (const ring of ringPaths) {
const steps = 72
let d = ''
for (let s = 0; s <= steps; s++) {
const a = (s / steps) * Math.PI * 2
const pt = project(Math.cos(a) * ring.radius, 0, Math.sin(a) * ring.radius)
d += (s === 0 ? 'M' : 'L') + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1)
}
ring.el.setAttribute('d', d)
}
// Self node at origin
if (selfEl) {
const pt = project(0, 0, 0)
const s = pt.s * cam.zoom * selfState.p
selfEl.setAttribute('transform', `translate(${pt.x},${pt.y}) scale(${Math.max(s, 0.001)})`)
selfEl.setAttribute('opacity', String(selfState.p))
}
const origin = project(0, 0, 0)
for (const peer of peers) {
// Fly-in: distance multiplier eases 1.9 → 1 as p goes 0 → 1
const dist = peer.ringRadius * (1.9 - 0.9 * peer.p)
const bob = staticMode ? 0 : Math.sin(elapsed * 0.9 + peer.bobPhase) * 0.05
const x = Math.cos(peer.angle) * dist
const z = Math.sin(peer.angle) * dist
const y = peer.yJitter + bob
const pt = project(x, y, z)
peer.depth = pt.z
const depthDim = 0.55 + 0.45 * Math.min(1, Math.max(0, (pt.s - 0.7) / 0.6))
const s = pt.s * cam.zoom * (0.4 + 0.6 * peer.p)
peer.el.setAttribute('transform', `translate(${pt.x},${pt.y}) scale(${Math.max(s, 0.001)})`)
peer.el.setAttribute('opacity', String(peer.p * depthDim))
peer.label.setAttribute('opacity', String(pt.s > 0.85 ? 1 : Math.max(0, (pt.s - 0.55) / 0.3)))
if (peer.linkEl) {
// Curved link: sag toward the plane midpoint for an orbital feel
const mid = project(x * 0.5, y * 0.5 - 0.12, z * 0.5)
peer.linkEl.setAttribute('d', `M${origin.x.toFixed(1)} ${origin.y.toFixed(1)} Q${mid.x.toFixed(1)} ${mid.y.toFixed(1)} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`)
peer.linkEl.setAttribute('opacity', String(peer.p))
}
}
// Painter's order: farthest first so near nodes overlap far ones. The self
// node sits at z=0 (mid-plane) and takes part in the same sort, so peers
// orbiting in front genuinely pass over it. Only touch the DOM when the
// order actually changed.
if (nodesLayer && peers.length > 0) {
const drawables: { el: SVGGElement; depth: number }[] = peers.map(p => ({ el: p.el, depth: p.depth }))
if (selfEl) drawables.push({ el: selfEl, depth: 0 })
drawables.sort((a, b) => b.depth - a.depth)
let dirty = false
const children = nodesLayer.children
for (let i = 0; i < drawables.length; i++) {
if (children[i] !== drawables[i]?.el) { dirty = true; break }
}
if (dirty) for (const d of drawables) nodesLayer.appendChild(d.el)
}
}
function tick(_time: number, deltaMS: number) {
elapsed += deltaMS / 1000
if (!dragging) cam.rotY += cam.spin * (deltaMS / 1000)
render()
}
function attachTicker() {
if (tickerAttached || staticMode) return
gsap.ticker.add(tick)
tickerAttached = true
}
function detachTicker() {
if (!tickerAttached) return
gsap.ticker.remove(tick)
tickerAttached = false
}
/* ----------------------------- intro ------------------------------ */
function playIntro() {
intro?.kill()
if (staticMode) {
// Reduced motion: no dolly, no stagger — everything lands in place.
cam.rotY = 0
cam.zoom = 1
selfState.p = 1
for (const p of peers) p.p = 1
for (const r of ringPaths) r.el.setAttribute('opacity', '1')
render()
return
}
cam.rotY = -1.1
cam.zoom = 1.35
selfState.p = 0
for (const p of peers) p.p = 0
intro = gsap.timeline()
intro
.to(cam, { rotY: 0, zoom: 1, duration: motionTokens.duration.cinematic, ease: motionTokens.ease.out }, 0)
.to(selfState, { p: 1, duration: 0.55, ease: motionTokens.ease.arrive }, 0.15)
.to(ringPaths.map(r => r.el), { opacity: 1, duration: 0.9, ease: motionTokens.ease.inOut, stagger: 0.12 }, 0.3)
if (peers.length) {
intro.to(peers, {
p: 1,
duration: 0.7,
ease: motionTokens.ease.arrive,
stagger: { each: 0.07, from: 'random' },
}, 0.45)
}
}
/* --------------------------- interaction --------------------------- */
let dragging = false
let lastX = 0
let dragVel = 0
let dragDistance = 0
function onPointerDown(e: PointerEvent) {
if (staticMode) return
dragging = true
lastX = e.clientX
dragVel = 0
dragDistance = 0
spinTween?.kill()
containerRef.value?.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return
const dx = e.clientX - lastX
lastX = e.clientX
dragDistance += Math.abs(dx)
cam.rotY += dx * 0.006
dragVel = dx * 0.006 * 60 // approx rad/s
}
/** Swallow the click that follows a real drag so releasing an orbit fling
* over a node doesn't open its detail modal. */
function onClickCapture(e: MouseEvent) {
if (dragDistance > 6) {
e.stopPropagation()
e.preventDefault()
}
}
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 back to the idle spin
cam.spin = Math.max(-3, Math.min(3, dragVel))
spinTween = gsap.to(cam, { spin: 0.05, duration: 1.6, ease: 'power2.out' })
}
/* ----------------------------- lifecycle --------------------------- */
function measure() {
const c = containerRef.value
if (!c) return
width = c.clientWidth
height = c.clientHeight
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
// World unit: outermost ring must fit inside the container with margin
const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1
unit = (Math.min(width, height * 1.45) / 2 - 60) / maxRing
render()
}
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
let hasIntroPlayed = false
function rebuild(replayIntro: boolean) {
staticMode = prefersReducedMotion()
buildScene()
measure()
if (replayIntro || !hasIntroPlayed) {
playIntro()
hasIntroPlayed = true
} else {
// Data refresh mid-session: pop new elements in without the full dolly
selfState.p = 1
if (staticMode) {
for (const p of peers) p.p = 1
} else {
gsap.to(peers, { p: 1, duration: 0.5, ease: motionTokens.ease.arrive, stagger: 0.03 })
}
for (const r of ringPaths) r.el.setAttribute('opacity', '1')
render()
}
if (!staticMode) attachTicker()
}
onMounted(() => {
rebuild(true)
resizeObserver = new ResizeObserver(() => measure())
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)
}
})
onUnmounted(() => {
detachTicker()
intro?.kill()
spinTween?.kill()
resizeObserver?.disconnect()
})
// KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return
onDeactivated(() => detachTicker())
onActivated(() => { if (!staticMode) attachTicker() })
watch(graphSignature, () => rebuild(false))
</script>
<style scoped>
.node-map-stage {
position: relative;
width: 100%;
height: 100%;
min-height: 320px;
background:
radial-gradient(ellipse at 50% 42%, rgba(251, 146, 60, 0.05), transparent 55%),
radial-gradient(ellipse at 50% 120%, rgba(255, 255, 255, 0.04), transparent 60%),
rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
overflow: hidden;
cursor: grab;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.node-map-stage:active {
cursor: grabbing;
}
.node-map-svg {
width: 100%;
height: 100%;
display: block;
}
.node-map-legend {
position: absolute;
top: 12px;
left: 12px;
display: flex;
gap: 10px;
padding: 6px 12px;
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);
pointer-events: none;
}
.node-map-legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.node-map-dot {
width: 7px;
height: 7px;
border-radius: 9999px;
display: inline-block;
}
.node-map-hint {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
pointer-events: none;
margin: 0;
}
.node-map-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 18%;
text-align: center;
pointer-events: none;
}
.node-map-empty-title {
font-size: 0.95rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
margin: 0 0 4px;
}
.node-map-empty-sub {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.45);
margin: 0;
max-width: 260px;
}
@media (max-width: 767px) {
.node-map-legend {
top: 8px;
left: 8px;
padding: 5px 10px;
gap: 8px;
}
}
</style>
+38
View File
@@ -3149,3 +3149,41 @@ select {
select::-ms-expand {
display: none;
}
/* =========================================================================
Federation 3D node map — fill-to-bottom layout
When the map stage is on screen, the dashboard scroll panel switches from
a scrolling document to a column that hands all remaining height to the
map, killing the big bottom margin on every form factor. List view (no
.node-map-stage in the DOM) is untouched, and browsers without :has()
gracefully fall back to the old scrolling behaviour via the stage's
min-height.
========================================================================= */
.dashboard-scroll-panel:has(.node-map-stage) {
display: flex;
flex-direction: column;
/* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
padding-bottom: 1rem;
}
/* The routed view stretches; DashboardRouterView tags it .view-container
(with Tailwind's flex-none, which this outranks on specificity). */
.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* The wrapper's bottom scroll spacer is dead weight in a filled column */
.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
display: none;
}
/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
not under it — the bar is viewport-fixed and would cover the map. */
@media (max-width: 920px) {
.dashboard-scroll-panel: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) + 12px);
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Design-system-aware GSAP setup — the single place animation code pulls
* timing, easing, and colour tokens from, so every GSAP-driven surface moves
* (and is coloured) like the rest of the glass UI instead of inventing its
* own physics per component.
*
* Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
* — never `import gsap from 'gsap'` directly, or the shared defaults are lost.
*/
import { gsap } from 'gsap'
/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
* dark-only (style.css pins `color-scheme: dark`), so these are constants,
* not theme-dependent lookups. */
export const motionTokens = {
color: {
/** Brand accent — the orange used for focus glows and highlights
* (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
accent: '#fb923c',
/** Trust-level palette — matches NodeList / trust badges. */
trusted: '#4ade80',
observer: '#fb923c',
untrusted: '#ef4444',
neutral: '#9ca3af',
/** Text/line opacities on the dark glass ground. */
textPrimary: 'rgba(255, 255, 255, 0.95)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textFaint: 'rgba(255, 255, 255, 0.45)',
line: 'rgba(255, 255, 255, 0.18)',
lineFaint: 'rgba(255, 255, 255, 0.08)',
glassDark: 'rgba(0, 0, 0, 0.35)',
glassDarker: 'rgba(0, 0, 0, 0.6)',
},
/** Durations (seconds) — align with the CSS transitions already shipped
* (modal 0.3s, press feedback 0.1s). */
duration: {
fast: 0.18,
base: 0.3,
slow: 0.6,
/** Scene-setting intros (map fly-in, hero moments). */
cinematic: 1.4,
},
ease: {
/** Default UI ease — matches the snappy glass feel. */
out: 'power3.out',
inOut: 'power2.inOut',
/** Playful overshoot for elements "arriving" (node pop-ins). */
arrive: 'back.out(1.6)',
/** Springy attention pulse. */
pulse: 'sine.inOut',
},
} as const
// Shared defaults: any tween that doesn't say otherwise moves like the rest
// of the design system.
gsap.defaults({
ease: motionTokens.ease.out,
duration: motionTokens.duration.base,
})
/** Live reduced-motion check. Query at animation-build time (not module
* scope) so OS-level toggles apply without a reload. Callers should skip
* intros / idle loops and jump to end state when this is true. */
export function prefersReducedMotion(): boolean {
return typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
}
export { gsap }
+17 -5
View File
@@ -1,5 +1,8 @@
<template>
<div class="pb-6">
<!-- Map view: no pb-6 the .dashboard-scroll-panel:has(.node-map-stage)
rules turn this view into a column that hands remaining height to the
map, so bottom padding would just re-create the dead margin. -->
<div :class="mapActive ? undefined : 'pb-6'">
<FederationHeader
:self-did="selfDid"
:server-name="appStore.serverName"
@@ -30,9 +33,9 @@
</button>
</div>
<!-- Network Map View -->
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
<!-- 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" />
</div>
<template v-if="activeView === 'list'">
@@ -243,7 +246,7 @@ import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap from '@/components/federation/NetworkMap.vue'
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
import FederationHeader from './federation/FederationHeader.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue'
@@ -308,6 +311,15 @@ function setView(id: ViewId) {
localStorage.setItem('federation-view', id)
}
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
/** Map click-through: tapping a peer opens the same detail modal as the list
* view. Tapping the self node is a no-op (its actions live in the header). */
function onMapSelect(did: string) {
const node = nodes.value.find(n => n.did === did)
if (node) selectedNode.value = node
}
const selfDid = ref('')
const mapNodes = computed(() => {
@@ -6,7 +6,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak).