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:
co-authored by
Claude Fable 5
parent
7fbdabf136
commit
fa14f6ebaa
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user