@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,638 @@
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
+ EMBER,
10
+ ensureVisible,
11
+ fitDistance,
12
+ prefersReducedMotion,
13
+ SceneTip,
14
+ SKY,
15
+ touchColors
16
+ } from "./sceneUtils";
17
+ import { fireflyTexture } from "./textures";
18
+ import { TrailRenderer } from "./trail";
19
+
20
+ interface CitySceneProps {
21
+ city?: CityMap;
22
+ playback: FilePlayback;
23
+ selectedPath?: string;
24
+ onSelect: (path?: string) => void;
25
+ onCanvasReady?: (canvas: HTMLCanvasElement | null) => void;
26
+ // static map mode: with no session to drive attention height, raise terrain
27
+ // columns by file size (lines of code) instead of leaving the plain flat
28
+ locHeights?: boolean;
29
+ }
30
+
31
+ // Attention terrain: the map is a flat dark plain (fog of war); height is
32
+ // earned by attention — touch depth × revisits — so mountains grow where the
33
+ // walker lingered. Light is data: only touched terrain gets bright color.
34
+ const colors: Record<Touch | "unvisited" | "ghost" | "selected", THREE.Color> = {
35
+ unvisited: new THREE.Color("#5b6372"),
36
+ ghost: new THREE.Color("#404551"),
37
+ ...touchColors
38
+ };
39
+
40
+ const TILE_H = 0.14;
41
+ const LABEL_Y = 2.4;
42
+ // the inspector docks on the right; selection pans the camera clear of it
43
+ const INSPECTOR_RESERVED_PX = 348;
44
+
45
+ function attentionHeight(touch: Touch, visits: number): number {
46
+ const base = touch === "edit" ? 7.2 : touch === "read" ? 4.2 : 1.6;
47
+ return base * (1 + 0.35 * Math.log2(Math.max(visits, 1)));
48
+ }
49
+
50
+ // static map height: normalized lines of code, log-scaled so a few huge files
51
+ // don't flatten everything else. maxLog is log2(largest file's lines).
52
+ const LOC_MIN_H = 0.3;
53
+ const LOC_MAX_H = 16;
54
+ // gamma > 1 exaggerates the top end: small files stay low, big files spike, so
55
+ // the skyline reads as a city (towers vs shacks) instead of a uniform plateau
56
+ const LOC_HEIGHT_GAMMA = 2.2;
57
+ // normalized 0..1 position of a file by lines of code (log-scaled)
58
+ function locFraction(lines: number, maxLog: number): number {
59
+ if (maxLog <= 0) return 0;
60
+ return Math.min(1, Math.log2(Math.max(lines, 1)) / maxLog);
61
+ }
62
+ function locHeight(t: number): number {
63
+ return LOC_MIN_H + Math.pow(t, LOC_HEIGHT_GAMMA) * (LOC_MAX_H - LOC_MIN_H);
64
+ }
65
+
66
+ // LOC tier ramp: small files stay grey, then warm up through orange and purple
67
+ // to red for the largest files. Stops are interpolated so the terrain reads as
68
+ // a continuous gradient rather than hard bands.
69
+ const LOC_RAMP: { at: number; color: THREE.Color }[] = [
70
+ { at: 0.0, color: new THREE.Color("#5b6372") }, // grey (matches unvisited)
71
+ { at: 0.35, color: new THREE.Color("#e0894f") }, // orange
72
+ { at: 0.7, color: new THREE.Color("#9a6bd8") }, // purple
73
+ { at: 1.0, color: new THREE.Color("#e0524f") } // red
74
+ ];
75
+ function locColor(t: number): THREE.Color {
76
+ for (let i = 1; i < LOC_RAMP.length; i++) {
77
+ if (t <= LOC_RAMP[i].at) {
78
+ const lo = LOC_RAMP[i - 1];
79
+ const hi = LOC_RAMP[i];
80
+ const span = hi.at - lo.at;
81
+ const k = span > 0 ? (t - lo.at) / span : 0;
82
+ return lo.color.clone().lerp(hi.color, k);
83
+ }
84
+ }
85
+ return LOC_RAMP[LOC_RAMP.length - 1].color.clone();
86
+ }
87
+
88
+ interface TerrainSlot {
89
+ fileId: number;
90
+ target: number;
91
+ color: THREE.Color;
92
+ }
93
+
94
+ export function CityScene({ city, playback, selectedPath, onSelect, onCanvasReady, locHeights }: CitySceneProps) {
95
+ const hostRef = useRef<HTMLDivElement | null>(null);
96
+ const tileMeshRef = useRef<THREE.InstancedMesh | null>(null);
97
+ const terrainMeshRef = useRef<THREE.InstancedMesh | null>(null);
98
+ const filesRef = useRef<CityFile[]>([]);
99
+ const slotsRef = useRef<TerrainSlot[]>([]);
100
+ const heightsRef = useRef<Map<number, number>>(new Map());
101
+ const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
102
+ const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
103
+ const controlsRef = useRef<OrbitControls | null>(null);
104
+ const sceneRef = useRef<THREE.Scene | null>(null);
105
+ const cityGroupRef = useRef<THREE.Group | null>(null);
106
+ const trailRef = useRef<TrailRenderer | null>(null);
107
+ const fireflyRef = useRef<THREE.Sprite | null>(null);
108
+ const labelSetRef = useRef<DirLabelSet | null>(null);
109
+ const frameRef = useRef<number | null>(null);
110
+ const reducedRef = useRef(false);
111
+ const boundsRef = useRef({ cx: 0, cz: 0, size: 120 });
112
+ // handlers live in the mount effect; they read playback through this ref
113
+ const playbackRef = useRef(playback);
114
+ playbackRef.current = playback;
115
+ // camera fit deferred while the viewport reports no size (hidden pane,
116
+ // background tab); resize retries it instead of leaving the camera at NaN
117
+ const fitPendingRef = useRef<(() => boolean) | null>(null);
118
+
119
+ const bounds = useMemo(() => {
120
+ if (!city || city.files.length === 0) return { cx: 0, cz: 0, size: 120, halfW: 60, halfD: 60 };
121
+ let minX = Infinity;
122
+ let maxX = -Infinity;
123
+ let minZ = Infinity;
124
+ let maxZ = -Infinity;
125
+ for (const file of city.files) {
126
+ minX = Math.min(minX, file.rect.x);
127
+ maxX = Math.max(maxX, file.rect.x + file.rect.w);
128
+ minZ = Math.min(minZ, file.rect.z);
129
+ maxZ = Math.max(maxZ, file.rect.z + file.rect.d);
130
+ }
131
+ return {
132
+ cx: (minX + maxX) / 2,
133
+ cz: (minZ + maxZ) / 2,
134
+ size: Math.max(maxX - minX, maxZ - minZ, 60),
135
+ halfW: (maxX - minX) / 2,
136
+ halfD: (maxZ - minZ) / 2
137
+ };
138
+ }, [city]);
139
+
140
+ useEffect(() => {
141
+ const host = hostRef.current;
142
+ if (!host) return;
143
+ const reduced = prefersReducedMotion();
144
+ reducedRef.current = reduced;
145
+
146
+ const scene = new THREE.Scene();
147
+ scene.background = SKY;
148
+ sceneRef.current = scene;
149
+
150
+ const camera = new THREE.PerspectiveCamera(38, host.clientWidth / host.clientHeight || 1, 0.1, 2400);
151
+ camera.position.set(70, 130, 100);
152
+ cameraRef.current = camera;
153
+
154
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
155
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
156
+ renderer.setSize(host.clientWidth, host.clientHeight);
157
+ rendererRef.current = renderer;
158
+ host.appendChild(renderer.domElement);
159
+ onCanvasReady?.(renderer.domElement);
160
+
161
+ const controls = new OrbitControls(camera, renderer.domElement);
162
+ controls.enableDamping = !reduced;
163
+ controls.dampingFactor = 0.08;
164
+ controls.maxPolarAngle = Math.PI * 0.44;
165
+ // the god view drifts slowly around the terrain until the user takes over
166
+ controls.autoRotate = !reduced;
167
+ controls.autoRotateSpeed = -0.5;
168
+ const tip = new SceneTip(host);
169
+ controls.addEventListener("start", () => {
170
+ controls.autoRotate = false;
171
+ tip.hide();
172
+ });
173
+ controlsRef.current = controls;
174
+
175
+ const sky = new THREE.HemisphereLight("#66779b", "#161922", 1.7);
176
+ scene.add(sky);
177
+ const moon = new THREE.DirectionalLight("#b6c5de", 1.1);
178
+ moon.position.set(-60, 120, -40);
179
+ scene.add(moon);
180
+
181
+ const raycaster = new THREE.Raycaster();
182
+ const pointer = new THREE.Vector2();
183
+ const pickFile = (event: PointerEvent): CityFile | undefined => {
184
+ if (!cameraRef.current || !rendererRef.current) return undefined;
185
+ const rect = rendererRef.current.domElement.getBoundingClientRect();
186
+ pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
187
+ pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
188
+ raycaster.setFromCamera(pointer, cameraRef.current);
189
+ const targets = [terrainMeshRef.current, tileMeshRef.current].filter(Boolean) as THREE.Object3D[];
190
+ const hit = raycaster.intersectObjects(targets, false)[0];
191
+ if (!hit || hit.instanceId === undefined) return undefined;
192
+ if (hit.object === terrainMeshRef.current) {
193
+ const slot = slotsRef.current[hit.instanceId];
194
+ return slot ? filesRef.current[slot.fileId] : undefined;
195
+ }
196
+ return filesRef.current[hit.instanceId];
197
+ };
198
+
199
+ let downAt: { x: number; y: number } | null = null;
200
+ const onPointerDown = (event: PointerEvent) => {
201
+ downAt = { x: event.clientX, y: event.clientY };
202
+ };
203
+ const onPointerUp = (event: PointerEvent) => {
204
+ if (!downAt) return;
205
+ const moved = Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y);
206
+ downAt = null;
207
+ if (moved > 5) return;
208
+ onSelect(pickFile(event)?.path);
209
+ };
210
+ // hover: cursor + one-line readout, throttled to one raycast per frame
211
+ let hoverRaf = 0;
212
+ const onPointerMove = (event: PointerEvent) => {
213
+ if (downAt) {
214
+ tip.hide();
215
+ return;
216
+ }
217
+ if (hoverRaf) return;
218
+ hoverRaf = requestAnimationFrame(() => {
219
+ hoverRaf = 0;
220
+ const file = pickFile(event);
221
+ renderer.domElement.style.cursor = file ? "pointer" : "";
222
+ if (!file) {
223
+ tip.hide();
224
+ return;
225
+ }
226
+ const snapshot = playbackRef.current;
227
+ const touch = snapshot.touchByPath.get(file.path);
228
+ const visits = snapshot.visitsByFile.get(file.id) ?? 0;
229
+ const meta = touch
230
+ ? `${touchWord(touch)} · ${visits} visit${visits === 1 ? "" : "s"}`
231
+ : touchWord(undefined);
232
+ tip.show(file.path, file.ghost ? `${meta} · ghost` : meta, event.clientX, event.clientY);
233
+ });
234
+ };
235
+ const onPointerLeave = () => {
236
+ tip.hide();
237
+ renderer.domElement.style.cursor = "";
238
+ };
239
+ renderer.domElement.addEventListener("pointerdown", onPointerDown);
240
+ renderer.domElement.addEventListener("pointerup", onPointerUp);
241
+ renderer.domElement.addEventListener("pointermove", onPointerMove);
242
+ renderer.domElement.addEventListener("pointerleave", onPointerLeave);
243
+
244
+ const resize = () => {
245
+ if (!hostRef.current) return;
246
+ const w = hostRef.current.clientWidth;
247
+ const h = hostRef.current.clientHeight;
248
+ if (w === 0 || h === 0) return;
249
+ renderer.setSize(w, h);
250
+ camera.aspect = w / h;
251
+ camera.updateProjectionMatrix();
252
+ if (fitPendingRef.current?.()) fitPendingRef.current = null;
253
+ };
254
+ const observer = new ResizeObserver(resize);
255
+ observer.observe(host);
256
+
257
+ const clock = new THREE.Clock();
258
+ const matrix = new THREE.Matrix4();
259
+ const quaternion = new THREE.Quaternion();
260
+ const render = () => {
261
+ controls.update();
262
+ labelSetRef.current?.updateTargets(camera, renderer.domElement.clientWidth, renderer.domElement.clientHeight);
263
+ labelSetRef.current?.ease(reducedRef.current);
264
+
265
+ // grow / shrink terrain columns toward their attention targets
266
+ const terrain = terrainMeshRef.current;
267
+ const slots = slotsRef.current;
268
+ const heights = heightsRef.current;
269
+ if (terrain && slots.length > 0) {
270
+ let moving = false;
271
+ for (let i = 0; i < slots.length; i++) {
272
+ const slot = slots[i];
273
+ const file = filesRef.current[slot.fileId];
274
+ if (!file) continue;
275
+ let cur = heights.get(slot.fileId) ?? 0;
276
+ const diff = slot.target - cur;
277
+ if (Math.abs(diff) > 0.015) {
278
+ cur = reducedRef.current ? slot.target : cur + diff * 0.13;
279
+ heights.set(slot.fileId, cur);
280
+ moving = true;
281
+ } else if (cur !== slot.target) {
282
+ heights.set(slot.fileId, slot.target);
283
+ cur = slot.target;
284
+ moving = true;
285
+ }
286
+ const sx = Math.max(file.rect.w, 0.45) + 0.04;
287
+ const sz = Math.max(file.rect.d, 0.45) + 0.04;
288
+ matrix.compose(
289
+ new THREE.Vector3(
290
+ file.rect.x + file.rect.w / 2 - boundsRef.current.cx,
291
+ Math.max(cur, 0.02) / 2 + TILE_H,
292
+ file.rect.z + file.rect.d / 2 - boundsRef.current.cz
293
+ ),
294
+ quaternion,
295
+ new THREE.Vector3(sx, Math.max(cur, 0.02), sz)
296
+ );
297
+ terrain.setMatrixAt(i, matrix);
298
+ }
299
+ if (moving) terrain.instanceMatrix.needsUpdate = true;
300
+ }
301
+
302
+ const firefly = fireflyRef.current;
303
+ if (firefly?.visible) {
304
+ const t = clock.getElapsedTime();
305
+ const base = firefly.userData.baseScale as number;
306
+ const pulse = reducedRef.current ? 1 : 1 + 0.1 * Math.sin(t * 2.4);
307
+ firefly.scale.setScalar(base * pulse);
308
+ }
309
+ renderer.render(scene, camera);
310
+ frameRef.current = requestAnimationFrame(render);
311
+ };
312
+ render();
313
+
314
+ return () => {
315
+ if (frameRef.current) cancelAnimationFrame(frameRef.current);
316
+ if (hoverRaf) cancelAnimationFrame(hoverRaf);
317
+ renderer.domElement.removeEventListener("pointerdown", onPointerDown);
318
+ renderer.domElement.removeEventListener("pointerup", onPointerUp);
319
+ renderer.domElement.removeEventListener("pointermove", onPointerMove);
320
+ renderer.domElement.removeEventListener("pointerleave", onPointerLeave);
321
+ tip.dispose();
322
+ observer.disconnect();
323
+ controls.dispose();
324
+ renderer.dispose();
325
+ host.removeChild(renderer.domElement);
326
+ scene.clear();
327
+ onCanvasReady?.(null);
328
+ };
329
+ }, [onSelect, onCanvasReady]);
330
+
331
+ // build the plain: ground, grid, district plates, flat file tiles
332
+ useEffect(() => {
333
+ const scene = sceneRef.current;
334
+ if (!scene) return;
335
+ filesRef.current = city?.files ?? [];
336
+ slotsRef.current = [];
337
+ heightsRef.current = new Map();
338
+ boundsRef.current = bounds;
339
+ if (!city || city.files.length === 0) return;
340
+
341
+ const group = new THREE.Group();
342
+ const size = bounds.size;
343
+
344
+ scene.fog = new THREE.Fog(SKY, size * 2.1, size * 4.2);
345
+
346
+ const ground = new THREE.Mesh(
347
+ new THREE.PlaneGeometry(size * 6, size * 6),
348
+ new THREE.MeshStandardMaterial({ color: "#14171e", roughness: 1 })
349
+ );
350
+ ground.rotation.x = -Math.PI / 2;
351
+ ground.position.y = -0.32;
352
+ group.add(ground);
353
+
354
+ const grid = new THREE.GridHelper(size * 2.8, 46, "#20242e", "#1a1e27");
355
+ (grid.material as THREE.Material).transparent = true;
356
+ (grid.material as THREE.Material).opacity = 0.5;
357
+ grid.position.y = -0.3;
358
+ group.add(grid);
359
+
360
+ const plateDirs = city.dirs.filter((dir) => dir.depth <= 3 && dir.rect.w > 0 && dir.rect.d > 0);
361
+ if (plateDirs.length > 0) {
362
+ const plateGeo = new THREE.BoxGeometry(1, 1, 1);
363
+ const plateMat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0 });
364
+ const plates = new THREE.InstancedMesh(plateGeo, plateMat, plateDirs.length);
365
+ const matrix = new THREE.Matrix4();
366
+ const shade = new THREE.Color();
367
+ plateDirs.forEach((dir, i) => {
368
+ const top = -0.2 + Math.min(dir.depth, 3) * 0.06;
369
+ const height = top + 0.3;
370
+ matrix.compose(
371
+ new THREE.Vector3(
372
+ dir.rect.x + dir.rect.w / 2 - bounds.cx,
373
+ top - height / 2,
374
+ dir.rect.z + dir.rect.d / 2 - bounds.cz
375
+ ),
376
+ new THREE.Quaternion(),
377
+ new THREE.Vector3(dir.rect.w, height, dir.rect.d)
378
+ );
379
+ plates.setMatrixAt(i, matrix);
380
+ shade.set("#1a1f29").lerp(new THREE.Color("#252b37"), Math.min(dir.depth, 3) / 3);
381
+ plates.setColorAt(i, shade);
382
+ });
383
+ plates.instanceMatrix.needsUpdate = true;
384
+ if (plates.instanceColor) plates.instanceColor.needsUpdate = true;
385
+ group.add(plates);
386
+ }
387
+
388
+ // flat tiles: every file exists on the map, dark until visited
389
+ const tileGeo = new THREE.BoxGeometry(1, 1, 1);
390
+ const tileMat = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 });
391
+ const tiles = new THREE.InstancedMesh(tileGeo, tileMat, city.files.length);
392
+ const matrix = new THREE.Matrix4();
393
+ for (const file of city.files) {
394
+ const sx = Math.max(file.rect.w, 0.45);
395
+ const sz = Math.max(file.rect.d, 0.45);
396
+ const x = file.rect.x + file.rect.w / 2 - bounds.cx;
397
+ const z = file.rect.z + file.rect.d / 2 - bounds.cz;
398
+ matrix.compose(new THREE.Vector3(x, TILE_H / 2, z), new THREE.Quaternion(), new THREE.Vector3(sx, TILE_H, sz));
399
+ tiles.setMatrixAt(file.id, matrix);
400
+ tiles.setColorAt(file.id, baseColor(file));
401
+ }
402
+ tiles.instanceMatrix.needsUpdate = true;
403
+ if (tiles.instanceColor) tiles.instanceColor.needsUpdate = true;
404
+ tileMeshRef.current = tiles;
405
+ group.add(tiles);
406
+
407
+ // attention terrain: unlit columns that grow out of the plain
408
+ const terrain = new THREE.InstancedMesh(
409
+ attentionColumnGeometry(),
410
+ new THREE.MeshBasicMaterial({ toneMapped: false, vertexColors: true }),
411
+ city.files.length
412
+ );
413
+ terrain.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(city.files.length * 3), 3);
414
+ terrain.count = 0;
415
+ terrain.frustumCulled = false;
416
+ terrainMeshRef.current = terrain;
417
+ group.add(terrain);
418
+
419
+ // district labels above the plain; drawn over the columns so a mountain
420
+ // range never buries the name of the district behind it
421
+ labelSetRef.current = new DirLabelSet(
422
+ city.dirs
423
+ .filter((dir) => dir.depth >= 1 && dir.fileCount > 0 && dir.rect.w > 0 && dir.rect.d > 0)
424
+ .map((dir) => ({
425
+ name: dirBasename(dir.path),
426
+ x: dir.rect.x + dir.rect.w / 2 - bounds.cx,
427
+ z: dir.rect.z + dir.rect.d / 2 - bounds.cz,
428
+ radius: Math.hypot(dir.rect.w, dir.rect.d) / 2,
429
+ fileCount: dir.fileCount,
430
+ depth: dir.depth
431
+ })),
432
+ group,
433
+ LABEL_Y,
434
+ true
435
+ );
436
+
437
+ const firefly = new THREE.Sprite(
438
+ new THREE.SpriteMaterial({
439
+ map: fireflyTexture(),
440
+ color: EMBER,
441
+ blending: THREE.AdditiveBlending,
442
+ depthWrite: false,
443
+ transparent: true
444
+ })
445
+ );
446
+ firefly.userData.baseScale = Math.max(size * 0.028, 2.2);
447
+ firefly.visible = false;
448
+ fireflyRef.current = firefly;
449
+ group.add(firefly);
450
+
451
+ const trail = new TrailRenderer(1.4);
452
+ trailRef.current = trail;
453
+ group.add(trail.object);
454
+
455
+ cityGroupRef.current = group;
456
+ scene.add(group);
457
+
458
+ // keep the canonical viewing direction, but pull back exactly far
459
+ // enough that the whole plain fits the viewport's frustum — the fixed
460
+ // size multiplier ignores the aspect ratio and overflows short windows
461
+ const fitView = (): boolean => {
462
+ const camera = cameraRef.current;
463
+ const controls = controlsRef.current;
464
+ if (!camera || !controls) return true;
465
+ const dir = new THREE.Vector3(0.46, 1.08, 0.72).normalize();
466
+ const corners: THREE.Vector3[] = [];
467
+ for (const sx of [-1, 1]) {
468
+ for (const sz of [-1, 1]) {
469
+ corners.push(new THREE.Vector3(sx * bounds.halfW, 0, sz * bounds.halfD));
470
+ }
471
+ }
472
+ const fitted = fitDistance(camera, dir, corners);
473
+ if (fitted === null) return false;
474
+ // breathing room so map edges clear the HUD overlays; floor keeps
475
+ // near-empty maps from parking the camera on the ground
476
+ const distance = Math.max(fitted * 1.12, size * 0.6);
477
+ camera.position.copy(dir).multiplyScalar(distance);
478
+ controls.target.set(0, 0, 0);
479
+ controls.minDistance = size * 0.18;
480
+ controls.maxDistance = Math.max(size * 2.6, distance * 1.2);
481
+ controls.update();
482
+ return true;
483
+ };
484
+ fitPendingRef.current = fitView() ? null : fitView;
485
+
486
+ return () => {
487
+ fitPendingRef.current = null;
488
+ disposeGroup(group);
489
+ scene.remove(group);
490
+ cityGroupRef.current = null;
491
+ tileMeshRef.current = null;
492
+ terrainMeshRef.current = null;
493
+ trailRef.current = null;
494
+ fireflyRef.current = null;
495
+ labelSetRef.current = null;
496
+ };
497
+ }, [city, bounds]);
498
+
499
+ // playback → terrain targets and colors
500
+ useEffect(() => {
501
+ const terrain = terrainMeshRef.current;
502
+ const tiles = tileMeshRef.current;
503
+ if (!terrain || !tiles || !city) return;
504
+
505
+ const heights = heightsRef.current;
506
+ const slots: TerrainSlot[] = [];
507
+ const present = new Set<number>();
508
+ // static map mode: no session drives attention, so raise every column by
509
+ // its lines of code instead of leaving the terrain flat
510
+ const maxLog = locHeights
511
+ ? Math.log2(Math.max(1, city.files.reduce((m, f) => Math.max(m, f.lines), 1)))
512
+ : 0;
513
+ for (const file of city.files) {
514
+ const touch = playback.touchByFile.get(file.id);
515
+ const selected = file.path === selectedPath;
516
+ tiles.setColorAt(file.id, selected ? colors.selected : baseColor(file));
517
+ if (touch) {
518
+ const visits = playback.visitsByFile.get(file.id) ?? 1;
519
+ let color = colors[touch];
520
+ if (file.ghost) color = color.clone().lerp(colors.ghost, 0.45);
521
+ if (selected) color = colors.selected;
522
+ slots.push({ fileId: file.id, target: attentionHeight(touch, visits), color });
523
+ present.add(file.id);
524
+ } else if (locHeights) {
525
+ const t = locFraction(file.lines, maxLog);
526
+ let color = locColor(t);
527
+ if (file.ghost) color = color.lerp(colors.ghost, 0.45);
528
+ if (selected) color = colors.selected;
529
+ slots.push({ fileId: file.id, target: locHeight(t), color });
530
+ present.add(file.id);
531
+ }
532
+ }
533
+ // let columns that just went dark shrink back into the plain
534
+ for (const [fileId, cur] of heights) {
535
+ if (cur > 0.04 && !present.has(fileId)) {
536
+ slots.push({ fileId, target: 0, color: colors.unvisited });
537
+ } else if (cur <= 0.04 && !present.has(fileId)) {
538
+ heights.delete(fileId);
539
+ }
540
+ }
541
+
542
+ slots.forEach((slot, i) => terrain.setColorAt(i, slot.color));
543
+ terrain.count = slots.length;
544
+ if (terrain.instanceColor) terrain.instanceColor.needsUpdate = true;
545
+ if (tiles.instanceColor) tiles.instanceColor.needsUpdate = true;
546
+ slotsRef.current = slots;
547
+ }, [city, playback, selectedPath, locHeights]);
548
+
549
+ // the inspector opens over the right edge; pan the selected tile clear of it
550
+ useEffect(() => {
551
+ if (!city || !selectedPath) return;
552
+ const file = city.files.find((f) => f.path === selectedPath);
553
+ const camera = cameraRef.current;
554
+ const controls = controlsRef.current;
555
+ const canvas = rendererRef.current?.domElement;
556
+ if (!file || !camera || !controls || !canvas) return;
557
+ controls.autoRotate = false;
558
+ const top = heightsRef.current.get(file.id) ?? TILE_H;
559
+ const world = centerFor(file, bounds);
560
+ world.y = top;
561
+ ensureVisible(camera, controls, world, canvas.clientWidth, canvas.clientHeight, INSPECTOR_RESERVED_PX);
562
+ }, [city, bounds, selectedPath]);
563
+
564
+ // trail: ballistic arcs between recent fixations + the firefly at the head
565
+ useEffect(() => {
566
+ const trail = trailRef.current;
567
+ if (!trail || !city) return;
568
+
569
+ // playback resolves fileId with the same path key the citymap uses, so a
570
+ // target still missing one has no tile to land on
571
+ const targetFiles = playback.recentTargets
572
+ .map((target) => (target.fileId !== undefined ? city.files[target.fileId] : undefined))
573
+ .filter((file): file is CityFile => Boolean(file));
574
+
575
+ const peakFor = (file: CityFile): number => {
576
+ const touch = playback.touchByFile.get(file.id);
577
+ const visits = playback.visitsByFile.get(file.id) ?? 1;
578
+ return touch ? attentionHeight(touch, visits) : TILE_H;
579
+ };
580
+
581
+ const firefly = fireflyRef.current;
582
+ if (firefly) {
583
+ const head = targetFiles[targetFiles.length - 1];
584
+ if (head) {
585
+ const p = centerFor(head, bounds);
586
+ firefly.position.set(p.x, peakFor(head) + 1.6, p.z);
587
+ firefly.visible = true;
588
+ } else {
589
+ firefly.visible = false;
590
+ }
591
+ }
592
+
593
+ trail.update(
594
+ targetFiles.map((file) => {
595
+ const p = centerFor(file, bounds);
596
+ p.y = peakFor(file) + 0.4;
597
+ return p;
598
+ })
599
+ );
600
+ }, [city, playback, bounds]);
601
+
602
+ return <div className="city-scene" ref={hostRef} aria-label="Attention terrain" />;
603
+ }
604
+
605
+ // Columns must read as phosphorescence, not paint: glow pools at the crest and
606
+ // falls off into the plain. Vertex shade multiplies the per-instance touch
607
+ // color; the top face sits under the side rims so edges catch the most light.
608
+ function attentionColumnGeometry(): THREE.BoxGeometry {
609
+ const geo = new THREE.BoxGeometry(1, 1, 1);
610
+ const pos = geo.getAttribute("position");
611
+ const normal = geo.getAttribute("normal");
612
+ const shade = new Float32Array(pos.count * 3);
613
+ for (let i = 0; i < pos.count; i++) {
614
+ const t = pos.getY(i) + 0.5;
615
+ const glow = normal.getY(i) === 1 ? 0.82 : 0.34 + 0.66 * t * t;
616
+ shade.fill(glow, i * 3, i * 3 + 3);
617
+ }
618
+ geo.setAttribute("color", new THREE.BufferAttribute(shade, 3));
619
+ return geo;
620
+ }
621
+
622
+ function baseColor(file: CityFile): THREE.Color {
623
+ if (file.ghost) return colors.ghost;
624
+ let h = 2166136261;
625
+ for (let i = 0; i < file.path.length; i++) {
626
+ h = Math.imul(h ^ file.path.charCodeAt(i), 16777619);
627
+ }
628
+ const jitter = ((h >>> 0) % 1000) / 1000 - 0.5;
629
+ return colors.unvisited.clone().offsetHSL(0, 0, jitter * 0.05);
630
+ }
631
+
632
+ function centerFor(file: CityFile, bounds: { cx: number; cz: number }): THREE.Vector3 {
633
+ return new THREE.Vector3(file.rect.x + file.rect.w / 2 - bounds.cx, 0, file.rect.z + file.rect.d / 2 - bounds.cz);
634
+ }
635
+
636
+ function dirBasename(path: string): string {
637
+ return path.slice(path.lastIndexOf("/") + 1) || path;
638
+ }