feat(federation): point-cloud globe nodes, auto-fit centering, calmer motion

- Nodes are now spheres made of points: fibonacci point-cloud globes with
  depth-shaded dots, limb outline, and a slow local spin (drag adds parallax).
  Self node is black — dark dots over a soft light backing disc with the
  brand-orange sonar pulse marking 'you'. Spheres and dots sized down.
- Scene auto-fits and centres to the container on every device: the outermost
  orbit is sampled through the real camera projection to get true bounds,
  then scaled/centred between the overlays. Portrait screens tilt the camera
  towards top-down so the orbit uses the full height (mobile readability).
- No idle orbiting: nodes hold position with a gentle side-to-side sway;
  drag inertia now settles to a stop. Intro dolly pushes in (0.82→1) instead
  of pulling back, so nothing clips during the intro.
- Labels get a dark paint-order halo and bump to 12.5px on mobile.
- Point clouds update at half frame rate to keep mobile/companion smooth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 17:20:24 -04:00
co-authored by Claude Fable 5
parent fa14f6ebaa
commit 8354db8e14
@@ -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))
}
}
</style>
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at
runtime, so Vue's scoped-style data attributes never land on them. -->
<style>
/* Dark halo behind node labels — keeps them legible over dots and links */
.node-map-stage text.nm-label {
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.6);
stroke-width: 2.5px;
stroke-linejoin: round;
}
@media (max-width: 767px) {
.node-map-stage text.nm-label {
font-size: 12.5px;
}
}
</style>