@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { labelTexture } from "./textures";
|
|
3
|
+
|
|
4
|
+
// Map-style LOD for directory names, shared by both scenes: a label shows
|
|
5
|
+
// while its subtree spans enough screen pixels — zoom out and only the big
|
|
6
|
+
// forks stay named, push in and deeper directories surface where you're
|
|
7
|
+
// looking. Two colliding labels drop whichever names fewer files.
|
|
8
|
+
|
|
9
|
+
export interface DirLabelEntry {
|
|
10
|
+
name: string;
|
|
11
|
+
x: number;
|
|
12
|
+
z: number;
|
|
13
|
+
/** world-space reach of the directory's subtree, drives the LOD cutoff */
|
|
14
|
+
radius: number;
|
|
15
|
+
fileCount: number;
|
|
16
|
+
depth: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DirLabel {
|
|
20
|
+
sprite: THREE.Sprite;
|
|
21
|
+
entry: DirLabelEntry;
|
|
22
|
+
aspect: number;
|
|
23
|
+
target: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const LABEL_MIN_SUBTREE_PX = 60;
|
|
27
|
+
const LABEL_BUDGET = 120;
|
|
28
|
+
|
|
29
|
+
export class DirLabelSet {
|
|
30
|
+
private readonly labels: DirLabel[] = [];
|
|
31
|
+
private readonly y: number;
|
|
32
|
+
private dirty = true;
|
|
33
|
+
private readonly lastCamPos = new THREE.Vector3(Infinity, Infinity, Infinity);
|
|
34
|
+
private readonly lastCamQuat = new THREE.Quaternion(0, 0, 0, 0);
|
|
35
|
+
private lastViewW = 0;
|
|
36
|
+
private lastViewH = 0;
|
|
37
|
+
private readonly point = new THREE.Vector3();
|
|
38
|
+
|
|
39
|
+
// sprites start invisible and fade in once the first projection pass runs;
|
|
40
|
+
// alwaysOnTop labels (terrain) skip the depth test so mountains never bury
|
|
41
|
+
// the district names
|
|
42
|
+
constructor(entries: DirLabelEntry[], group: THREE.Group, y: number, alwaysOnTop = false) {
|
|
43
|
+
this.y = y;
|
|
44
|
+
const budget = [...entries].sort((a, b) => b.fileCount - a.fileCount).slice(0, LABEL_BUDGET);
|
|
45
|
+
for (const entry of budget) {
|
|
46
|
+
const { texture, aspect } = labelTexture(entry.name);
|
|
47
|
+
const sprite = new THREE.Sprite(
|
|
48
|
+
new THREE.SpriteMaterial({
|
|
49
|
+
map: texture,
|
|
50
|
+
transparent: true,
|
|
51
|
+
opacity: 0,
|
|
52
|
+
depthWrite: false,
|
|
53
|
+
depthTest: !alwaysOnTop,
|
|
54
|
+
toneMapped: false,
|
|
55
|
+
fog: false
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
if (alwaysOnTop) sprite.renderOrder = 20;
|
|
59
|
+
sprite.visible = false;
|
|
60
|
+
sprite.position.set(entry.x, y, entry.z);
|
|
61
|
+
sprite.raycast = () => undefined;
|
|
62
|
+
group.add(sprite);
|
|
63
|
+
this.labels.push({ sprite, entry, aspect, target: 0 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
markDirty() {
|
|
68
|
+
this.dirty = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// reprojects subtrees whenever the camera or viewport moves, picks the
|
|
72
|
+
// labels whose subtree is prominent on screen, drops collision losers
|
|
73
|
+
updateTargets(camera: THREE.PerspectiveCamera, viewW: number, viewH: number) {
|
|
74
|
+
if (this.labels.length === 0 || viewW === 0 || viewH === 0) return;
|
|
75
|
+
const moved =
|
|
76
|
+
this.dirty ||
|
|
77
|
+
!camera.position.equals(this.lastCamPos) ||
|
|
78
|
+
!camera.quaternion.equals(this.lastCamQuat) ||
|
|
79
|
+
viewW !== this.lastViewW ||
|
|
80
|
+
viewH !== this.lastViewH;
|
|
81
|
+
if (!moved) return;
|
|
82
|
+
this.dirty = false;
|
|
83
|
+
this.lastCamPos.copy(camera.position);
|
|
84
|
+
this.lastCamQuat.copy(camera.quaternion);
|
|
85
|
+
this.lastViewW = viewW;
|
|
86
|
+
this.lastViewH = viewH;
|
|
87
|
+
|
|
88
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
89
|
+
const maxDim = Math.max(viewW, viewH);
|
|
90
|
+
interface Candidate {
|
|
91
|
+
label: DirLabel;
|
|
92
|
+
sx: number;
|
|
93
|
+
sy: number;
|
|
94
|
+
pw: number;
|
|
95
|
+
ph: number;
|
|
96
|
+
}
|
|
97
|
+
const candidates: Candidate[] = [];
|
|
98
|
+
for (const label of this.labels) {
|
|
99
|
+
this.point.set(label.entry.x, this.y, label.entry.z);
|
|
100
|
+
const dist = this.point.distanceTo(camera.position);
|
|
101
|
+
const pxPerWorld = viewH / (2 * dist * tanV);
|
|
102
|
+
const subtreePx = label.entry.radius * pxPerWorld;
|
|
103
|
+
this.point.project(camera);
|
|
104
|
+
const sx = ((this.point.x + 1) / 2) * viewW;
|
|
105
|
+
const sy = ((1 - this.point.y) / 2) * viewH;
|
|
106
|
+
const onScreen = this.point.z < 1 && sx > -60 && sx < viewW + 60 && sy > -40 && sy < viewH + 40;
|
|
107
|
+
// too small to matter, or so large we're inside it — either way the
|
|
108
|
+
// name would float over unrelated geometry
|
|
109
|
+
if (!onScreen || subtreePx < LABEL_MIN_SUBTREE_PX || subtreePx > maxDim * 1.6) {
|
|
110
|
+
label.target = 0;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// constant screen-size type, like map labels
|
|
114
|
+
const ph = label.entry.depth <= 1 ? 15 : 13;
|
|
115
|
+
const worldH = ph / pxPerWorld;
|
|
116
|
+
label.sprite.scale.set(worldH * label.aspect, worldH, 1);
|
|
117
|
+
candidates.push({ label, sx, sy, pw: ph * label.aspect, ph });
|
|
118
|
+
}
|
|
119
|
+
candidates.sort((a, b) => b.label.entry.fileCount - a.label.entry.fileCount);
|
|
120
|
+
const kept: Candidate[] = [];
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
const clash = kept.some(
|
|
123
|
+
(other) =>
|
|
124
|
+
Math.abs(other.sx - candidate.sx) < (other.pw + candidate.pw) / 2 + 14 &&
|
|
125
|
+
Math.abs(other.sy - candidate.sy) < (other.ph + candidate.ph) / 2 + 10
|
|
126
|
+
);
|
|
127
|
+
candidate.label.target = clash ? 0 : 1;
|
|
128
|
+
if (!clash) kept.push(candidate);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// labels ease toward their LOD targets each frame
|
|
133
|
+
ease(reduced: boolean) {
|
|
134
|
+
for (const label of this.labels) {
|
|
135
|
+
const material = label.sprite.material as THREE.SpriteMaterial;
|
|
136
|
+
const diff = label.target - material.opacity;
|
|
137
|
+
if (Math.abs(diff) > 0.02) {
|
|
138
|
+
material.opacity = reduced ? label.target : material.opacity + diff * 0.16;
|
|
139
|
+
} else if (material.opacity !== label.target) {
|
|
140
|
+
material.opacity = label.target;
|
|
141
|
+
}
|
|
142
|
+
label.sprite.visible = material.opacity > 0.02;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { Touch } from "../types";
|
|
3
|
+
|
|
4
|
+
// Shared scene vocabulary. The touch colors are the meaning the HUD legend
|
|
5
|
+
// promises; both scenes must draw them identically. Ambient colors (ground,
|
|
6
|
+
// unvisited, ghost) stay per-scene tuning.
|
|
7
|
+
export const SKY = new THREE.Color("#12151c");
|
|
8
|
+
export const EMBER = new THREE.Color("#ff9e5e");
|
|
9
|
+
|
|
10
|
+
export const touchColors: Record<Touch | "selected", THREE.Color> = {
|
|
11
|
+
hit: new THREE.Color("#8fb45f"),
|
|
12
|
+
// chromatic enough to read as blue on lit terrain columns — a paler tint
|
|
13
|
+
// washed out to white and stopped matching the HUD legend
|
|
14
|
+
read: new THREE.Color("#a5c8f1"),
|
|
15
|
+
edit: new THREE.Color("#f0ad5a"),
|
|
16
|
+
selected: new THREE.Color("#f6ead2")
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Distance along `dir` that fits every point inside the camera frustum.
|
|
20
|
+
// Returns null while the viewport has no usable aspect (hidden pane, tab in
|
|
21
|
+
// the background, mid-layout) — fitting then would park the camera at NaN
|
|
22
|
+
// forever; callers must retry once a real resize lands.
|
|
23
|
+
export function fitDistance(
|
|
24
|
+
camera: THREE.PerspectiveCamera,
|
|
25
|
+
dir: THREE.Vector3,
|
|
26
|
+
points: Iterable<THREE.Vector3>
|
|
27
|
+
): number | null {
|
|
28
|
+
if (!Number.isFinite(camera.aspect) || camera.aspect <= 0) return null;
|
|
29
|
+
const forward = dir.clone().negate();
|
|
30
|
+
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
|
|
31
|
+
const up = new THREE.Vector3().crossVectors(right, forward);
|
|
32
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
33
|
+
const tanH = tanV * camera.aspect;
|
|
34
|
+
let distance = 0;
|
|
35
|
+
for (const point of points) {
|
|
36
|
+
const depth = point.dot(forward);
|
|
37
|
+
distance = Math.max(
|
|
38
|
+
distance,
|
|
39
|
+
Math.abs(point.dot(right)) / tanH - depth,
|
|
40
|
+
Math.abs(point.dot(up)) / tanV - depth
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return distance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Pan the camera (position + orbit target together, so the view direction
|
|
47
|
+
// holds) until `world` projects inside the viewport's safe area. The right
|
|
48
|
+
// margin reserves room for the inspector panel, which would otherwise sit
|
|
49
|
+
// exactly on top of the leaf the user just selected.
|
|
50
|
+
export function ensureVisible(
|
|
51
|
+
camera: THREE.PerspectiveCamera,
|
|
52
|
+
controls: { target: THREE.Vector3; update: () => void },
|
|
53
|
+
world: THREE.Vector3,
|
|
54
|
+
viewW: number,
|
|
55
|
+
viewH: number,
|
|
56
|
+
reservedRight: number
|
|
57
|
+
) {
|
|
58
|
+
if (viewW === 0 || viewH === 0) return;
|
|
59
|
+
const forward = camera.getWorldDirection(new THREE.Vector3());
|
|
60
|
+
const depth = world.clone().sub(camera.position).dot(forward);
|
|
61
|
+
if (depth <= 0) return; // behind the camera: panning math breaks down
|
|
62
|
+
const projected = world.clone().project(camera);
|
|
63
|
+
const sx = ((projected.x + 1) / 2) * viewW;
|
|
64
|
+
const sy = ((1 - projected.y) / 2) * viewH;
|
|
65
|
+
const safeL = 48;
|
|
66
|
+
const safeR = Math.max(safeL + 60, viewW - reservedRight - 48);
|
|
67
|
+
const safeT = 120;
|
|
68
|
+
const safeB = viewH - 100;
|
|
69
|
+
const targetX = Math.min(Math.max(sx, safeL), safeR);
|
|
70
|
+
const targetY = Math.min(Math.max(sy, safeT), safeB);
|
|
71
|
+
if (targetX === sx && targetY === sy) return;
|
|
72
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
73
|
+
const tanH = tanV * camera.aspect;
|
|
74
|
+
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
|
|
75
|
+
const up = new THREE.Vector3().crossVectors(right, forward);
|
|
76
|
+
// moving the camera right shifts the point left on screen, and vice versa
|
|
77
|
+
const pan = right
|
|
78
|
+
.multiplyScalar(((sx - targetX) * 2 * depth * tanH) / viewW)
|
|
79
|
+
.addScaledVector(up, ((targetY - sy) * 2 * depth * tanV) / viewH);
|
|
80
|
+
camera.position.add(pan);
|
|
81
|
+
controls.target.add(pan);
|
|
82
|
+
controls.update();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One-line hover readout, driven imperatively from the scenes' pointermove
|
|
86
|
+
// handlers so hovering never re-renders the React tree.
|
|
87
|
+
export class SceneTip {
|
|
88
|
+
private readonly el: HTMLDivElement;
|
|
89
|
+
private readonly pathEl: HTMLSpanElement;
|
|
90
|
+
private readonly metaEl: HTMLSpanElement;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly host: HTMLElement) {
|
|
93
|
+
this.el = document.createElement("div");
|
|
94
|
+
this.el.className = "scene-tip";
|
|
95
|
+
this.pathEl = document.createElement("span");
|
|
96
|
+
this.metaEl = document.createElement("span");
|
|
97
|
+
this.metaEl.className = "dim";
|
|
98
|
+
this.el.append(this.pathEl, this.metaEl);
|
|
99
|
+
host.appendChild(this.el);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
show(path: string, meta: string, clientX: number, clientY: number) {
|
|
103
|
+
this.pathEl.textContent = path;
|
|
104
|
+
this.metaEl.textContent = ` · ${meta}`;
|
|
105
|
+
this.el.style.display = "block";
|
|
106
|
+
const bounds = this.host.getBoundingClientRect();
|
|
107
|
+
const x = clientX - bounds.left;
|
|
108
|
+
const y = clientY - bounds.top;
|
|
109
|
+
const left = Math.min(x + 14, Math.max(0, bounds.width - this.el.offsetWidth - 8));
|
|
110
|
+
const top = Math.min(y + 16, Math.max(0, bounds.height - this.el.offsetHeight - 8));
|
|
111
|
+
this.el.style.left = `${left}px`;
|
|
112
|
+
this.el.style.top = `${top}px`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
hide() {
|
|
116
|
+
this.el.style.display = "none";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dispose() {
|
|
120
|
+
this.el.remove();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const prefersReducedMotion = () =>
|
|
125
|
+
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
126
|
+
|
|
127
|
+
export function disposeGroup(group: THREE.Group) {
|
|
128
|
+
group.traverse((obj) => {
|
|
129
|
+
if (obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Sprite) {
|
|
130
|
+
obj.geometry?.dispose();
|
|
131
|
+
const mat = obj.material as THREE.Material | THREE.Material[];
|
|
132
|
+
if (Array.isArray(mat)) mat.forEach(disposeMaterial);
|
|
133
|
+
else if (mat) disposeMaterial(mat);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function disposeMaterial(mat: THREE.Material) {
|
|
139
|
+
// Material.dispose() does not free assigned textures; module-cached
|
|
140
|
+
// textures (fireflyTexture/haloTexture) are marked shared and must survive.
|
|
141
|
+
const map = (mat as THREE.Material & { map?: THREE.Texture | null }).map;
|
|
142
|
+
if (map && !map.userData.shared) map.dispose();
|
|
143
|
+
mat.dispose();
|
|
144
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
let fireflyMap: THREE.Texture | null = null;
|
|
4
|
+
export function fireflyTexture(): THREE.Texture {
|
|
5
|
+
if (fireflyMap) return fireflyMap;
|
|
6
|
+
const size = 64;
|
|
7
|
+
const canvas = document.createElement("canvas");
|
|
8
|
+
canvas.width = size;
|
|
9
|
+
canvas.height = size;
|
|
10
|
+
const ctx = canvas.getContext("2d")!;
|
|
11
|
+
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
12
|
+
g.addColorStop(0, "rgba(255,255,255,1)");
|
|
13
|
+
g.addColorStop(0.25, "rgba(255,210,160,0.55)");
|
|
14
|
+
g.addColorStop(1, "rgba(255,158,94,0)");
|
|
15
|
+
ctx.fillStyle = g;
|
|
16
|
+
ctx.fillRect(0, 0, size, size);
|
|
17
|
+
fireflyMap = new THREE.CanvasTexture(canvas);
|
|
18
|
+
fireflyMap.userData.shared = true; // module cache: disposeGroup must not free it
|
|
19
|
+
return fireflyMap;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let haloMap: THREE.Texture | null = null;
|
|
23
|
+
export function haloTexture(): THREE.Texture {
|
|
24
|
+
if (haloMap) return haloMap;
|
|
25
|
+
const size = 128;
|
|
26
|
+
const canvas = document.createElement("canvas");
|
|
27
|
+
canvas.width = size;
|
|
28
|
+
canvas.height = size;
|
|
29
|
+
const ctx = canvas.getContext("2d")!;
|
|
30
|
+
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
31
|
+
g.addColorStop(0, "rgba(255,255,255,0.9)");
|
|
32
|
+
g.addColorStop(0.4, "rgba(255,255,255,0.28)");
|
|
33
|
+
g.addColorStop(1, "rgba(255,255,255,0)");
|
|
34
|
+
ctx.fillStyle = g;
|
|
35
|
+
ctx.fillRect(0, 0, size, size);
|
|
36
|
+
haloMap = new THREE.CanvasTexture(canvas);
|
|
37
|
+
haloMap.userData.shared = true; // module cache: disposeGroup must not free it
|
|
38
|
+
return haloMap;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function labelTexture(text: string): { texture: THREE.Texture; aspect: number } {
|
|
42
|
+
const font = '500 30px "Schibsted Grotesk Variable", "PingFang SC", sans-serif';
|
|
43
|
+
const measure = document.createElement("canvas").getContext("2d")!;
|
|
44
|
+
measure.font = font;
|
|
45
|
+
const width = Math.ceil(measure.measureText(text).width) + 24;
|
|
46
|
+
const height = 44;
|
|
47
|
+
const canvas = document.createElement("canvas");
|
|
48
|
+
canvas.width = width * 2;
|
|
49
|
+
canvas.height = height * 2;
|
|
50
|
+
const ctx = canvas.getContext("2d")!;
|
|
51
|
+
ctx.scale(2, 2);
|
|
52
|
+
ctx.font = font;
|
|
53
|
+
ctx.textBaseline = "middle";
|
|
54
|
+
ctx.textAlign = "center";
|
|
55
|
+
ctx.fillStyle = "rgba(197, 205, 222, 0.95)";
|
|
56
|
+
ctx.fillText(text, width / 2, height / 2 + 1);
|
|
57
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
58
|
+
texture.anisotropy = 4;
|
|
59
|
+
return { texture, aspect: width / height };
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { EMBER } from "./sceneUtils";
|
|
3
|
+
|
|
4
|
+
const SAMPLES = 14;
|
|
5
|
+
const MAX_ARCS = 11; // playback keeps 12 recent targets → at most 11 arcs
|
|
6
|
+
|
|
7
|
+
// Fixed-capacity trail: the arc soup changes every playback tick, so the
|
|
8
|
+
// geometry and material are allocated once and only the attribute contents
|
|
9
|
+
// (plus the draw range) are rewritten in place.
|
|
10
|
+
export class TrailRenderer {
|
|
11
|
+
readonly object: THREE.LineSegments;
|
|
12
|
+
private readonly positions: THREE.BufferAttribute;
|
|
13
|
+
private readonly colors: THREE.BufferAttribute;
|
|
14
|
+
private readonly lift: number;
|
|
15
|
+
private readonly arcPoints = Array.from({ length: SAMPLES + 1 }, () => new THREE.Vector3());
|
|
16
|
+
private readonly mid = new THREE.Vector3();
|
|
17
|
+
private readonly curve = new THREE.QuadraticBezierCurve3();
|
|
18
|
+
private readonly color = new THREE.Color();
|
|
19
|
+
|
|
20
|
+
constructor(lift: number) {
|
|
21
|
+
this.lift = lift;
|
|
22
|
+
const vertexCount = MAX_ARCS * SAMPLES * 2;
|
|
23
|
+
const geometry = new THREE.BufferGeometry();
|
|
24
|
+
this.positions = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
|
25
|
+
this.positions.setUsage(THREE.DynamicDrawUsage);
|
|
26
|
+
this.colors = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
|
27
|
+
this.colors.setUsage(THREE.DynamicDrawUsage);
|
|
28
|
+
geometry.setAttribute("position", this.positions);
|
|
29
|
+
geometry.setAttribute("color", this.colors);
|
|
30
|
+
geometry.setDrawRange(0, 0);
|
|
31
|
+
const material = new THREE.LineBasicMaterial({
|
|
32
|
+
vertexColors: true,
|
|
33
|
+
transparent: true,
|
|
34
|
+
blending: THREE.AdditiveBlending,
|
|
35
|
+
depthWrite: false,
|
|
36
|
+
fog: false
|
|
37
|
+
});
|
|
38
|
+
this.object = new THREE.LineSegments(geometry, material);
|
|
39
|
+
this.object.frustumCulled = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// points are the recent fixations, oldest first, y already at arc height
|
|
43
|
+
update(points: THREE.Vector3[]) {
|
|
44
|
+
const arcs = Math.min(Math.max(points.length - 1, 0), MAX_ARCS);
|
|
45
|
+
if (arcs === 0) {
|
|
46
|
+
this.object.geometry.setDrawRange(0, 0);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const start = points.length - 1 - arcs;
|
|
50
|
+
let v = 0;
|
|
51
|
+
for (let i = 1; i <= arcs; i++) {
|
|
52
|
+
const a = points[start + i - 1];
|
|
53
|
+
const b = points[start + i];
|
|
54
|
+
this.mid.copy(a).lerp(b, 0.5);
|
|
55
|
+
this.mid.y = Math.max(a.y, b.y) + a.distanceTo(b) * 0.22 + this.lift;
|
|
56
|
+
this.curve.v0.copy(a);
|
|
57
|
+
this.curve.v1.copy(this.mid);
|
|
58
|
+
this.curve.v2.copy(b);
|
|
59
|
+
for (let s = 0; s <= SAMPLES; s++) {
|
|
60
|
+
this.curve.getPoint(s / SAMPLES, this.arcPoints[s]);
|
|
61
|
+
}
|
|
62
|
+
const recency = i / arcs;
|
|
63
|
+
this.color.copy(EMBER).multiplyScalar(0.05 + 0.95 * recency * recency);
|
|
64
|
+
for (let s = 0; s < SAMPLES; s++) {
|
|
65
|
+
const pa = this.arcPoints[s];
|
|
66
|
+
const pb = this.arcPoints[s + 1];
|
|
67
|
+
this.positions.setXYZ(v, pa.x, pa.y, pa.z);
|
|
68
|
+
this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b);
|
|
69
|
+
v++;
|
|
70
|
+
this.positions.setXYZ(v, pb.x, pb.y, pb.z);
|
|
71
|
+
this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b);
|
|
72
|
+
v++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.object.geometry.setDrawRange(0, v);
|
|
76
|
+
this.positions.needsUpdate = true;
|
|
77
|
+
this.colors.needsUpdate = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { CityFile } from "../types";
|
|
2
|
+
|
|
3
|
+
// Deterministic radial tree: root at the origin, directories as branch
|
|
4
|
+
// forks, files as leaf slots. Every leaf gets an equal angular share in
|
|
5
|
+
// path-sorted order, so the same commit always grows the same tree.
|
|
6
|
+
|
|
7
|
+
export interface TreeDir {
|
|
8
|
+
path: string;
|
|
9
|
+
name: string;
|
|
10
|
+
x: number;
|
|
11
|
+
z: number;
|
|
12
|
+
depth: number;
|
|
13
|
+
fileCount: number;
|
|
14
|
+
// how far the subtree's leaves reach from the fork, in layout units —
|
|
15
|
+
// the scene projects this to screen pixels to decide when the directory
|
|
16
|
+
// is prominent enough to deserve its label
|
|
17
|
+
radius: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TreeEdge {
|
|
21
|
+
childPath?: string;
|
|
22
|
+
childFileId?: number;
|
|
23
|
+
points: { x: number; z: number }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TreeLayout {
|
|
27
|
+
radius: number;
|
|
28
|
+
leaf: Map<number, { x: number; z: number }>;
|
|
29
|
+
dirs: TreeDir[];
|
|
30
|
+
edges: TreeEdge[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Node {
|
|
34
|
+
name: string;
|
|
35
|
+
path: string;
|
|
36
|
+
depth: number;
|
|
37
|
+
children: Map<string, Node>;
|
|
38
|
+
files: CityFile[];
|
|
39
|
+
leafCount: number;
|
|
40
|
+
angle: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function computeTreeLayout(files: CityFile[]): TreeLayout {
|
|
44
|
+
const root: Node = { name: "", path: "", depth: 0, children: new Map(), files: [], leafCount: 0, angle: 0 };
|
|
45
|
+
const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
46
|
+
for (const file of sorted) {
|
|
47
|
+
const parts = file.path.split("/").filter(Boolean);
|
|
48
|
+
let node = root;
|
|
49
|
+
for (const part of parts.slice(0, -1)) {
|
|
50
|
+
let next = node.children.get(part);
|
|
51
|
+
if (!next) {
|
|
52
|
+
next = {
|
|
53
|
+
name: part,
|
|
54
|
+
path: node.path ? `${node.path}/${part}` : part,
|
|
55
|
+
depth: node.depth + 1,
|
|
56
|
+
children: new Map(),
|
|
57
|
+
files: [],
|
|
58
|
+
leafCount: 0,
|
|
59
|
+
angle: 0
|
|
60
|
+
};
|
|
61
|
+
node.children.set(part, next);
|
|
62
|
+
}
|
|
63
|
+
node = next;
|
|
64
|
+
}
|
|
65
|
+
node.files.push(file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let maxDepth = 1;
|
|
69
|
+
const countLeaves = (node: Node): number => {
|
|
70
|
+
node.leafCount = node.files.length;
|
|
71
|
+
if (node.files.length > 0) maxDepth = Math.max(maxDepth, node.depth + 1);
|
|
72
|
+
for (const child of node.children.values()) {
|
|
73
|
+
node.leafCount += countLeaves(child);
|
|
74
|
+
}
|
|
75
|
+
return node.leafCount;
|
|
76
|
+
};
|
|
77
|
+
countLeaves(root);
|
|
78
|
+
|
|
79
|
+
const total = Math.max(root.leafCount, 1);
|
|
80
|
+
const radius = Math.max(55, Math.sqrt(total) * 4);
|
|
81
|
+
const step = radius / Math.max(maxDepth, 1);
|
|
82
|
+
const slotAngle = (Math.PI * 2) / total;
|
|
83
|
+
|
|
84
|
+
const layout: TreeLayout = { radius, leaf: new Map(), dirs: [], edges: [] };
|
|
85
|
+
const polar = (r: number, angle: number) => ({ x: r * Math.cos(angle), z: r * Math.sin(angle) });
|
|
86
|
+
|
|
87
|
+
const sampleEdge = (r1: number, a1: number, r2: number, a2: number): { x: number; z: number }[] => {
|
|
88
|
+
// straight spokes out of the root, eased sweeps between rings
|
|
89
|
+
const from = r1 < 1e-6 ? a2 : a1;
|
|
90
|
+
let delta = a2 - from;
|
|
91
|
+
while (delta > Math.PI) delta -= Math.PI * 2;
|
|
92
|
+
while (delta < -Math.PI) delta += Math.PI * 2;
|
|
93
|
+
const points: { x: number; z: number }[] = [];
|
|
94
|
+
const SEGMENTS = 8;
|
|
95
|
+
for (let s = 0; s <= SEGMENTS; s++) {
|
|
96
|
+
const t = s / SEGMENTS;
|
|
97
|
+
const ease = t * t * (3 - 2 * t);
|
|
98
|
+
points.push(polar(r1 + (r2 - r1) * t, from + delta * ease));
|
|
99
|
+
}
|
|
100
|
+
return points;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
const place = (node: Node) => {
|
|
105
|
+
node.angle = (cursor + node.leafCount / 2) * slotAngle;
|
|
106
|
+
const dirs = [...node.children.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
107
|
+
const files = [...node.files].sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
108
|
+
for (const child of dirs) {
|
|
109
|
+
place(child);
|
|
110
|
+
}
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const angle = (cursor + 0.5) * slotAngle;
|
|
113
|
+
cursor += 1;
|
|
114
|
+
const pos = polar((node.depth + 1) * step, angle);
|
|
115
|
+
layout.leaf.set(file.id, pos);
|
|
116
|
+
layout.edges.push({
|
|
117
|
+
childFileId: file.id,
|
|
118
|
+
points: sampleEdge(node.depth * step, node.angle, (node.depth + 1) * step, angle)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (node.path !== "") {
|
|
122
|
+
const pos = polar(node.depth * step, node.angle);
|
|
123
|
+
layout.dirs.push({
|
|
124
|
+
path: node.path,
|
|
125
|
+
name: node.name,
|
|
126
|
+
x: pos.x,
|
|
127
|
+
z: pos.z,
|
|
128
|
+
depth: node.depth,
|
|
129
|
+
fileCount: node.leafCount,
|
|
130
|
+
radius: 0
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
place(root);
|
|
135
|
+
|
|
136
|
+
// parent→dir edges need final angles, so wire them after placement
|
|
137
|
+
const nodeByPath = new Map<string, Node>();
|
|
138
|
+
const collect = (node: Node) => {
|
|
139
|
+
nodeByPath.set(node.path, node);
|
|
140
|
+
for (const child of node.children.values()) collect(child);
|
|
141
|
+
};
|
|
142
|
+
collect(root);
|
|
143
|
+
for (const dir of layout.dirs) {
|
|
144
|
+
const node = nodeByPath.get(dir.path)!;
|
|
145
|
+
const parentPath = dir.path.includes("/") ? dir.path.slice(0, dir.path.lastIndexOf("/")) : "";
|
|
146
|
+
const parent = nodeByPath.get(parentPath)!;
|
|
147
|
+
layout.edges.push({
|
|
148
|
+
childPath: dir.path,
|
|
149
|
+
points: sampleEdge(parent.depth * step, parent.angle, node.depth * step, node.angle)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// subtree reach: every leaf stretches the radius of each ancestor dir
|
|
154
|
+
const dirByPath = new Map(layout.dirs.map((dir) => [dir.path, dir]));
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const pos = layout.leaf.get(file.id);
|
|
157
|
+
if (!pos) continue;
|
|
158
|
+
const parts = file.path.split("/").filter(Boolean);
|
|
159
|
+
let path = "";
|
|
160
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
161
|
+
path = path ? `${path}/${parts[i]}` : parts[i];
|
|
162
|
+
const dir = dirByPath.get(path);
|
|
163
|
+
if (!dir) continue;
|
|
164
|
+
dir.radius = Math.max(dir.radius, Math.hypot(pos.x - dir.x, pos.z - dir.z));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return layout;
|
|
169
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SessionMeta } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface SessionFilters {
|
|
4
|
+
hideEmpty: boolean;
|
|
5
|
+
harness?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "mindwalk.sessionFilters";
|
|
9
|
+
|
|
10
|
+
// activeKey keeps an explicitly opened session visible even when it has no
|
|
11
|
+
// calls; the harness filter is deliberate user intent, so it still applies
|
|
12
|
+
export function sessionVisible(session: SessionMeta, filters: SessionFilters, activeKey?: string): boolean {
|
|
13
|
+
if (filters.harness && session.harness !== filters.harness) return false;
|
|
14
|
+
if (filters.hideEmpty && session.eventCount === 0 && session.key !== activeKey) return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadFilters(): SessionFilters {
|
|
19
|
+
try {
|
|
20
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
21
|
+
if (raw) {
|
|
22
|
+
const parsed = JSON.parse(raw) as Partial<SessionFilters>;
|
|
23
|
+
return {
|
|
24
|
+
hideEmpty: parsed.hideEmpty !== false,
|
|
25
|
+
harness: typeof parsed.harness === "string" ? parsed.harness : undefined
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// corrupted storage: fall through to defaults
|
|
30
|
+
}
|
|
31
|
+
return { hideEmpty: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveFilters(filters: SessionFilters): void {
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
|
37
|
+
} catch {
|
|
38
|
+
// storage unavailable: filters reset on next load
|
|
39
|
+
}
|
|
40
|
+
}
|