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
Generated
+7
@@ -16,6 +16,7 @@
|
|||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
@@ -7293,6 +7294,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
// 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
|
// `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
|
// 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
|
// this plan's scope). This file therefore only covers the Leaflet map's
|
||||||
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
||||||
// vacuously satisfied (there is nothing to leak).
|
// 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 {
|
select::-ms-expand {
|
||||||
display: none;
|
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>
|
<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
|
<FederationHeader
|
||||||
:self-did="selfDid"
|
:self-did="selfDid"
|
||||||
:server-name="appStore.serverName"
|
:server-name="appStore.serverName"
|
||||||
@@ -30,9 +33,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Map View -->
|
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||||
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||||
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="activeView === 'list'">
|
<template v-if="activeView === 'list'">
|
||||||
@@ -243,7 +246,7 @@ import { useCachedResource } from '@/composables/useCachedResource'
|
|||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useSyncStore } from '@/stores/sync'
|
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 FederationHeader from './federation/FederationHeader.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'
|
||||||
@@ -308,6 +311,15 @@ function setView(id: ViewId) {
|
|||||||
localStorage.setItem('federation-view', id)
|
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 selfDid = ref('')
|
||||||
|
|
||||||
const mapNodes = computed(() => {
|
const mapNodes = computed(() => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
// 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
|
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||||
// Mesh.vue's component tree; the only D3 force simulation belongs to
|
// 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
|
// file therefore only covers the six cached fetch groups (Task 1) and the
|
||||||
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
||||||
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
||||||
|
|||||||
Reference in New Issue
Block a user