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:
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>
|
||||
|
||||
Reference in New Issue
Block a user