From 8d1fda29fa708ccaae58b9892d042989dfae14c9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 14 Aug 2026 07:36:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(federation):=20peer=20requests=20take=20th?= =?UTF-8?q?e=20stage=20=E2=80=94=20clustered=20and=20faced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests were spread evenly around the orbit, so they could sit BEHIND the globe: the blinking call-to-action was invisible and the chart read as mis-scaled until the user hand-rotated. Now requests cluster tightly at one stage angle (spacing shrinks as count grows) and, when a NEW request arrives, the camera steers to face the cluster front-and-center (depth ∝ sin(angle−rotY); front = angle+π/2) — arrival only, so a user who rotates away isn't fought. Static/reduced-motion paths snap+render. Co-Authored-By: Claude Fable 5 --- .../components/federation/NetworkMap3D.vue | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue index 2e23fe1f..14945465 100644 --- a/neode-ui/src/components/federation/NetworkMap3D.vue +++ b/neode-ui/src/components/federation/NetworkMap3D.vue @@ -702,11 +702,22 @@ function applyGraph() { const reqs = [...(props.requests ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const reqRing = requestRingRadius(peerNodes.length) const seenReqs = new Set() + // Requests are a call to action: cluster them tightly at one stage angle + // (instead of spread around the orbit, where they could sit BEHIND the + // globe — reading as a mis-scaled chart the user had to rotate by hand) + // and steer the camera to face them when a new one arrives. + const REQUEST_STAGE_ANGLE = 0.35 + const reqSpacing = Math.min(0.5, (Math.PI * 0.9) / Math.max(reqs.length, 1)) + let newRequestArrived = false reqs.forEach((req, i) => { seenReqs.add(req.id) - const slot = { angle: (i / reqs.length) * Math.PI * 2 + 0.35, ringRadius: reqRing } + const slot = { + angle: REQUEST_STAGE_ANGLE + (i - (reqs.length - 1) / 2) * reqSpacing, + ringRadius: reqRing, + } const existing = reqMap.get(req.id) if (!existing) { + newRequestArrived = true const vis = createRequest(req, slot) if (introPlayed) { if (staticMode) vis.p = 1 @@ -738,6 +749,20 @@ function applyGraph() { for (const [id, vis] of reqMap) { if (!seenReqs.has(id)) killRequest(vis) } + // Face the request cluster when a new one arrives. Depth for a node at + // world angle a is proportional to sin(a − rotY): front-center (nearest + // to the viewer, horizontally centred) is a − rotY = −π/2. Only on + // ARRIVAL, so a user who rotates away afterwards isn't fought. + if (newRequestArrived && introPlayed) { + const target = nearestAngle(cam.rotY, REQUEST_STAGE_ANGLE + Math.PI / 2) + for (const t of spinTween) t.kill() + if (staticMode && !tickerAttached) { + cam.rotY = target + render() + } else { + gsap.to(cam, { rotY: target, duration: 1.1, ease: motionTokens.ease.inOut, overwrite: 'auto' }) + } + } // --- orbit guide rings --- const desiredRings: { radius: number; kind: 'peer' | 'request' }[] = []