diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue index 84dc9521..2747b5d4 100644 --- a/neode-ui/src/components/federation/NetworkMap3D.vue +++ b/neode-ui/src/components/federation/NetworkMap3D.vue @@ -63,8 +63,17 @@ const SVG_NS = 'http://www.w3.org/2000/svg' const cam = { rotY: -1.1, // intro starts swung around; settles to 0 tilt: -0.5, // fixed downward tilt so the orbit reads as a plane - zoom: 1.35, // intro dolly-in target is 1 - spin: 0.05, // idle rad/s; drag inertia tweens through this + zoom: 0.82, // intro dollies in to 1 + spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not + // idle-orbit (nodes hold position so the map stays readable) +} + +/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */ +interface GlobeDot { + el: SVGCircleElement + x: number + y: number + z: number } interface PeerVis { @@ -77,10 +86,11 @@ interface PeerVis { /** Intro/arrival progress 0→1: drives fly-in offset, scale, opacity. */ p: number el: SVGGElement - core: SVGCircleElement - halo: SVGCircleElement label: SVGTextElement linkEl: SVGPathElement | null + dots: GlobeDot[] + radiusPx: number + dotBaseOpacity: number depth: number } @@ -95,7 +105,13 @@ let ringsLayer: SVGGElement | null = null let width = 0 let height = 0 let unit = 1 +let centerY = 0 let elapsed = 0 +let frame = 0 +let selfDots: GlobeDot[] = [] +let selfRadiusPx = 15 + +const PERSP = 3.2 let resizeObserver: ResizeObserver | null = null let intro: gsap.core.Timeline | null = null let spinTween: gsap.core.Tween | null = null @@ -112,7 +128,7 @@ function trustColor(n: MapNode): string { } function nodeRadius(n: MapNode): number { - return n.is_self ? 15 : Math.max(8, Math.min(13, 7 + n.app_count * 0.5)) + return n.is_self ? 11 : Math.max(6, Math.min(9.5, 5.5 + n.app_count * 0.4)) } /** Ring layout: first 8 peers on the inner orbit, next 14 on a wider one, @@ -139,11 +155,10 @@ function project(x: number, y: number, z: number) { const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt) const y2 = y * ct - z1 * st const z2 = y * st + z1 * ct - const persp = 3.2 - const s = persp / (persp + z2) + const s = PERSP / (PERSP + z2) return { x: width / 2 + x1 * unit * s * cam.zoom, - y: height / 2 + y2 * unit * s * cam.zoom, + y: centerY + y2 * unit * s * cam.zoom, s, z: z2, } @@ -155,6 +170,7 @@ function clearScene() { peers = [] ringPaths = [] selfEl = null + selfDots = [] nodesLayer = null linksLayer = null ringsLayer = null @@ -173,6 +189,44 @@ function hash01(i: number): number { return x - Math.floor(x) } +/** Build a point-cloud sphere: dots on a fibonacci-sphere surface, appended + * to `parent` in the node's local px coordinate space. Positions/opacity are + * written per frame by renderGlobe(). */ +function makeGlobe(parent: SVGGElement, radiusPx: number, color: string, count: number): GlobeDot[] { + const dots: GlobeDot[] = [] + const golden = Math.PI * (3 - Math.sqrt(5)) + for (let k = 0; k < count; k++) { + const y = 1 - (2 * (k + 0.5)) / count + const rr = Math.sqrt(Math.max(0, 1 - y * y)) + const phi = k * golden + const dot = el('circle', { + r: (radiusPx * (0.06 + hash01(k) * 0.035)).toFixed(2), + fill: color, + }) + parent.appendChild(dot) + dots.push({ el: dot, x: Math.cos(phi) * rr, y, z: Math.sin(phi) * rr }) + } + return dots +} + +/** One frame of a globe: spin the point cloud around its local Y axis, apply + * the camera tilt so every sphere shares the scene's horizon, and shade dots + * by depth (front bright, limb dim) so it reads as a solid sphere of points. */ +function renderGlobe(dots: GlobeDot[], radiusPx: number, spin: number, baseOpacity: number) { + const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt) + const ca = Math.cos(spin), sa = Math.sin(spin) + for (const d of dots) { + const x1 = d.x * ca + d.z * sa + const z1 = -d.x * sa + d.z * ca + const y2 = d.y * ct - z1 * st + const z2 = d.y * st + z1 * ct + const t = (1 - z2) / 2 // z2 ∈ [-1,1]; front (−1) → 1 + d.el.setAttribute('cx', (x1 * radiusPx).toFixed(2)) + d.el.setAttribute('cy', (y2 * radiusPx).toFixed(2)) + d.el.setAttribute('opacity', (baseOpacity * (0.12 + 0.88 * t * t)).toFixed(3)) + } +} + function buildScene() { const svg = svgRef.value if (!svg) return @@ -203,16 +257,23 @@ function buildScene() { ringPaths.push({ el: p, radius }) } - // Self node: layered halo + pulse ring + core + label + // Self node: a BLACK point-cloud globe — dark dots over a soft light + // backing disc so it reads against the dark glass, with the brand-orange + // sonar pulse marking "you". 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' }) + selfRadiusPx = r + const halo = el('circle', { r: String(r * 2.2), fill: '#ffffff', opacity: '0.07' }) 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 backing = el('circle', { r: String(r * 1.04), fill: '#ffffff', opacity: '0.16' }) + const rim = el('circle', { r: String(r * 1.04), fill: 'none', stroke: '#ffffff', 'stroke-width': '1', 'stroke-opacity': '0.45' }) + g.append(halo, pulse, backing, rim) + selfDots = makeGlobe(g, r, '#000000', 56) const label = el('text', { dy: String(r + 18), 'text-anchor': 'middle', + class: 'nm-label', fill: motionTokens.color.textPrimary, 'font-size': '12px', 'font-weight': '600', @@ -221,7 +282,7 @@ function buildScene() { label.textContent = selfNode.label || 'This node' const title = el('title') title.textContent = `${selfNode.did}\nThis node` - g.append(halo, pulse, core, label, title) + g.append(label, title) g.addEventListener('click', () => emit('select', selfNode.did)) nodesLayer.appendChild(g) selfEl = g @@ -245,18 +306,24 @@ function buildScene() { 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', { + // Faint limb outline sells the sphere silhouette; offline nodes get the + // dashed ring + dim dots instead of a solid fill change. + const outline = el('circle', { r: String(r), - fill: color, - 'fill-opacity': node.online ? '0.85' : '0.25', + fill: 'none', stroke: color, - 'stroke-width': '1.5', - 'stroke-opacity': node.online ? '1' : '0.4', + 'stroke-width': '1', + 'stroke-opacity': node.online ? '0.35' : '0.3', 'stroke-dasharray': node.online ? 'none' : '3 3', }) + const core = el('circle', { r: String(r * 0.4), fill: color, opacity: node.online ? '0.22' : '0.08' }) + g.append(halo, outline, core) + const dotCount = Math.max(22, Math.round(r * 3.4)) + const dots = makeGlobe(g, r, color, dotCount) const label = el('text', { dy: String(r + 15), 'text-anchor': 'middle', + class: 'nm-label', fill: motionTokens.color.textSecondary, 'font-size': '11px', 'font-family': "'Avenir Next', system-ui, sans-serif", @@ -264,7 +331,7 @@ function buildScene() { 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.append(label, title) g.addEventListener('click', () => emit('select', node.did)) nodesLayer!.appendChild(g) @@ -290,10 +357,11 @@ function buildScene() { bobPhase: hash01(i + 100) * Math.PI * 2, p: 0, el: g, - core, - halo, label, linkEl, + dots, + radiusPx: r, + dotBaseOpacity: node.online ? 0.95 : 0.35, depth: 0, }) }) @@ -302,6 +370,10 @@ function buildScene() { /** One frame: project every element through the camera and write attrs. */ function render() { if (!width || !height) return + frame++ + // Point clouds update at half rate — the spin is slow, and this halves the + // per-frame attribute writes (the dominant cost on mobile/companion). + const updateDots = staticMode || frame % 2 === 0 // Orbit guide rings for (const ring of ringPaths) { @@ -321,6 +393,7 @@ function render() { 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)) + if (updateDots) renderGlobe(selfDots, selfRadiusPx, cam.rotY + elapsed * 0.22, 0.95) } const origin = project(0, 0, 0) @@ -328,12 +401,20 @@ function render() { 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 + // Idle motion is a gentle side-to-side sway along the ring (plus a whisper + // of vertical drift) — nodes hold their position instead of orbiting, so + // the map stays readable at a glance. + const sway = staticMode ? 0 : Math.sin(elapsed * 0.55 + peer.bobPhase) * 0.04 + const bob = staticMode ? 0 : Math.sin(elapsed * 0.85 + peer.bobPhase * 1.7) * 0.02 + const a = peer.angle + sway + const x = Math.cos(a) * dist + const z = Math.sin(a) * dist const y = peer.yJitter + bob const pt = project(x, y, z) peer.depth = pt.z + if (updateDots) { + renderGlobe(peer.dots, peer.radiusPx, cam.rotY + elapsed * 0.18 + peer.bobPhase, peer.dotBaseOpacity) + } 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) @@ -400,7 +481,7 @@ function playIntro() { } cam.rotY = -1.1 - cam.zoom = 1.35 + cam.zoom = 0.82 selfState.p = 0 for (const p of peers) p.p = 0 @@ -458,9 +539,10 @@ 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 + // Inertia: carry the fling velocity, then settle to a full stop — the + // scene never idle-orbits on its own. cam.spin = Math.max(-3, Math.min(3, dragVel)) - spinTween = gsap.to(cam, { spin: 0.05, duration: 1.6, ease: 'power2.out' }) + spinTween = gsap.to(cam, { spin: 0, duration: 1.4, ease: 'power2.out' }) } /* ----------------------------- lifecycle --------------------------- */ @@ -471,9 +553,45 @@ function measure() { 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 + + // Portrait (phone/companion): tilt the camera towards top-down so the + // orbit becomes a tall ellipse that uses the vertical space — nodes and + // labels spread out in two dimensions instead of stacking on a flat band. + cam.tilt = height > width * 1.15 ? -0.95 : -0.5 + + // Auto-fit: sample the outermost orbit through the real camera math + // (rotation-invariant — a ring about the Y axis projects identically at any + // rotY) to get the scene's true projected bounds, then scale to fill the + // container and centre vertically in the space between the overlays. This + // is what makes the map fill a phone, the companion, and a desktop panel + // equally well instead of assuming one aspect ratio. const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1 - unit = (Math.min(width, height * 1.45) / 2 - 60) / maxRing + const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt) + let maxAbsX = 0 + let yMin = Infinity + let yMax = -Infinity + for (let i = 0; i < 72; i++) { + const a = (i / 72) * Math.PI * 2 + const x = Math.cos(a) * maxRing + const z = Math.sin(a) * maxRing + for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude + const y2 = y * ct - z * st + const z2 = y * st + z * ct + const s = PERSP / (PERSP + z2) + maxAbsX = Math.max(maxAbsX, Math.abs(x * s)) + yMin = Math.min(yMin, y2 * s) + yMax = Math.max(yMax, y2 * s) + } + } + const marginX = 46 // node radius + label half-width clearance + const marginTop = 54 // legend chip + const marginBottom = 66 // hint line + node label below the front edge + const bandH = Math.max(80, height - marginTop - marginBottom) + const unitX = (width / 2 - marginX) / maxAbsX + const unitY = bandH / Math.max(yMax - yMin, 0.001) + unit = Math.max(20, Math.min(unitX, unitY)) + // Place the projected ellipse's midpoint at the centre of the available band + centerY = marginTop + bandH / 2 - ((yMax + yMin) / 2) * unit render() } @@ -634,3 +752,20 @@ watch(graphSignature, () => rebuild(false)) } } + + +