@hybridlabor-api/bdb-synapse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,656 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import * as THREE from "three";
3
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
4
+ import { touchWord, type CityFile, type CityMap, type Touch } from "../types";
5
+ import type { FilePlayback } from "../playback/reducer";
6
+ import { DirLabelSet } from "./dirLabels";
7
+ import {
8
+ disposeGroup,
9
+ ensureVisible,
10
+ fitDistance,
11
+ prefersReducedMotion,
12
+ SceneTip,
13
+ SKY,
14
+ touchColors
15
+ } from "./sceneUtils";
16
+ import { computeTreeLayout, type TreeLayout } from "./treeLayout";
17
+ import { fireflyTexture, haloTexture } from "./textures";
18
+ import { TrailRenderer } from "./trail";
19
+
20
+ interface TreeSceneProps {
21
+ city?: CityMap;
22
+ playback: FilePlayback;
23
+ selectedPath?: string;
24
+ onSelect: (path?: string) => void;
25
+ onCanvasReady?: (canvas: HTMLCanvasElement | null) => void;
26
+ }
27
+
28
+ // Firefly tree: the repo is a radial tree — directories fork, files are
29
+ // leaves. The walker is a firefly that lights leaves as it lands; attention
30
+ // depth shows as the pool of light it leaves behind on the ground.
31
+ //
32
+ // One variable, one channel: leaf color says WHAT happened (touch state),
33
+ // halo radius says HOW MUCH (visits), branch brightness says WHERE the
34
+ // light is, and the selection is a shape (ring + beam), never a recolor.
35
+ const colors: Record<Touch | "unvisited" | "ghost" | "selected", THREE.Color> = {
36
+ unvisited: new THREE.Color("#5a6375"),
37
+ ghost: new THREE.Color("#4d5464"),
38
+ ...touchColors
39
+ };
40
+ const EDGE_BASE = new THREE.Color("#3c424f");
41
+ // branches leading to visited leaves brighten, but stay neutral: the branch
42
+ // guides the eye, the leaf carries the classification
43
+ const EDGE_LIT = new THREE.Color("#7d8496");
44
+ // white-hot walker: keeps the firefly apart from edit-amber leaves
45
+ const FIREFLY_HOT = new THREE.Color("#ffeeda");
46
+ const LEAF_Y = 0.7;
47
+
48
+ // halo radius encodes revisits only — touch type already lives in the leaf
49
+ // color, and doubling it up here made the radius unreadable
50
+ function haloRadius(visits: number): number {
51
+ return Math.min(1.8 * (1 + 0.45 * Math.log2(Math.max(visits, 1))), 4.8);
52
+ }
53
+
54
+ interface HaloSlot {
55
+ fileId: number;
56
+ target: number;
57
+ color: THREE.Color;
58
+ }
59
+
60
+ const LABEL_Y = 1.8;
61
+ // the inspector docks on the right; selection pans the camera clear of it
62
+ const INSPECTOR_RESERVED_PX = 348;
63
+
64
+ export function TreeScene({ city, playback, selectedPath, onSelect, onCanvasReady }: TreeSceneProps) {
65
+ const hostRef = useRef<HTMLDivElement | null>(null);
66
+ const leafMeshRef = useRef<THREE.InstancedMesh | null>(null);
67
+ const ghostMeshRef = useRef<THREE.InstancedMesh | null>(null);
68
+ const ghostIndexRef = useRef<Map<number, number>>(new Map());
69
+ const selectionRef = useRef<{ ring: THREE.Mesh; beam: THREE.Mesh } | null>(null);
70
+ const haloMeshRef = useRef<THREE.InstancedMesh | null>(null);
71
+ const edgesRef = useRef<THREE.LineSegments | null>(null);
72
+ const edgeMetaRef = useRef<{ childPath?: string; childFileId?: number; vertexCount: number }[]>([]);
73
+ const filesRef = useRef<CityFile[]>([]);
74
+ const layoutRef = useRef<TreeLayout | null>(null);
75
+ const slotsRef = useRef<HaloSlot[]>([]);
76
+ const radiiRef = useRef<Map<number, number>>(new Map());
77
+ const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
78
+ const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
79
+ const controlsRef = useRef<OrbitControls | null>(null);
80
+ const sceneRef = useRef<THREE.Scene | null>(null);
81
+ const groupRef = useRef<THREE.Group | null>(null);
82
+ const trailRef = useRef<TrailRenderer | null>(null);
83
+ const fireflyRef = useRef<THREE.Sprite | null>(null);
84
+ const labelSetRef = useRef<DirLabelSet | null>(null);
85
+ const frameRef = useRef<number | null>(null);
86
+ const reducedRef = useRef(false);
87
+ // handlers live in the mount effect; they read playback through this ref
88
+ const playbackRef = useRef(playback);
89
+ playbackRef.current = playback;
90
+ // camera fit deferred while the viewport reports no size (hidden pane,
91
+ // background tab); resize retries it instead of leaving the camera at NaN
92
+ const fitPendingRef = useRef<(() => boolean) | null>(null);
93
+
94
+ const layout = useMemo(() => (city && city.files.length > 0 ? computeTreeLayout(city.files) : null), [city]);
95
+
96
+ useEffect(() => {
97
+ const host = hostRef.current;
98
+ if (!host) return;
99
+ const reduced = prefersReducedMotion();
100
+ reducedRef.current = reduced;
101
+
102
+ const scene = new THREE.Scene();
103
+ scene.background = SKY;
104
+ sceneRef.current = scene;
105
+
106
+ const camera = new THREE.PerspectiveCamera(38, host.clientWidth / host.clientHeight || 1, 0.1, 2400);
107
+ camera.position.set(60, 110, 90);
108
+ cameraRef.current = camera;
109
+
110
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
111
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
112
+ renderer.setSize(host.clientWidth, host.clientHeight);
113
+ rendererRef.current = renderer;
114
+ host.appendChild(renderer.domElement);
115
+ onCanvasReady?.(renderer.domElement);
116
+
117
+ const controls = new OrbitControls(camera, renderer.domElement);
118
+ controls.enableDamping = !reduced;
119
+ controls.dampingFactor = 0.08;
120
+ controls.maxPolarAngle = Math.PI * 0.44;
121
+ controls.autoRotate = !reduced;
122
+ controls.autoRotateSpeed = -0.5;
123
+ const tip = new SceneTip(host);
124
+ controls.addEventListener("start", () => {
125
+ controls.autoRotate = false;
126
+ tip.hide();
127
+ });
128
+ controlsRef.current = controls;
129
+
130
+ const sky = new THREE.HemisphereLight("#66779b", "#161922", 1.7);
131
+ scene.add(sky);
132
+ const moon = new THREE.DirectionalLight("#b6c5de", 1.1);
133
+ moon.position.set(-60, 120, -40);
134
+ scene.add(moon);
135
+
136
+ // screen-space nearest-leaf picking: tiny orbs are hostile raycast
137
+ // targets, so pick whatever leaf lands closest to the pointer
138
+ const PICK_RADIUS = 18;
139
+ const projected = new THREE.Vector3();
140
+ const pickFile = (event: PointerEvent): CityFile | undefined => {
141
+ const treeLayout = layoutRef.current;
142
+ if (!treeLayout || !cameraRef.current || !rendererRef.current) return undefined;
143
+ const rect = rendererRef.current.domElement.getBoundingClientRect();
144
+ const px = event.clientX - rect.left;
145
+ const py = event.clientY - rect.top;
146
+ let best: CityFile | undefined;
147
+ let bestD2 = PICK_RADIUS * PICK_RADIUS;
148
+ for (const file of filesRef.current) {
149
+ const pos = treeLayout.leaf.get(file.id);
150
+ if (!pos) continue;
151
+ projected.set(pos.x, LEAF_Y, pos.z).project(cameraRef.current);
152
+ if (projected.z > 1) continue;
153
+ const sx = ((projected.x + 1) / 2) * rect.width;
154
+ const sy = ((1 - projected.y) / 2) * rect.height;
155
+ const d2 = (sx - px) * (sx - px) + (sy - py) * (sy - py);
156
+ if (d2 < bestD2) {
157
+ bestD2 = d2;
158
+ best = file;
159
+ }
160
+ }
161
+ return best;
162
+ };
163
+
164
+ let downAt: { x: number; y: number } | null = null;
165
+ const onPointerDown = (event: PointerEvent) => {
166
+ downAt = { x: event.clientX, y: event.clientY };
167
+ };
168
+ const onPointerUp = (event: PointerEvent) => {
169
+ if (!downAt) return;
170
+ const moved = Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y);
171
+ downAt = null;
172
+ if (moved > 5) return;
173
+ onSelect(pickFile(event)?.path);
174
+ };
175
+ // hover: cursor + one-line readout, throttled to one pick per frame
176
+ let hoverRaf = 0;
177
+ const onPointerMove = (event: PointerEvent) => {
178
+ if (downAt) {
179
+ tip.hide();
180
+ return;
181
+ }
182
+ if (hoverRaf) return;
183
+ hoverRaf = requestAnimationFrame(() => {
184
+ hoverRaf = 0;
185
+ const file = pickFile(event);
186
+ renderer.domElement.style.cursor = file ? "pointer" : "";
187
+ if (!file) {
188
+ tip.hide();
189
+ return;
190
+ }
191
+ const snapshot = playbackRef.current;
192
+ const touch = snapshot.touchByPath.get(file.path);
193
+ const visits = snapshot.visitsByFile.get(file.id) ?? 0;
194
+ const meta = touch
195
+ ? `${touchWord(touch)} · ${visits} visit${visits === 1 ? "" : "s"}`
196
+ : touchWord(undefined);
197
+ tip.show(file.path, file.ghost ? `${meta} · ghost` : meta, event.clientX, event.clientY);
198
+ });
199
+ };
200
+ const onPointerLeave = () => {
201
+ tip.hide();
202
+ renderer.domElement.style.cursor = "";
203
+ };
204
+ renderer.domElement.addEventListener("pointerdown", onPointerDown);
205
+ renderer.domElement.addEventListener("pointerup", onPointerUp);
206
+ renderer.domElement.addEventListener("pointermove", onPointerMove);
207
+ renderer.domElement.addEventListener("pointerleave", onPointerLeave);
208
+
209
+ const resize = () => {
210
+ if (!hostRef.current) return;
211
+ const w = hostRef.current.clientWidth;
212
+ const h = hostRef.current.clientHeight;
213
+ if (w === 0 || h === 0) return;
214
+ renderer.setSize(w, h);
215
+ camera.aspect = w / h;
216
+ camera.updateProjectionMatrix();
217
+ if (fitPendingRef.current?.()) fitPendingRef.current = null;
218
+ };
219
+ const observer = new ResizeObserver(resize);
220
+ observer.observe(host);
221
+
222
+ const clock = new THREE.Clock();
223
+ const matrix = new THREE.Matrix4();
224
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
225
+ const render = () => {
226
+ controls.update();
227
+ labelSetRef.current?.updateTargets(camera, renderer.domElement.clientWidth, renderer.domElement.clientHeight);
228
+ labelSetRef.current?.ease(reducedRef.current);
229
+
230
+ // pools of light grow toward their attention radius
231
+ const halos = haloMeshRef.current;
232
+ const slots = slotsRef.current;
233
+ const radii = radiiRef.current;
234
+ const treeLayout = layoutRef.current;
235
+ if (halos && treeLayout && slots.length > 0) {
236
+ let moving = false;
237
+ for (let i = 0; i < slots.length; i++) {
238
+ const slot = slots[i];
239
+ const pos = treeLayout.leaf.get(slot.fileId);
240
+ if (!pos) continue;
241
+ let cur = radii.get(slot.fileId) ?? 0;
242
+ const diff = slot.target - cur;
243
+ if (Math.abs(diff) > 0.015) {
244
+ cur = reducedRef.current ? slot.target : cur + diff * 0.12;
245
+ radii.set(slot.fileId, cur);
246
+ moving = true;
247
+ } else if (cur !== slot.target) {
248
+ radii.set(slot.fileId, slot.target);
249
+ cur = slot.target;
250
+ moving = true;
251
+ }
252
+ matrix.compose(
253
+ new THREE.Vector3(pos.x, 0.06, pos.z),
254
+ quaternion,
255
+ new THREE.Vector3(Math.max(cur, 0.01) * 2, Math.max(cur, 0.01) * 2, 1)
256
+ );
257
+ halos.setMatrixAt(i, matrix);
258
+ }
259
+ if (moving) halos.instanceMatrix.needsUpdate = true;
260
+ }
261
+
262
+ const firefly = fireflyRef.current;
263
+ if (firefly?.visible) {
264
+ const t = clock.getElapsedTime();
265
+ const base = firefly.userData.baseScale as number;
266
+ const pulse = reducedRef.current ? 1 : 1 + 0.1 * Math.sin(t * 2.4);
267
+ firefly.scale.setScalar(base * pulse);
268
+ }
269
+ renderer.render(scene, camera);
270
+ frameRef.current = requestAnimationFrame(render);
271
+ };
272
+ render();
273
+
274
+ return () => {
275
+ if (frameRef.current) cancelAnimationFrame(frameRef.current);
276
+ if (hoverRaf) cancelAnimationFrame(hoverRaf);
277
+ renderer.domElement.removeEventListener("pointerdown", onPointerDown);
278
+ renderer.domElement.removeEventListener("pointerup", onPointerUp);
279
+ renderer.domElement.removeEventListener("pointermove", onPointerMove);
280
+ renderer.domElement.removeEventListener("pointerleave", onPointerLeave);
281
+ tip.dispose();
282
+ observer.disconnect();
283
+ controls.dispose();
284
+ renderer.dispose();
285
+ host.removeChild(renderer.domElement);
286
+ scene.clear();
287
+ onCanvasReady?.(null);
288
+ };
289
+ }, [onSelect, onCanvasReady]);
290
+
291
+ // grow the skeleton: ground, edges, leaves, labels
292
+ useEffect(() => {
293
+ const scene = sceneRef.current;
294
+ if (!scene) return;
295
+ filesRef.current = city?.files ?? [];
296
+ layoutRef.current = layout;
297
+ slotsRef.current = [];
298
+ radiiRef.current = new Map();
299
+ if (!city || !layout) return;
300
+
301
+ const group = new THREE.Group();
302
+ const size = layout.radius * 2.3;
303
+
304
+ scene.fog = new THREE.Fog(SKY, size * 2.1, size * 4.2);
305
+
306
+ const ground = new THREE.Mesh(
307
+ new THREE.PlaneGeometry(size * 6, size * 6),
308
+ new THREE.MeshStandardMaterial({ color: "#14171e", roughness: 1 })
309
+ );
310
+ ground.rotation.x = -Math.PI / 2;
311
+ ground.position.y = -0.25;
312
+ group.add(ground);
313
+
314
+ const grid = new THREE.GridHelper(size * 2.4, 40, "#1d222c", "#181c25");
315
+ (grid.material as THREE.Material).transparent = true;
316
+ (grid.material as THREE.Material).opacity = 0.4;
317
+ grid.position.y = -0.24;
318
+ group.add(grid);
319
+
320
+ // branch skeleton, one segment soup with per-vertex colors
321
+ const positions: number[] = [];
322
+ const edgeMeta: { childPath?: string; childFileId?: number; vertexCount: number }[] = [];
323
+ for (const edge of layout.edges) {
324
+ let count = 0;
325
+ for (let i = 0; i < edge.points.length - 1; i++) {
326
+ positions.push(edge.points[i].x, 0.12, edge.points[i].z);
327
+ positions.push(edge.points[i + 1].x, 0.12, edge.points[i + 1].z);
328
+ count += 2;
329
+ }
330
+ edgeMeta.push({ childPath: edge.childPath, childFileId: edge.childFileId, vertexCount: count });
331
+ }
332
+ const edgeGeo = new THREE.BufferGeometry();
333
+ edgeGeo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
334
+ const edgeColors = new Float32Array(positions.length);
335
+ edgeGeo.setAttribute("color", new THREE.BufferAttribute(edgeColors, 3));
336
+ const edges = new THREE.LineSegments(
337
+ edgeGeo,
338
+ new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.9 })
339
+ );
340
+ edgesRef.current = edges;
341
+ edgeMetaRef.current = edgeMeta;
342
+ group.add(edges);
343
+
344
+ // leaves: one orb per file, sized by sqrt(lines) — solid for files on
345
+ // disk, wireframe for ghosts (touched in the session, gone from the
346
+ // repo): shape separates them where a third grey never could.
347
+ // state colors must not fade with distance, so leaf-like materials
348
+ // opt out of the fog; only the ground plane keeps the depth cue
349
+ const leafGeo = new THREE.SphereGeometry(0.5, 10, 8);
350
+ const leafMat = new THREE.MeshBasicMaterial({ toneMapped: false, fog: false });
351
+ const leaves = new THREE.InstancedMesh(leafGeo, leafMat, city.files.length);
352
+ leaves.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(city.files.length * 3), 3);
353
+ const ghostFiles = city.files.filter((file) => file.ghost);
354
+ const ghostIndex = new Map<number, number>();
355
+ ghostFiles.forEach((file, i) => ghostIndex.set(file.id, i));
356
+ ghostIndexRef.current = ghostIndex;
357
+ const matrix = new THREE.Matrix4();
358
+ const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
359
+ for (const file of city.files) {
360
+ const pos = layout.leaf.get(file.id);
361
+ if (!pos) continue;
362
+ if (file.ghost) {
363
+ leaves.setMatrixAt(file.id, hidden);
364
+ continue;
365
+ }
366
+ const scale = Math.min(0.24 + Math.sqrt(Math.max(file.lines, 1)) * 0.045, 1.05);
367
+ matrix.compose(new THREE.Vector3(pos.x, LEAF_Y, pos.z), new THREE.Quaternion(), new THREE.Vector3(scale, scale, scale));
368
+ leaves.setMatrixAt(file.id, matrix);
369
+ leaves.setColorAt(file.id, colors.unvisited);
370
+ }
371
+ leaves.instanceMatrix.needsUpdate = true;
372
+ if (leaves.instanceColor) leaves.instanceColor.needsUpdate = true;
373
+ leafMeshRef.current = leaves;
374
+ group.add(leaves);
375
+
376
+ let ghosts: THREE.InstancedMesh | null = null;
377
+ if (ghostFiles.length > 0) {
378
+ ghosts = new THREE.InstancedMesh(
379
+ new THREE.SphereGeometry(0.55, 8, 5),
380
+ new THREE.MeshBasicMaterial({ wireframe: true, transparent: true, opacity: 0.85, toneMapped: false, fog: false }),
381
+ ghostFiles.length
382
+ );
383
+ ghosts.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(ghostFiles.length * 3), 3);
384
+ for (const file of ghostFiles) {
385
+ const pos = layout.leaf.get(file.id);
386
+ const i = ghostIndex.get(file.id)!;
387
+ if (!pos) {
388
+ ghosts.setMatrixAt(i, hidden);
389
+ continue;
390
+ }
391
+ const scale = Math.min(0.24 + Math.sqrt(Math.max(file.lines, 1)) * 0.045, 1.05) * 0.8;
392
+ matrix.compose(new THREE.Vector3(pos.x, LEAF_Y, pos.z), new THREE.Quaternion(), new THREE.Vector3(scale, scale, scale));
393
+ ghosts.setMatrixAt(i, matrix);
394
+ ghosts.setColorAt(i, colors.ghost);
395
+ }
396
+ ghosts.instanceMatrix.needsUpdate = true;
397
+ if (ghosts.instanceColor) ghosts.instanceColor.needsUpdate = true;
398
+ ghosts.raycast = () => undefined;
399
+ group.add(ghosts);
400
+ }
401
+ ghostMeshRef.current = ghosts;
402
+
403
+ // pools of light under visited leaves — normal blending with a fixed
404
+ // alpha: additive stacking burned dense directories into one white
405
+ // blob and erased the per-file radius
406
+ const halos = new THREE.InstancedMesh(
407
+ new THREE.PlaneGeometry(1, 1),
408
+ new THREE.MeshBasicMaterial({
409
+ map: haloTexture(),
410
+ transparent: true,
411
+ opacity: 0.55,
412
+ depthWrite: false,
413
+ toneMapped: false,
414
+ fog: false
415
+ }),
416
+ city.files.length
417
+ );
418
+ halos.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(city.files.length * 3), 3);
419
+ halos.count = 0;
420
+ halos.frustumCulled = false;
421
+ halos.raycast = () => undefined;
422
+ haloMeshRef.current = halos;
423
+ group.add(halos);
424
+
425
+ // directory labels: screen-space LOD in the render loop decides which
426
+ // of the biggest subtrees show their name
427
+ labelSetRef.current = new DirLabelSet(layout.dirs, group, LABEL_Y);
428
+
429
+ const firefly = new THREE.Sprite(
430
+ new THREE.SpriteMaterial({
431
+ map: fireflyTexture(),
432
+ color: FIREFLY_HOT,
433
+ blending: THREE.AdditiveBlending,
434
+ depthWrite: false,
435
+ transparent: true,
436
+ fog: false
437
+ })
438
+ );
439
+ firefly.userData.baseScale = Math.max(size * 0.026, 2.2);
440
+ firefly.visible = false;
441
+ fireflyRef.current = firefly;
442
+ group.add(firefly);
443
+
444
+ // selection is a shape, not a recolor: the ring + beam mark the leaf
445
+ // while its touch color stays intact
446
+ const selectionMat = new THREE.MeshBasicMaterial({
447
+ color: colors.selected,
448
+ transparent: true,
449
+ opacity: 0.9,
450
+ side: THREE.DoubleSide,
451
+ depthWrite: false,
452
+ toneMapped: false,
453
+ fog: false
454
+ });
455
+ const ring = new THREE.Mesh(new THREE.RingGeometry(1.2, 1.5, 48), selectionMat);
456
+ ring.rotation.x = -Math.PI / 2;
457
+ ring.visible = false;
458
+ ring.raycast = () => undefined;
459
+ group.add(ring);
460
+ const beam = new THREE.Mesh(
461
+ new THREE.CylinderGeometry(0.05, 0.05, 6, 6, 1, true),
462
+ new THREE.MeshBasicMaterial({
463
+ color: colors.selected,
464
+ transparent: true,
465
+ opacity: 0.32,
466
+ blending: THREE.AdditiveBlending,
467
+ depthWrite: false,
468
+ toneMapped: false,
469
+ fog: false
470
+ })
471
+ );
472
+ beam.visible = false;
473
+ beam.raycast = () => undefined;
474
+ group.add(beam);
475
+ selectionRef.current = { ring, beam };
476
+
477
+ const trail = new TrailRenderer(1.6);
478
+ trailRef.current = trail;
479
+ group.add(trail.object);
480
+
481
+ groupRef.current = group;
482
+ scene.add(group);
483
+
484
+ // keep the canonical viewing direction, but pull back exactly far
485
+ // enough that every leaf fits the viewport's frustum — nominal radius
486
+ // ignores the aspect ratio and overflows short windows
487
+ const fitView = (): boolean => {
488
+ const camera = cameraRef.current;
489
+ const controls = controlsRef.current;
490
+ if (!camera || !controls) return true;
491
+ const dir = new THREE.Vector3(0.3, 0.66, 0.47).normalize();
492
+ const points = [...layout.leaf.values()].map((pos) => new THREE.Vector3(pos.x, LEAF_Y, pos.z));
493
+ const fitted = fitDistance(camera, dir, points);
494
+ if (fitted === null) return false;
495
+ // breathing room so edge leaves clear the HUD overlays
496
+ const distance = fitted * 1.12;
497
+ camera.position.copy(dir).multiplyScalar(distance);
498
+ controls.target.set(0, 0, 0);
499
+ controls.minDistance = size * 0.15;
500
+ controls.maxDistance = Math.max(size * 2.4, distance * 1.2);
501
+ controls.update();
502
+ return true;
503
+ };
504
+ fitPendingRef.current = fitView() ? null : fitView;
505
+
506
+ return () => {
507
+ fitPendingRef.current = null;
508
+ disposeGroup(group);
509
+ scene.remove(group);
510
+ groupRef.current = null;
511
+ leafMeshRef.current = null;
512
+ ghostMeshRef.current = null;
513
+ ghostIndexRef.current = new Map();
514
+ selectionRef.current = null;
515
+ haloMeshRef.current = null;
516
+ edgesRef.current = null;
517
+ trailRef.current = null;
518
+ fireflyRef.current = null;
519
+ labelSetRef.current = null;
520
+ };
521
+ }, [city, layout]);
522
+
523
+ // playback → leaf colors, halo targets, branch tinting
524
+ useEffect(() => {
525
+ const leaves = leafMeshRef.current;
526
+ const ghosts = ghostMeshRef.current;
527
+ const ghostIndex = ghostIndexRef.current;
528
+ const halos = haloMeshRef.current;
529
+ const edges = edgesRef.current;
530
+ if (!leaves || !halos || !edges || !city || !layout) return;
531
+
532
+ const radii = radiiRef.current;
533
+ const slots: HaloSlot[] = [];
534
+ const present = new Set<number>();
535
+ // directories on a path to a touched file — branches brighten as a
536
+ // single neutral tone, classification stays on the leaves
537
+ const litDirs = new Set<string>();
538
+ for (const file of city.files) {
539
+ const touch = playback.touchByFile.get(file.id);
540
+ let leafColor = file.ghost ? colors.ghost : colors.unvisited;
541
+ if (touch) {
542
+ leafColor = colors[touch];
543
+ const visits = playback.visitsByFile.get(file.id) ?? 1;
544
+ slots.push({ fileId: file.id, target: haloRadius(visits), color: colors[touch] });
545
+ present.add(file.id);
546
+ const parts = file.path.split("/");
547
+ let path = "";
548
+ for (let i = 0; i < parts.length - 1; i++) {
549
+ path = path ? `${path}/${parts[i]}` : parts[i];
550
+ litDirs.add(path);
551
+ }
552
+ }
553
+ if (file.ghost) {
554
+ const i = ghostIndex.get(file.id);
555
+ if (ghosts && i !== undefined) ghosts.setColorAt(i, leafColor);
556
+ } else {
557
+ leaves.setColorAt(file.id, leafColor);
558
+ }
559
+ }
560
+ for (const [fileId, cur] of radii) {
561
+ if (cur > 0.04 && !present.has(fileId)) {
562
+ slots.push({ fileId, target: 0, color: colors.unvisited });
563
+ } else if (cur <= 0.04 && !present.has(fileId)) {
564
+ radii.delete(fileId);
565
+ }
566
+ }
567
+ slots.forEach((slot, i) => halos.setColorAt(i, slot.color));
568
+ halos.count = slots.length;
569
+ if (halos.instanceColor) halos.instanceColor.needsUpdate = true;
570
+ if (leaves.instanceColor) leaves.instanceColor.needsUpdate = true;
571
+ if (ghosts?.instanceColor) ghosts.instanceColor.needsUpdate = true;
572
+ slotsRef.current = slots;
573
+
574
+ // brighten branches that lead to light
575
+ const colorAttr = edges.geometry.getAttribute("color") as THREE.BufferAttribute;
576
+ let vertex = 0;
577
+ for (const meta of edgeMetaRef.current) {
578
+ let lit = false;
579
+ if (meta.childFileId !== undefined) {
580
+ lit = playback.touchByFile.has(meta.childFileId);
581
+ } else if (meta.childPath) {
582
+ lit = litDirs.has(meta.childPath);
583
+ }
584
+ const tinted = lit ? EDGE_LIT : EDGE_BASE;
585
+ for (let v = 0; v < meta.vertexCount; v++) {
586
+ colorAttr.setXYZ(vertex++, tinted.r, tinted.g, tinted.b);
587
+ }
588
+ }
589
+ colorAttr.needsUpdate = true;
590
+ }, [city, layout, playback]);
591
+
592
+ // selection marker follows the selected leaf
593
+ useEffect(() => {
594
+ const selection = selectionRef.current;
595
+ if (!selection || !city || !layout) return;
596
+ const file = selectedPath ? city.files.find((f) => f.path === selectedPath) : undefined;
597
+ const pos = file ? layout.leaf.get(file.id) : undefined;
598
+ if (pos) {
599
+ selection.ring.position.set(pos.x, 0.1, pos.z);
600
+ selection.beam.position.set(pos.x, 3, pos.z);
601
+ selection.ring.visible = true;
602
+ selection.beam.visible = true;
603
+ // the inspector opens over the right edge; pan the leaf clear of it
604
+ const camera = cameraRef.current;
605
+ const controls = controlsRef.current;
606
+ const canvas = rendererRef.current?.domElement;
607
+ if (camera && controls && canvas) {
608
+ controls.autoRotate = false;
609
+ ensureVisible(
610
+ camera,
611
+ controls,
612
+ new THREE.Vector3(pos.x, LEAF_Y, pos.z),
613
+ canvas.clientWidth,
614
+ canvas.clientHeight,
615
+ INSPECTOR_RESERVED_PX
616
+ );
617
+ }
618
+ } else {
619
+ selection.ring.visible = false;
620
+ selection.beam.visible = false;
621
+ }
622
+ }, [city, layout, selectedPath]);
623
+
624
+ // trail arcs + firefly
625
+ useEffect(() => {
626
+ const trail = trailRef.current;
627
+ if (!trail || !city || !layout) return;
628
+
629
+ // playback resolves fileId with the same path key the citymap uses, so a
630
+ // target still missing one has no leaf to land on
631
+ const targetFiles = playback.recentTargets
632
+ .map((target) => (target.fileId !== undefined ? city.files[target.fileId] : undefined))
633
+ .filter((file): file is CityFile => Boolean(file && layout.leaf.get(file.id)));
634
+
635
+ const firefly = fireflyRef.current;
636
+ if (firefly) {
637
+ const head = targetFiles[targetFiles.length - 1];
638
+ if (head) {
639
+ const pos = layout.leaf.get(head.id)!;
640
+ firefly.position.set(pos.x, LEAF_Y + 1.7, pos.z);
641
+ firefly.visible = true;
642
+ } else {
643
+ firefly.visible = false;
644
+ }
645
+ }
646
+
647
+ trail.update(
648
+ targetFiles.map((file) => {
649
+ const pos = layout.leaf.get(file.id)!;
650
+ return new THREE.Vector3(pos.x, LEAF_Y + 0.3, pos.z);
651
+ })
652
+ );
653
+ }, [city, layout, playback]);
654
+
655
+ return <div className="city-scene" ref={hostRef} aria-label="Firefly tree" />;
656
+ }