World: Gource-grade effects + orbit/pinch-zoom + wired formations
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:
co-authored by
Claude Opus 4.8
parent
b1d18504c3
commit
da20417fda
@@ -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,8 +515,9 @@ 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" });
|
||||||
}
|
}
|
||||||
for (const p of engine.pawns.values())
|
if (showPawns)
|
||||||
wantLabels.set(p.id, { x: p.x, y: p.y, text: p.label, color: "#0a0a0a", big: false });
|
for (const p of engine.pawns.values())
|
||||||
|
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;
|
||||||
const H = mount.clientHeight || 1;
|
const H = mount.clientHeight || 1;
|
||||||
const v = new THREE.Vector3();
|
const v = new THREE.Vector3();
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user