World: Gource-grade effects + orbit/pinch-zoom + wired formations
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Effects (matching Gource's technique — additive radial-gradient glow + particles,
not just post-processing):
- additive glow sprites on every node + pawn (a precomputed radial-gradient
  texture, AdditiveBlending), scaled/brightened by heat — the soft Gource glow.
- a particle system (THREE.Points, additive, ring buffer): spark bursts on each
  beam, ambient sparkle from hot nodes, and fading motion trails behind pawns.
- additive beams (brightness by life); UnrealBloom retuned (low threshold) so the
  additive glow blooms.

Camera: PerspectiveCamera + OrbitControls — drag/one-finger to rotate (orbit),
pinch / wheel to zoom, two-finger / right-drag to pan; auto-frames until you grab
it. Tap-vs-drag raycast selection.

Formations now each behave distinctly:
- hierarchy — strong parent spring, low repulsion → clean structured tree (calm
  pawns/effects).
- flat — spring to center + high repulsion → a spread peer mesh.
- live — balanced activity view with full pawns/beams/sparks/trails.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-23 22:16:30 -07:00
co-authored by Claude Opus 4.8
parent b1d18504c3
commit da20417fda
2 changed files with 246 additions and 87 deletions
+232 -80
View File
@@ -1,13 +1,16 @@
"use client"; "use client";
// The Large World — a Gource-inspired live visualization rendered in WebGL // The Large World — a Gource-inspired live visualization in WebGL (three.js).
// (three.js). The org▸company▸team structure is a force-directed tree; agents are // Structure (org▸company▸team) is a force-directed tree; agents are glowing
// glowing "pawns" that converge on the node they touch and beam it; world nodes // "pawns" that converge on the node they touch and beam it; world nodes glow
// glow with heat and fade when idle. Driven by the live taxonomy feed (with a // with heat and fade when idle. The look mirrors Gource's technique — additive
// synthetic fallback) so it breathes with real agent activity. Replaces WorldFlow. // radial-gradient glow sprites + a particle system (sparks + trails) — rather
// than relying on post-processing alone. Orbit/pinch-zoom via OrbitControls.
// Driven by the live taxonomy feed (with a synthetic fallback). Replaces WorldFlow.
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react"; import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
@@ -45,6 +48,25 @@ function applyReplayEvent(engine: WorldEngine, ev: ReplayEvent) {
} }
} }
/** A soft radial-gradient sprite texture — the basis of the additive glow (the
* Gource bloom technique) and the particle points. */
function makeGlowTexture(): THREE.Texture {
const s = 128;
const c = document.createElement("canvas");
c.width = c.height = s;
const ctx = c.getContext("2d")!;
const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(0.2, "rgba(255,255,255,0.65)");
g.addColorStop(0.5, "rgba(255,255,255,0.18)");
g.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, s, s);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
interface WorldCanvasProps { interface WorldCanvasProps {
roots: WorldSeed[]; roots: WorldSeed[];
selectedId: string | null; selectedId: string | null;
@@ -73,7 +95,6 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const replayHours = 24; const replayHours = 24;
// latest-prop refs so the imperative loop sees current values without re-init
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const selectedRef = useRef(selectedId); const selectedRef = useRef(selectedId);
const engineRef = useRef<WorldEngine | null>(null); const engineRef = useRef<WorldEngine | null>(null);
@@ -152,82 +173,138 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
mount.appendChild(renderer.domElement); mount.appendChild(renderer.domElement);
renderer.domElement.style.display = "block"; renderer.domElement.style.display = "block";
renderer.domElement.style.touchAction = "none";
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 1000); const camera = new THREE.PerspectiveCamera(52, 1, 1, 8000);
camera.position.set(0, 0, 10); camera.position.set(0, 0, 700);
// orbit (left-drag/1-finger), pinch-zoom + pan (wheel/2-finger), pan (right-drag)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.rotateSpeed = 0.55;
controls.zoomSpeed = 0.9;
controls.panSpeed = 0.8;
controls.screenSpacePanning = true;
controls.minDistance = 70;
controls.maxDistance = 4000;
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
let userInteracted = false;
controls.addEventListener("start", () => {
userInteracted = true;
});
const glow = makeGlowTexture();
const circle = new THREE.CircleGeometry(1, 28); const circle = new THREE.CircleGeometry(1, 28);
const nodeMeshes = new Map<string, THREE.Mesh>(); const nodeMeshes = new Map<string, THREE.Mesh>();
const nodeGlows = new Map<string, THREE.Sprite>();
const pawnMeshes = new Map<string, THREE.Mesh>(); const pawnMeshes = new Map<string, THREE.Mesh>();
const pawnGlows = new Map<string, THREE.Sprite>();
const labels = new Map<string, HTMLSpanElement>(); const labels = new Map<string, HTMLSpanElement>();
const newGlow = () => {
const s = new THREE.Sprite(
new THREE.SpriteMaterial({ map: glow, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, depthTest: false }),
);
scene.add(s);
return s;
};
const edgeGeom = new THREE.BufferGeometry(); const edgeGeom = new THREE.BufferGeometry();
const edges = new THREE.LineSegments( const edges = new THREE.LineSegments(
edgeGeom, edgeGeom,
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.12 }), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.1, depthWrite: false }),
); );
scene.add(edges); scene.add(edges);
const beamGeom = new THREE.BufferGeometry(); const beamGeom = new THREE.BufferGeometry();
const beams = new THREE.LineSegments( const beams = new THREE.LineSegments(
beamGeom, beamGeom,
new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.9 }), new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }),
); );
scene.add(beams); scene.add(beams);
// particle system (sparks + trails): additive points, ring buffer, RGB→0 fade
const MAXP = 1400;
const pPos = new Float32Array(MAXP * 3);
const pCol = new Float32Array(MAXP * 3);
const pVel = new Float32Array(MAXP * 3);
const pBase = new Float32Array(MAXP * 3);
const pLife = new Float32Array(MAXP);
const pDecay = new Float32Array(MAXP);
let pCur = 0;
const particleGeom = new THREE.BufferGeometry();
particleGeom.setAttribute("position", new THREE.BufferAttribute(pPos, 3));
particleGeom.setAttribute("color", new THREE.BufferAttribute(pCol, 3));
const particles = new THREE.Points(
particleGeom,
new THREE.PointsMaterial({ size: 8, map: glow, vertexColors: true, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, sizeAttenuation: true }),
);
scene.add(particles);
const tmp = new THREE.Color();
const spawn = (x: number, y: number, hex: string, vx: number, vy: number, decay: number) => {
const i = pCur % MAXP;
pCur += 1;
tmp.set(hex);
pPos[i * 3] = x;
pPos[i * 3 + 1] = y;
pPos[i * 3 + 2] = 0;
pVel[i * 3] = vx;
pVel[i * 3 + 1] = vy;
pVel[i * 3 + 2] = 0;
pBase[i * 3] = tmp.r;
pBase[i * 3 + 1] = tmp.g;
pBase[i * 3 + 2] = tmp.b;
pLife[i] = 1;
pDecay[i] = decay;
};
const composer = new EffectComposer(renderer); const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera)); composer.addPass(new RenderPass(scene, camera));
composer.addPass( const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.7, 0.6, 0.2);
new UnrealBloomPass(new THREE.Vector2(1, 1), /*strength*/ 0.9, /*radius*/ 0.5, /*threshold*/ 0.5), composer.addPass(bloom);
);
const setSize = () => { const setSize = () => {
const w = mount.clientWidth || 1; const w = mount.clientWidth || 1;
const h = mount.clientHeight || 1; const h = mount.clientHeight || 1;
renderer.setSize(w, h); renderer.setSize(w, h);
composer.setSize(w, h); composer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}; };
setSize(); setSize();
const ro = new ResizeObserver(setSize); const ro = new ResizeObserver(setSize);
ro.observe(mount); ro.observe(mount);
// smoothed camera fit // tap (not drag) → raycast select
let viewCx = 0; const ray = new THREE.Raycaster();
let viewCy = 0; let downX = 0;
let viewHalf = 240; let downY = 0;
let downT = 0;
// click → select nearest node/pawn (unproject pointer to world) const onDown = (e: PointerEvent) => {
const onClick = (ev: MouseEvent) => { downX = e.clientX;
const rect = renderer.domElement.getBoundingClientRect(); downY = e.clientY;
const ndc = new THREE.Vector3( downT = performance.now();
((ev.clientX - rect.left) / rect.width) * 2 - 1,
-((ev.clientY - rect.top) / rect.height) * 2 + 1,
0,
).unproject(camera);
let best: string | null = null;
let bestD = Infinity;
for (const n of engine.nodes.values()) {
if (n.tier === "root") continue;
const d = Math.hypot(n.x - ndc.x, n.y - ndc.y);
if (d < n.r + 10 && d < bestD) {
bestD = d;
best = n.id;
}
}
for (const p of engine.pawns.values()) {
const d = Math.hypot(p.x - ndc.x, p.y - ndc.y);
if (d < 16 && d < bestD) {
bestD = d;
best = p.id;
}
}
if (best) onSelectRef.current(best);
}; };
renderer.domElement.addEventListener("click", onClick); const onUp = (e: PointerEvent) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || performance.now() - downT > 500) return;
const rect = renderer.domElement.getBoundingClientRect();
ray.setFromCamera(
new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1),
camera,
);
const hits = ray.intersectObjects([...nodeMeshes.values(), ...pawnMeshes.values()]);
const id = hits.length ? (hits[0].object.userData.id as string | undefined) : undefined;
if (id) onSelectRef.current(id);
};
renderer.domElement.addEventListener("pointerdown", onDown);
renderer.domElement.addEventListener("pointerup", onUp);
const col = new THREE.Color(); const col = new THREE.Color();
const white = new THREE.Color(0xffffff);
let raf = 0; let raf = 0;
let lastT = performance.now(); let lastT = performance.now();
let frameN = 0;
const frame = () => { const frame = () => {
raf = requestAnimationFrame(frame); raf = requestAnimationFrame(frame);
@@ -235,6 +312,7 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
const now = performance.now(); const now = performance.now();
const dt = Math.min(0.05, (now - lastT) / 1000); const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now; lastT = now;
frameN += 1;
if (modeRef.current === "replay") { if (modeRef.current === "replay") {
const rp = replayRef.current; const rp = replayRef.current;
@@ -255,18 +333,23 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
engine.formation = formationRef.current; engine.formation = formationRef.current;
engine.step(dt); engine.step(dt);
const F = engine.formation;
const showPawns = F !== "hierarchy";
const effects = F === "live";
// --- nodes ---
const liveNodeIds = new Set<string>();
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
// nodes (solid disc + additive glow sprite)
const liveNodeIds = new Set<string>();
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
liveNodeIds.add(n.id); liveNodeIds.add(n.id);
let m = nodeMeshes.get(n.id); let m = nodeMeshes.get(n.id);
if (!m) { if (!m) {
m = new THREE.Mesh(circle, new THREE.MeshBasicMaterial({ transparent: true })); m = new THREE.Mesh(circle, new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false }));
m.userData.id = n.id;
nodeMeshes.set(n.id, m); nodeMeshes.set(n.id, m);
scene.add(m); scene.add(m);
} }
@@ -274,9 +357,25 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
m.position.set(n.x, n.y, 0); m.position.set(n.x, n.y, 0);
m.scale.set(r, r, 1); m.scale.set(r, r, 1);
const mat = m.material as THREE.MeshBasicMaterial; const mat = m.material as THREE.MeshBasicMaterial;
col.set(n.color).lerp(new THREE.Color(0xffffff), n.heat * 0.6); col.set(n.color).lerp(white, n.heat * 0.6);
mat.color.copy(col); mat.color.copy(col);
mat.opacity = n.alpha * (n.tier === "root" ? 1 : 0.55 + n.heat * 0.45); mat.opacity = n.alpha * (n.tier === "root" ? 1 : 0.6 + n.heat * 0.4);
let g = nodeGlows.get(n.id);
if (!g) {
g = newGlow();
nodeGlows.set(n.id, g);
}
const gs = r * (3.2 + n.heat * 3.5);
g.position.set(n.x, n.y, -1);
g.scale.set(gs, gs, 1);
const gm = g.material as THREE.SpriteMaterial;
gm.color.set(n.color);
gm.opacity = (n.tier === "root" ? 0.5 : 0.22 + n.heat * 0.8) * n.alpha;
if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) {
const a = Math.random() * Math.PI * 2;
const sp = 18 + Math.random() * 30;
spawn(n.x, n.y, n.color, Math.cos(a) * sp, Math.sin(a) * sp, 1.7 + Math.random());
}
if (n.tier !== "root") { if (n.tier !== "root") {
minX = Math.min(minX, n.x - r); minX = Math.min(minX, n.x - r);
minY = Math.min(minY, n.y - r); minY = Math.min(minY, n.y - r);
@@ -289,10 +388,16 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
scene.remove(m); scene.remove(m);
(m.material as THREE.Material).dispose(); (m.material as THREE.Material).dispose();
nodeMeshes.delete(id); nodeMeshes.delete(id);
const g = nodeGlows.get(id);
if (g) {
scene.remove(g);
(g.material as THREE.Material).dispose();
nodeGlows.delete(id);
}
} }
} }
// --- edges (child → parent) --- // edges
const epos: number[] = []; const epos: number[] = [];
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
if (!n.parentId) continue; if (!n.parentId) continue;
@@ -303,67 +408,106 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
edgeGeom.setAttribute("position", new THREE.Float32BufferAttribute(epos, 3)); edgeGeom.setAttribute("position", new THREE.Float32BufferAttribute(epos, 3));
edgeGeom.attributes.position.needsUpdate = true; edgeGeom.attributes.position.needsUpdate = true;
edgeGeom.setDrawRange(0, epos.length / 3); edgeGeom.setDrawRange(0, epos.length / 3);
(edges.material as THREE.LineBasicMaterial).opacity = F === "flat" ? 0.04 : 0.1;
// --- pawns --- // pawns (solid + glow + trail particles)
const livePawnIds = new Set<string>(); const livePawnIds = new Set<string>();
for (const p of engine.pawns.values()) { for (const p of engine.pawns.values()) {
livePawnIds.add(p.id); livePawnIds.add(p.id);
let m = pawnMeshes.get(p.id); let m = pawnMeshes.get(p.id);
if (!m) { if (!m) {
m = new THREE.Mesh(circle, new THREE.MeshBasicMaterial({ transparent: true })); m = new THREE.Mesh(circle, new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false }));
m.userData.id = p.id;
pawnMeshes.set(p.id, m); pawnMeshes.set(p.id, m);
scene.add(m); scene.add(m);
} }
m.position.set(p.x, p.y, 1); const pa = p.alpha * (showPawns ? 1 : 0.5);
m.scale.set(9, 9, 1); m.position.set(p.x, p.y, 2);
m.scale.set(8, 8, 1);
const mat = m.material as THREE.MeshBasicMaterial; const mat = m.material as THREE.MeshBasicMaterial;
mat.color.set(p.color); mat.color.set(p.color);
mat.opacity = p.alpha; mat.opacity = pa;
minX = Math.min(minX, p.x - 9); let g = pawnGlows.get(p.id);
minY = Math.min(minY, p.y - 9); if (!g) {
maxX = Math.max(maxX, p.x + 9); g = newGlow();
maxY = Math.max(maxY, p.y + 9); pawnGlows.set(p.id, g);
}
g.position.set(p.x, p.y, 1);
g.scale.set(34, 34, 1);
const gm = g.material as THREE.SpriteMaterial;
gm.color.set(p.color);
gm.opacity = 0.55 * pa;
if (showPawns && frameN % 2 === 0) spawn(p.x, p.y, p.color, 0, 0, 3.0);
minX = Math.min(minX, p.x - 8);
minY = Math.min(minY, p.y - 8);
maxX = Math.max(maxX, p.x + 8);
maxY = Math.max(maxY, p.y + 8);
} }
for (const [id, m] of pawnMeshes) { for (const [id, m] of pawnMeshes) {
if (!livePawnIds.has(id)) { if (!livePawnIds.has(id)) {
scene.remove(m); scene.remove(m);
(m.material as THREE.Material).dispose(); (m.material as THREE.Material).dispose();
pawnMeshes.delete(id); pawnMeshes.delete(id);
const g = pawnGlows.get(id);
if (g) {
scene.remove(g);
(g.material as THREE.Material).dispose();
pawnGlows.delete(id);
}
} }
} }
// --- beams --- // beams (additive) + a spark burst once per beam
const bpos: number[] = []; const bpos: number[] = [];
const bcol: number[] = []; const bcol: number[] = [];
for (const b of engine.beams) { for (const b of engine.beams) {
col.set(b.color); col.set(b.color);
const l = b.life;
bpos.push(b.x1, b.y1, 0.5, b.x2, b.y2, 0.5); bpos.push(b.x1, b.y1, 0.5, b.x2, b.y2, 0.5);
bcol.push(col.r, col.g, col.b, col.r, col.g, col.b); bcol.push(col.r * l, col.g * l, col.b * l, col.r * l, col.g * l, col.b * l);
if (effects && !b.sparked) {
b.sparked = true;
for (let k = 0; k < 7; k++) {
const a = Math.random() * Math.PI * 2;
const sp = 25 + Math.random() * 45;
spawn(b.x2, b.y2, b.color, Math.cos(a) * sp, Math.sin(a) * sp, 1.6 + Math.random());
}
}
} }
beamGeom.setAttribute("position", new THREE.Float32BufferAttribute(bpos, 3)); beamGeom.setAttribute("position", new THREE.Float32BufferAttribute(bpos, 3));
beamGeom.setAttribute("color", new THREE.Float32BufferAttribute(bcol, 3)); beamGeom.setAttribute("color", new THREE.Float32BufferAttribute(bcol, 3));
beamGeom.attributes.position.needsUpdate = true; beamGeom.attributes.position.needsUpdate = true;
beamGeom.setDrawRange(0, bpos.length / 3); beamGeom.setDrawRange(0, bpos.length / 3);
// --- camera fit (smoothed) --- // particle integrate + fade (additive: RGB→0 = invisible)
if (minX < maxX) { for (let i = 0; i < MAXP; i++) {
if (pLife[i] > 0) {
pLife[i] = Math.max(0, pLife[i] - dt * pDecay[i]);
pPos[i * 3] += pVel[i * 3] * dt;
pPos[i * 3 + 1] += pVel[i * 3 + 1] * dt;
const l = pLife[i];
pCol[i * 3] = pBase[i * 3] * l;
pCol[i * 3 + 1] = pBase[i * 3 + 1] * l;
pCol[i * 3 + 2] = pBase[i * 3 + 2] * l;
} else {
pCol[i * 3] = pCol[i * 3 + 1] = pCol[i * 3 + 2] = 0;
}
}
particleGeom.attributes.position.needsUpdate = true;
particleGeom.attributes.color.needsUpdate = true;
// camera: auto-frame until the user grabs it (then OrbitControls owns it)
if (!userInteracted && minX < maxX) {
const cx = (minX + maxX) / 2; const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2; const cy = (minY + maxY) / 2;
const aspect = (mount.clientWidth || 1) / (mount.clientHeight || 1); const span = Math.max(maxX - minX, maxY - minY);
const half = Math.max((maxX - minX) / 2, (maxY - minY) / 2 * aspect) * 1.18 + 40; const dist = Math.max(200, span * 0.95 + 130);
viewCx += (cx - viewCx) * 0.06; controls.target.lerp(new THREE.Vector3(cx, cy, 0), 0.05);
viewCy += (cy - viewCy) * 0.06; camera.position.lerp(new THREE.Vector3(cx, cy, dist), 0.05);
viewHalf += (half - viewHalf) * 0.06;
const vh = viewHalf / aspect;
camera.left = -viewHalf;
camera.right = viewHalf;
camera.top = vh;
camera.bottom = -vh;
camera.position.set(viewCx, viewCy, 10);
camera.updateProjectionMatrix();
} }
controls.update();
// --- labels (structure nodes + hot world nodes + pawns + selected) --- // labels (project to screen)
const wantLabels = new Map<string, { x: number; y: number; text: string; color: string; big: boolean }>(); const wantLabels = new Map<string, { x: number; y: number; text: string; color: string; big: boolean }>();
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
const struct = n.tier === "org" || n.tier === "company" || n.tier === "team"; const struct = n.tier === "org" || n.tier === "company" || n.tier === "team";
@@ -371,6 +515,7 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
if (struct || hot) if (struct || hot)
wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" }); wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" });
} }
if (showPawns)
for (const p of engine.pawns.values()) for (const p of engine.pawns.values())
wantLabels.set(p.id, { x: p.x, y: p.y, text: p.label, color: "#0a0a0a", big: false }); wantLabels.set(p.id, { x: p.x, y: p.y, text: p.label, color: "#0a0a0a", big: false });
const W = mount.clientWidth || 1; const W = mount.clientWidth || 1;
@@ -380,7 +525,7 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
let span = labels.get(id); let span = labels.get(id);
if (!span) { if (!span) {
span = document.createElement("span"); span = document.createElement("span");
span.style.cssText = `position:absolute;transform:translate(-50%,-50%);font-family:${mono};white-space:nowrap;pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.8)`; span.style.cssText = `position:absolute;transform:translate(-50%,-50%);font-family:${mono};white-space:nowrap;pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.85)`;
labelLayer.appendChild(span); labelLayer.appendChild(span);
labels.set(id, span); labels.set(id, span);
} }
@@ -400,6 +545,7 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
} }
} }
bloom.strength = effects ? 0.85 : 0.5;
composer.render(); composer.render();
}; };
raf = requestAnimationFrame(frame); raf = requestAnimationFrame(frame);
@@ -407,13 +553,19 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
ro.disconnect(); ro.disconnect();
renderer.domElement.removeEventListener("click", onClick); renderer.domElement.removeEventListener("pointerdown", onDown);
renderer.domElement.removeEventListener("pointerup", onUp);
controls.dispose();
labels.forEach((s) => s.remove()); labels.forEach((s) => s.remove());
nodeMeshes.forEach((m) => (m.material as THREE.Material).dispose()); nodeMeshes.forEach((m) => (m.material as THREE.Material).dispose());
nodeGlows.forEach((s) => (s.material as THREE.Material).dispose());
pawnMeshes.forEach((m) => (m.material as THREE.Material).dispose()); pawnMeshes.forEach((m) => (m.material as THREE.Material).dispose());
pawnGlows.forEach((s) => (s.material as THREE.Material).dispose());
circle.dispose(); circle.dispose();
edgeGeom.dispose(); edgeGeom.dispose();
beamGeom.dispose(); beamGeom.dispose();
particleGeom.dispose();
glow.dispose();
composer.dispose(); composer.dispose();
renderer.dispose(); renderer.dispose();
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
@@ -450,7 +602,7 @@ export function WorldCanvas({ roots, selectedId, onSelect }: WorldCanvasProps) {
<div style={{ position: "absolute", inset: 0 }}> <div style={{ position: "absolute", inset: 0 }}>
<div ref={mountRef} style={{ position: "absolute", inset: 0 }} /> <div ref={mountRef} style={{ position: "absolute", inset: 0 }} />
<div ref={labelLayerRef} style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }} /> <div ref={labelLayerRef} style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }} />
{/* Formation switch (Gource-style camera/layout modes). */} {/* Formation switch */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
+12 -5
View File
@@ -52,6 +52,7 @@ export interface Beam {
y2: number; y2: number;
color: string; color: string;
life: number; life: number;
sparked?: boolean; // renderer fires a particle burst once per beam
} }
export interface WorldSeed { export interface WorldSeed {
@@ -209,11 +210,16 @@ export class WorldEngine {
// --- simulation ----------------------------------------------------------- // --- simulation -----------------------------------------------------------
step(dt: number) { step(dt: number) {
this.now += dt; this.now += dt;
const flat = this.formation === "flat"; // Each formation is a distinct force regime:
const springK = flat ? 0.03 : 0.14; // hierarchy — strong parent spring, low repulsion → a clean structured tree.
const repulse = flat ? 9000 : 5200; // flat — no parent pull (spring to center) + high repulsion → a peer mesh.
// live — balanced, the activity view where pawns converge + beam.
const F = this.formation;
const springK = F === "flat" ? 0.05 : F === "hierarchy" ? 0.2 : 0.13;
const repulse = F === "flat" ? 11000 : F === "hierarchy" ? 3800 : 5200;
const friction = 0.82; const friction = 0.82;
const arr = [...this.nodes.values()]; const arr = [...this.nodes.values()];
const root = this.nodes.get(ROOT)!;
// global charge repulsion (all pairs) — O(n²), fine for low hundreds // global charge repulsion (all pairs) — O(n²), fine for low hundreds
for (let i = 0; i < arr.length; i++) { for (let i = 0; i < arr.length; i++) {
@@ -244,11 +250,12 @@ export class WorldEngine {
for (const n of arr) { for (const n of arr) {
if (n.fixed) continue; if (n.fixed) continue;
const parent = this.nodes.get(n.parentId ?? ROOT) ?? this.nodes.get(ROOT)!; const parent = F === "flat" ? root : this.nodes.get(n.parentId ?? ROOT) ?? root;
const dx = parent.x - n.x; const dx = parent.x - n.x;
const dy = parent.y - n.y; const dy = parent.y - n.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
const desired = parent.r + n.r + (n.tier === "org" ? 130 : n.tier === "company" ? 95 : 64); const desired =
F === "flat" ? 130 + n.depth * 26 : parent.r + n.r + (n.tier === "org" ? 130 : n.tier === "company" ? 95 : 64);
const f = (d - desired) * springK; const f = (d - desired) * springK;
n.vx += (dx / d) * f * dt * 4; n.vx += (dx / d) * f * dt * 4;
n.vy += (dy / d) * f * dt * 4; n.vy += (dy / d) * f * dt * 4;