diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md index c9a675ef41c9..ec17b46c796f 100644 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -9,3 +9,14 @@ Copyright (c) 2016 Roberto Huertas Licensed under the MIT License. The full license text is available in the upstream repository: . + +## mindwalk + +The 3D scene, playback, and trace/citymap type code under `src/mindwalk/` is +ported from the [`mindwalk`](https://github.com/cosmtrek/mindwalk) project's +`web/src` frontend (vendored in this repo at `.repos/mindwalk`). + +Copyright (c) 2026 Ricko Yu + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/package.json b/apps/web/package.json index 53b38b80ba0b..af4ffe5cf67d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", + "three": "^0.182.0", "zustand": "^5.0.11" }, "devDependencies": { @@ -56,6 +57,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", + "@types/three": "^0.182.0", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", diff --git a/apps/web/src/mindwalk/MindwalkHarness.tsx b/apps/web/src/mindwalk/MindwalkHarness.tsx new file mode 100644 index 000000000000..619dcda2d089 --- /dev/null +++ b/apps/web/src/mindwalk/MindwalkHarness.tsx @@ -0,0 +1,98 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { useEffect, useMemo, useState } from "react"; + +import { PrimaryEnvironmentHttpClient } from "../environments/primary/httpClient"; +import { runPrimaryHttp } from "../lib/runtime"; +import { PlaybackEngine } from "./playback/reducer"; +import { CityScene } from "./scene/CityScene"; +import { TreeScene } from "./scene/TreeScene"; +import type { CityMap } from "./types"; +import "./harness.css"; + +type View = "tree" | "terrain"; + +/** + * Throwaway harness proving the verbatim-ported mindwalk scenes render a T3 + * citymap. Static-map baseline only: no trace, so playback stays empty and + * CityScene runs in `locHeights` mode — the equivalent of mindwalk's `?map=1`. + * The real surface (playback deck, HUD, restyle) is later map work. + */ +export default function MindwalkHarness({ threadId }: { threadId: string }) { + const [city, setCity] = useState(); + const [error, setError] = useState(); + const [view, setView] = useState("terrain"); + const [selectedPath, setSelectedPath] = useState(); + + useEffect(() => { + // Clear first: losing the threadId must drop the previous citymap rather + // than leave it on screen behind the "pass a threadId" prompt. + setCity(undefined); + setError(undefined); + if (!threadId) return; + let cancelled = false; + runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.citymap.threadCitymap({ + params: { threadId: threadId as ThreadId }, + headers: {}, + }), + ), + ), + ).then( + // The wire shape is byte-identical to mindwalk's CityMap by design (see + // packages/contracts/src/citymap.ts); the cast only drops readonly. + (map) => { + if (!cancelled) setCity(map as unknown as CityMap); + }, + (cause: unknown) => { + if (!cancelled) setError(String(cause)); + }, + ); + return () => { + cancelled = true; + }; + }, [threadId]); + + const playback = useMemo(() => new PlaybackEngine(undefined, city).snapshotAt(-1), [city]); + const selection = selectedPath === undefined ? {} : { selectedPath }; + + return ( +
+ {city ? ( + view === "tree" ? ( + + ) : ( + + ) + ) : ( +
+ {error + ? `Citymap failed: ${error}` + : threadId + ? "Building the map…" + : "Pass ?threadId= to load a citymap."} +
+ )} +
+ {(["terrain", "tree"] as const).map((candidate) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/mindwalk/README.md b/apps/web/src/mindwalk/README.md new file mode 100644 index 000000000000..8d797c8935c3 --- /dev/null +++ b/apps/web/src/mindwalk/README.md @@ -0,0 +1,13 @@ +# mindwalk port + +`scene/`, `playback/`, and `types.ts` are copied near-verbatim from mindwalk's +`web/src` (MIT, © 2026 Ricko Yu — see `../../THIRD_PARTY_NOTICES.md`), vendored +at `.repos/mindwalk` at the revision this copy was taken from. + +Verbatim on purpose: the squarified-treemap math, instanced-mesh buffer +indexing, and colour handling in this code render _almost_ right when +mistranslated, and that class of bug costs days to bisect. Deviations are +limited to what this repo's stricter tsconfig forces (non-null assertions and +casts, all erased at runtime) plus the pre-commit `vp fmt` autoformat, and each +must preserve behavior. Diff against `.repos/mindwalk/web/src` before editing +anything here. diff --git a/apps/web/src/mindwalk/harness.css b/apps/web/src/mindwalk/harness.css new file mode 100644 index 000000000000..d292fbb6c844 --- /dev/null +++ b/apps/web/src/mindwalk/harness.css @@ -0,0 +1,43 @@ +/* Minimal load-bearing styling for the ported scenes, lifted from mindwalk's + * styles.css: the scenes absolutely fill their host and the hover tip is a + * floating chip. Values are mindwalk's; restyling to T3 tokens is later work. */ +.mindwalk-harness { + --hairline-strong: oklch(0.46 0.016 252); + --ink: oklch(0.91 0.014 88); + --muted: oklch(0.72 0.02 252); + --text-xs: 0.75rem; + position: relative; + height: 100%; + overflow: hidden; +} + +.mindwalk-harness .city-scene { + position: absolute; + inset: 0; +} +.mindwalk-harness .city-scene canvas { + display: block; +} + +.mindwalk-harness .scene-tip { + position: absolute; + z-index: 30; + display: none; + max-width: 380px; + padding: 5px 9px; + border: 1px solid var(--hairline-strong); + border-radius: 4px; + background: oklch(0.245 0.014 256 / 0.97); + color: var(--ink); + font-size: var(--text-xs); + letter-spacing: 0.01em; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + font-variant-numeric: tabular-nums; +} +.mindwalk-harness .scene-tip .dim { + color: var(--muted); +} diff --git a/apps/web/src/mindwalk/playback/recorder.ts b/apps/web/src/mindwalk/playback/recorder.ts new file mode 100644 index 000000000000..d04c6d6032e2 --- /dev/null +++ b/apps/web/src/mindwalk/playback/recorder.ts @@ -0,0 +1,166 @@ +// Client-side video export: capture the live WebGL canvas with MediaRecorder +// while driving the playhead from the first event to the last, then hand back a +// webm Blob. No server, no extra dependency — captureStream + MediaRecorder are +// native browser APIs. + +export interface RecordOptions { + canvas: HTMLCanvasElement; + total: number; // number of playback events (trace.events.length) + setSeq: (seq: number) => void; // drives the store's currentSeq + fps?: number; // capture frame rate + durationMs?: number; // wall-clock length of the exported run + onProgress?: (fraction: number) => void; // 0..1 + signal?: AbortSignal; // cancel an in-flight recording +} + +export interface RecordResult { + blob: Blob; + extension: string; // matches the recorded container (webm / mp4 / …) +} + +// Map a MediaRecorder mimeType to a sensible file extension. Falls back to the +// container subtype when it's an unknown-but-plausible type. +function extensionForMime(mimeType: string): string { + const base = mimeType.split(";")[0]!.trim().toLowerCase(); // strip codecs + const subtype = base.split("/")[1] ?? ""; + if (subtype.includes("webm")) return "webm"; + if (subtype.includes("mp4")) return "mp4"; + if (subtype.includes("x-matroska") || subtype.includes("matroska")) return "mkv"; + if (subtype.includes("ogg")) return "ogv"; + return subtype || "webm"; +} + +const DEFAULT_FPS = 30; +// Long sessions would otherwise produce minutes of video; cap the run and let +// the playhead cover more events per frame. Short sessions play close to +// real-ish time via the per-event floor. +const MAX_DURATION_MS = 30_000; +const PER_EVENT_MS = 120; + +function pickMimeType(): string | undefined { + const candidates = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]; + for (const type of candidates) { + if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(type)) return type; + } + return undefined; +} + +export function recordingSupported(): boolean { + return ( + typeof MediaRecorder !== "undefined" && + typeof HTMLCanvasElement !== "undefined" && + typeof HTMLCanvasElement.prototype.captureStream === "function" + ); +} + +// Records the playback and resolves with the recorded blob and a file extension +// that matches the actual container. Rejects if the browser can't record or the +// run is aborted. +export function recordPlayback(opts: RecordOptions): Promise { + const { canvas, total, setSeq, onProgress, signal } = opts; + const fps = opts.fps ?? DEFAULT_FPS; + + if (!recordingSupported()) { + return Promise.reject( + new Error("This browser can't record the canvas (MediaRecorder unavailable)."), + ); + } + if (total <= 0) { + return Promise.reject(new Error("Nothing to record — load a session first.")); + } + + const durationMs = + opts.durationMs ?? Math.min(MAX_DURATION_MS, Math.max(1000, total * PER_EVENT_MS)); + const mimeType = pickMimeType(); + const stream = canvas.captureStream(fps); + const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); + const chunks: BlobPart[] = []; + + return new Promise((resolve, reject) => { + let rafId = 0; + let startTs = 0; + let settled = false; + + const cleanup = () => { + if (rafId) cancelAnimationFrame(rafId); + stream.getTracks().forEach((track) => track.stop()); + signal?.removeEventListener("abort", onAbort); + }; + + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + // the browser may have chosen a container different from what we + // requested; trust recorder.mimeType so the blob type and file extension + // match what was actually recorded + const actualType = recorder.mimeType || mimeType || "video/webm"; + resolve({ + blob: new Blob(chunks, { type: actualType }), + extension: extensionForMime(actualType), + }); + }; + + const fail = (err: Error) => { + if (settled) return; + settled = true; + cleanup(); + try { + if (recorder.state !== "inactive") recorder.stop(); + } catch { + // recorder already torn down + } + reject(err); + }; + + const onAbort = () => fail(new Error("Recording cancelled.")); + + recorder.ondataavailable = (e) => { + if (e.data && e.data.size > 0) chunks.push(e.data); + }; + recorder.onerror = () => fail(new Error("Recording failed.")); + recorder.onstop = finish; + + if (signal) { + if (signal.aborted) { + fail(new Error("Recording cancelled.")); + return; + } + signal.addEventListener("abort", onAbort); + } + + const tick = (ts: number) => { + if (settled) return; + if (!startTs) startTs = ts; + const fraction = Math.min(1, (ts - startTs) / durationMs); + const seq = Math.min(total - 1, Math.floor(fraction * (total - 1))); + setSeq(seq); + onProgress?.(fraction); + if (fraction >= 1) { + // let the final frame paint before closing the recorder + rafId = requestAnimationFrame(() => { + if (recorder.state !== "inactive") recorder.stop(); + }); + return; + } + rafId = requestAnimationFrame(tick); + }; + + setSeq(0); + recorder.start(); + rafId = requestAnimationFrame(tick); + }); +} + +// Triggers a browser download of the recorded Blob. +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // revoke on the next tick so the download has a chance to start + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/apps/web/src/mindwalk/playback/reducer.ts b/apps/web/src/mindwalk/playback/reducer.ts new file mode 100644 index 000000000000..1b1b78604d25 --- /dev/null +++ b/apps/web/src/mindwalk/playback/reducer.ts @@ -0,0 +1,94 @@ +import type { CityMap, Target, Touch, Trace, TraceEvent } from "../types"; + +export const touchRank: Record = { + hit: 1, + read: 2, + edit: 3, +}; + +export interface FilePlayback { + touchByFile: Map; + touchByPath: Map; + visitsByFile: Map; + historyByPath: Map; + recentTargets: Target[]; +} + +const RECENT_WINDOW = 12; + +// Incremental playback state. Stepping the playhead forward applies only the +// events it crossed; scrubbing backward replays from the start once. Snapshots +// share the engine's live collections — consumers must treat them as read-only +// and re-read whenever the snapshot identity changes (one fresh wrapper per +// snapshotAt call). +export class PlaybackEngine { + private readonly events: TraceEvent[]; + private readonly idByPath = new Map(); + private cursor = -1; + private readonly touchByFile = new Map(); + private readonly touchByPath = new Map(); + private readonly visitsByFile = new Map(); + private readonly historyByPath = new Map(); + private readonly recentTargets: Target[] = []; + + constructor(trace: Trace | undefined, city: CityMap | undefined) { + this.events = trace?.events ?? []; + for (const file of city?.files ?? []) { + this.idByPath.set(file.path, file.id); + } + } + + snapshotAt(seq: number): FilePlayback { + const lastSeq = Math.min(seq, this.events.length - 1); + if (lastSeq < this.cursor) this.reset(); + for (let i = this.cursor + 1; i <= lastSeq; i++) { + this.apply(this.events[i]!); + } + this.cursor = Math.max(this.cursor, lastSeq); + return { + touchByFile: this.touchByFile, + touchByPath: this.touchByPath, + visitsByFile: this.visitsByFile, + historyByPath: this.historyByPath, + recentTargets: this.recentTargets, + }; + } + + private reset() { + this.cursor = -1; + this.touchByFile.clear(); + this.touchByPath.clear(); + this.visitsByFile.clear(); + this.historyByPath.clear(); + this.recentTargets.length = 0; + } + + private apply(event: TraceEvent) { + for (const target of event.targets) { + const prev = this.touchByPath.get(target.path); + if (!prev || touchRank[target.touch] > touchRank[prev]) { + this.touchByPath.set(target.path, target.touch); + } + const fileId = target.fileId ?? this.idByPath.get(target.path); + if (fileId !== undefined) { + const prevFile = this.touchByFile.get(fileId); + if (!prevFile || touchRank[target.touch] > touchRank[prevFile]) { + this.touchByFile.set(fileId, target.touch); + } + this.visitsByFile.set(fileId, (this.visitsByFile.get(fileId) ?? 0) + 1); + } + const history = this.historyByPath.get(target.path); + if (history) history.push(event); + else this.historyByPath.set(target.path, [event]); + } + if (event.targets.length > 0) { + // the trail follows the strongest signal: skip weak (heuristic) targets + const primary = event.targets.find((target) => !target.weak) ?? event.targets[0]!; + this.recentTargets.push({ + ...primary, + fileId: primary.fileId ?? this.idByPath.get(primary.path), + } as Target); + if (this.recentTargets.length > RECENT_WINDOW) this.recentTargets.shift(); + } + } +} diff --git a/apps/web/src/mindwalk/scene/CityScene.tsx b/apps/web/src/mindwalk/scene/CityScene.tsx new file mode 100644 index 000000000000..33f31dc89108 --- /dev/null +++ b/apps/web/src/mindwalk/scene/CityScene.tsx @@ -0,0 +1,679 @@ +import { useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { touchWord, type CityFile, type CityMap, type Touch } from "../types"; +import type { FilePlayback } from "../playback/reducer"; +import { DirLabelSet } from "./dirLabels"; +import { + disposeGroup, + EMBER, + ensureVisible, + fitDistance, + prefersReducedMotion, + SceneTip, + SKY, + touchColors, +} from "./sceneUtils"; +import { fireflyTexture } from "./textures"; +import { TrailRenderer } from "./trail"; + +interface CitySceneProps { + city?: CityMap; + playback: FilePlayback; + selectedPath?: string; + onSelect: (path?: string) => void; + onCanvasReady?: (canvas: HTMLCanvasElement | null) => void; + // static map mode: with no session to drive attention height, raise terrain + // columns by file size (lines of code) instead of leaving the plain flat + locHeights?: boolean; +} + +// Attention terrain: the map is a flat dark plain (fog of war); height is +// earned by attention — touch depth × revisits — so mountains grow where the +// walker lingered. Light is data: only touched terrain gets bright color. +const colors: Record = { + unvisited: new THREE.Color("#5b6372"), + ghost: new THREE.Color("#404551"), + ...touchColors, +}; + +const TILE_H = 0.14; +const LABEL_Y = 2.4; +// the inspector docks on the right; selection pans the camera clear of it +const INSPECTOR_RESERVED_PX = 348; + +function attentionHeight(touch: Touch, visits: number): number { + const base = touch === "edit" ? 7.2 : touch === "read" ? 4.2 : 1.6; + return base * (1 + 0.35 * Math.log2(Math.max(visits, 1))); +} + +// static map height: normalized lines of code, log-scaled so a few huge files +// don't flatten everything else. maxLog is log2(largest file's lines). +const LOC_MIN_H = 0.3; +const LOC_MAX_H = 16; +// gamma > 1 exaggerates the top end: small files stay low, big files spike, so +// the skyline reads as a city (towers vs shacks) instead of a uniform plateau +const LOC_HEIGHT_GAMMA = 2.2; +// normalized 0..1 position of a file by lines of code (log-scaled) +function locFraction(lines: number, maxLog: number): number { + if (maxLog <= 0) return 0; + return Math.min(1, Math.log2(Math.max(lines, 1)) / maxLog); +} +function locHeight(t: number): number { + return LOC_MIN_H + Math.pow(t, LOC_HEIGHT_GAMMA) * (LOC_MAX_H - LOC_MIN_H); +} + +// LOC tier ramp: small files stay grey, then warm up through orange and purple +// to red for the largest files. Stops are interpolated so the terrain reads as +// a continuous gradient rather than hard bands. +const LOC_RAMP: { at: number; color: THREE.Color }[] = [ + { at: 0.0, color: new THREE.Color("#5b6372") }, // grey (matches unvisited) + { at: 0.35, color: new THREE.Color("#e0894f") }, // orange + { at: 0.7, color: new THREE.Color("#9a6bd8") }, // purple + { at: 1.0, color: new THREE.Color("#e0524f") }, // red +]; +function locColor(t: number): THREE.Color { + for (let i = 1; i < LOC_RAMP.length; i++) { + if (t <= LOC_RAMP[i]!.at) { + const lo = LOC_RAMP[i - 1]!; + const hi = LOC_RAMP[i]!; + const span = hi.at - lo.at; + const k = span > 0 ? (t - lo.at) / span : 0; + return lo.color.clone().lerp(hi.color, k); + } + } + return LOC_RAMP[LOC_RAMP.length - 1]!.color.clone(); +} + +interface TerrainSlot { + fileId: number; + target: number; + color: THREE.Color; +} + +export function CityScene({ + city, + playback, + selectedPath, + onSelect, + onCanvasReady, + locHeights, +}: CitySceneProps) { + const hostRef = useRef(null); + const tileMeshRef = useRef(null); + const terrainMeshRef = useRef(null); + const filesRef = useRef([]); + const slotsRef = useRef([]); + const heightsRef = useRef>(new Map()); + const rendererRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const sceneRef = useRef(null); + const cityGroupRef = useRef(null); + const trailRef = useRef(null); + const fireflyRef = useRef(null); + const labelSetRef = useRef(null); + const frameRef = useRef(null); + const reducedRef = useRef(false); + const boundsRef = useRef({ cx: 0, cz: 0, size: 120 }); + // handlers live in the mount effect; they read playback through this ref + const playbackRef = useRef(playback); + playbackRef.current = playback; + // camera fit deferred while the viewport reports no size (hidden pane, + // background tab); resize retries it instead of leaving the camera at NaN + const fitPendingRef = useRef<(() => boolean) | null>(null); + + const bounds = useMemo(() => { + if (!city || city.files.length === 0) return { cx: 0, cz: 0, size: 120, halfW: 60, halfD: 60 }; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + for (const file of city.files) { + minX = Math.min(minX, file.rect.x); + maxX = Math.max(maxX, file.rect.x + file.rect.w); + minZ = Math.min(minZ, file.rect.z); + maxZ = Math.max(maxZ, file.rect.z + file.rect.d); + } + return { + cx: (minX + maxX) / 2, + cz: (minZ + maxZ) / 2, + size: Math.max(maxX - minX, maxZ - minZ, 60), + halfW: (maxX - minX) / 2, + halfD: (maxZ - minZ) / 2, + }; + }, [city]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const reduced = prefersReducedMotion(); + reducedRef.current = reduced; + + const scene = new THREE.Scene(); + scene.background = SKY; + sceneRef.current = scene; + + const camera = new THREE.PerspectiveCamera( + 38, + host.clientWidth / host.clientHeight || 1, + 0.1, + 2400, + ); + camera.position.set(70, 130, 100); + cameraRef.current = camera; + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(host.clientWidth, host.clientHeight); + rendererRef.current = renderer; + host.appendChild(renderer.domElement); + onCanvasReady?.(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = !reduced; + controls.dampingFactor = 0.08; + controls.maxPolarAngle = Math.PI * 0.44; + // the god view drifts slowly around the terrain until the user takes over + controls.autoRotate = !reduced; + controls.autoRotateSpeed = -0.5; + const tip = new SceneTip(host); + controls.addEventListener("start", () => { + controls.autoRotate = false; + tip.hide(); + }); + controlsRef.current = controls; + + const sky = new THREE.HemisphereLight("#66779b", "#161922", 1.7); + scene.add(sky); + const moon = new THREE.DirectionalLight("#b6c5de", 1.1); + moon.position.set(-60, 120, -40); + scene.add(moon); + + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + const pickFile = (event: PointerEvent): CityFile | undefined => { + if (!cameraRef.current || !rendererRef.current) return undefined; + const rect = rendererRef.current.domElement.getBoundingClientRect(); + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(pointer, cameraRef.current); + const targets = [terrainMeshRef.current, tileMeshRef.current].filter( + Boolean, + ) as THREE.Object3D[]; + const hit = raycaster.intersectObjects(targets, false)[0]; + if (!hit || hit.instanceId === undefined) return undefined; + if (hit.object === terrainMeshRef.current) { + const slot = slotsRef.current[hit.instanceId]; + return slot ? filesRef.current[slot.fileId] : undefined; + } + return filesRef.current[hit.instanceId]; + }; + + let downAt: { x: number; y: number } | null = null; + const onPointerDown = (event: PointerEvent) => { + downAt = { x: event.clientX, y: event.clientY }; + }; + const onPointerUp = (event: PointerEvent) => { + if (!downAt) return; + const moved = Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y); + downAt = null; + if (moved > 5) return; + onSelect(pickFile(event)?.path); + }; + // hover: cursor + one-line readout, throttled to one raycast per frame + let hoverRaf = 0; + const onPointerMove = (event: PointerEvent) => { + if (downAt) { + tip.hide(); + return; + } + if (hoverRaf) return; + hoverRaf = requestAnimationFrame(() => { + hoverRaf = 0; + const file = pickFile(event); + renderer.domElement.style.cursor = file ? "pointer" : ""; + if (!file) { + tip.hide(); + return; + } + const snapshot = playbackRef.current; + const touch = snapshot.touchByPath.get(file.path); + const visits = snapshot.visitsByFile.get(file.id) ?? 0; + const meta = touch + ? `${touchWord(touch)} · ${visits} visit${visits === 1 ? "" : "s"}` + : touchWord(undefined); + tip.show(file.path, file.ghost ? `${meta} · ghost` : meta, event.clientX, event.clientY); + }); + }; + const onPointerLeave = () => { + tip.hide(); + renderer.domElement.style.cursor = ""; + }; + renderer.domElement.addEventListener("pointerdown", onPointerDown); + renderer.domElement.addEventListener("pointerup", onPointerUp); + renderer.domElement.addEventListener("pointermove", onPointerMove); + renderer.domElement.addEventListener("pointerleave", onPointerLeave); + + const resize = () => { + if (!hostRef.current) return; + const w = hostRef.current.clientWidth; + const h = hostRef.current.clientHeight; + if (w === 0 || h === 0) return; + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + if (fitPendingRef.current?.()) fitPendingRef.current = null; + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + + const clock = new THREE.Clock(); + const matrix = new THREE.Matrix4(); + const quaternion = new THREE.Quaternion(); + const render = () => { + controls.update(); + labelSetRef.current?.updateTargets( + camera, + renderer.domElement.clientWidth, + renderer.domElement.clientHeight, + ); + labelSetRef.current?.ease(reducedRef.current); + + // grow / shrink terrain columns toward their attention targets + const terrain = terrainMeshRef.current; + const slots = slotsRef.current; + const heights = heightsRef.current; + if (terrain && slots.length > 0) { + let moving = false; + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]!; + const file = filesRef.current[slot.fileId]; + if (!file) continue; + let cur = heights.get(slot.fileId) ?? 0; + const diff = slot.target - cur; + if (Math.abs(diff) > 0.015) { + cur = reducedRef.current ? slot.target : cur + diff * 0.13; + heights.set(slot.fileId, cur); + moving = true; + } else if (cur !== slot.target) { + heights.set(slot.fileId, slot.target); + cur = slot.target; + moving = true; + } + const sx = Math.max(file.rect.w, 0.45) + 0.04; + const sz = Math.max(file.rect.d, 0.45) + 0.04; + matrix.compose( + new THREE.Vector3( + file.rect.x + file.rect.w / 2 - boundsRef.current.cx, + Math.max(cur, 0.02) / 2 + TILE_H, + file.rect.z + file.rect.d / 2 - boundsRef.current.cz, + ), + quaternion, + new THREE.Vector3(sx, Math.max(cur, 0.02), sz), + ); + terrain.setMatrixAt(i, matrix); + } + if (moving) terrain.instanceMatrix.needsUpdate = true; + } + + const firefly = fireflyRef.current; + if (firefly?.visible) { + const t = clock.getElapsedTime(); + const base = firefly.userData.baseScale as number; + const pulse = reducedRef.current ? 1 : 1 + 0.1 * Math.sin(t * 2.4); + firefly.scale.setScalar(base * pulse); + } + renderer.render(scene, camera); + frameRef.current = requestAnimationFrame(render); + }; + render(); + + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + if (hoverRaf) cancelAnimationFrame(hoverRaf); + renderer.domElement.removeEventListener("pointerdown", onPointerDown); + renderer.domElement.removeEventListener("pointerup", onPointerUp); + renderer.domElement.removeEventListener("pointermove", onPointerMove); + renderer.domElement.removeEventListener("pointerleave", onPointerLeave); + tip.dispose(); + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + host.removeChild(renderer.domElement); + scene.clear(); + onCanvasReady?.(null); + }; + }, [onSelect, onCanvasReady]); + + // build the plain: ground, grid, district plates, flat file tiles + useEffect(() => { + const scene = sceneRef.current; + if (!scene) return; + filesRef.current = city?.files ?? []; + slotsRef.current = []; + heightsRef.current = new Map(); + boundsRef.current = bounds; + if (!city || city.files.length === 0) return; + + const group = new THREE.Group(); + const size = bounds.size; + + scene.fog = new THREE.Fog(SKY, size * 2.1, size * 4.2); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(size * 6, size * 6), + new THREE.MeshStandardMaterial({ color: "#14171e", roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -0.32; + group.add(ground); + + const grid = new THREE.GridHelper(size * 2.8, 46, "#20242e", "#1a1e27"); + (grid.material as THREE.Material).transparent = true; + (grid.material as THREE.Material).opacity = 0.5; + grid.position.y = -0.3; + group.add(grid); + + const plateDirs = city.dirs.filter((dir) => dir.depth <= 3 && dir.rect.w > 0 && dir.rect.d > 0); + if (plateDirs.length > 0) { + const plateGeo = new THREE.BoxGeometry(1, 1, 1); + const plateMat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0 }); + const plates = new THREE.InstancedMesh(plateGeo, plateMat, plateDirs.length); + const matrix = new THREE.Matrix4(); + const shade = new THREE.Color(); + plateDirs.forEach((dir, i) => { + const top = -0.2 + Math.min(dir.depth, 3) * 0.06; + const height = top + 0.3; + matrix.compose( + new THREE.Vector3( + dir.rect.x + dir.rect.w / 2 - bounds.cx, + top - height / 2, + dir.rect.z + dir.rect.d / 2 - bounds.cz, + ), + new THREE.Quaternion(), + new THREE.Vector3(dir.rect.w, height, dir.rect.d), + ); + plates.setMatrixAt(i, matrix); + shade.set("#1a1f29").lerp(new THREE.Color("#252b37"), Math.min(dir.depth, 3) / 3); + plates.setColorAt(i, shade); + }); + plates.instanceMatrix.needsUpdate = true; + if (plates.instanceColor) plates.instanceColor.needsUpdate = true; + group.add(plates); + } + + // flat tiles: every file exists on the map, dark until visited + const tileGeo = new THREE.BoxGeometry(1, 1, 1); + const tileMat = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }); + const tiles = new THREE.InstancedMesh(tileGeo, tileMat, city.files.length); + const matrix = new THREE.Matrix4(); + for (const file of city.files) { + const sx = Math.max(file.rect.w, 0.45); + const sz = Math.max(file.rect.d, 0.45); + const x = file.rect.x + file.rect.w / 2 - bounds.cx; + const z = file.rect.z + file.rect.d / 2 - bounds.cz; + matrix.compose( + new THREE.Vector3(x, TILE_H / 2, z), + new THREE.Quaternion(), + new THREE.Vector3(sx, TILE_H, sz), + ); + tiles.setMatrixAt(file.id, matrix); + tiles.setColorAt(file.id, baseColor(file)); + } + tiles.instanceMatrix.needsUpdate = true; + if (tiles.instanceColor) tiles.instanceColor.needsUpdate = true; + tileMeshRef.current = tiles; + group.add(tiles); + + // attention terrain: unlit columns that grow out of the plain + const terrain = new THREE.InstancedMesh( + attentionColumnGeometry(), + new THREE.MeshBasicMaterial({ toneMapped: false, vertexColors: true }), + city.files.length, + ); + terrain.instanceColor = new THREE.InstancedBufferAttribute( + new Float32Array(city.files.length * 3), + 3, + ); + terrain.count = 0; + terrain.frustumCulled = false; + terrainMeshRef.current = terrain; + group.add(terrain); + + // district labels above the plain; drawn over the columns so a mountain + // range never buries the name of the district behind it + labelSetRef.current = new DirLabelSet( + city.dirs + .filter((dir) => dir.depth >= 1 && dir.fileCount > 0 && dir.rect.w > 0 && dir.rect.d > 0) + .map((dir) => ({ + name: dirBasename(dir.path), + x: dir.rect.x + dir.rect.w / 2 - bounds.cx, + z: dir.rect.z + dir.rect.d / 2 - bounds.cz, + radius: Math.hypot(dir.rect.w, dir.rect.d) / 2, + fileCount: dir.fileCount, + depth: dir.depth, + })), + group, + LABEL_Y, + true, + ); + + const firefly = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: fireflyTexture(), + color: EMBER, + blending: THREE.AdditiveBlending, + depthWrite: false, + transparent: true, + }), + ); + firefly.userData.baseScale = Math.max(size * 0.028, 2.2); + firefly.visible = false; + fireflyRef.current = firefly; + group.add(firefly); + + const trail = new TrailRenderer(1.4); + trailRef.current = trail; + group.add(trail.object); + + cityGroupRef.current = group; + scene.add(group); + + // keep the canonical viewing direction, but pull back exactly far + // enough that the whole plain fits the viewport's frustum — the fixed + // size multiplier ignores the aspect ratio and overflows short windows + const fitView = (): boolean => { + const camera = cameraRef.current; + const controls = controlsRef.current; + if (!camera || !controls) return true; + const dir = new THREE.Vector3(0.46, 1.08, 0.72).normalize(); + const corners: THREE.Vector3[] = []; + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + corners.push(new THREE.Vector3(sx * bounds.halfW, 0, sz * bounds.halfD)); + } + } + const fitted = fitDistance(camera, dir, corners); + if (fitted === null) return false; + // breathing room so map edges clear the HUD overlays; floor keeps + // near-empty maps from parking the camera on the ground + const distance = Math.max(fitted * 1.12, size * 0.6); + camera.position.copy(dir).multiplyScalar(distance); + controls.target.set(0, 0, 0); + controls.minDistance = size * 0.18; + controls.maxDistance = Math.max(size * 2.6, distance * 1.2); + controls.update(); + return true; + }; + fitPendingRef.current = fitView() ? null : fitView; + + return () => { + fitPendingRef.current = null; + disposeGroup(group); + scene.remove(group); + cityGroupRef.current = null; + tileMeshRef.current = null; + terrainMeshRef.current = null; + trailRef.current = null; + fireflyRef.current = null; + labelSetRef.current = null; + }; + }, [city, bounds]); + + // playback → terrain targets and colors + useEffect(() => { + const terrain = terrainMeshRef.current; + const tiles = tileMeshRef.current; + if (!terrain || !tiles || !city) return; + + const heights = heightsRef.current; + const slots: TerrainSlot[] = []; + const present = new Set(); + // static map mode: no session drives attention, so raise every column by + // its lines of code instead of leaving the terrain flat + const maxLog = locHeights + ? Math.log2( + Math.max( + 1, + city.files.reduce((m, f) => Math.max(m, f.lines), 1), + ), + ) + : 0; + for (const file of city.files) { + const touch = playback.touchByFile.get(file.id); + const selected = file.path === selectedPath; + tiles.setColorAt(file.id, selected ? colors.selected : baseColor(file)); + if (touch) { + const visits = playback.visitsByFile.get(file.id) ?? 1; + let color = colors[touch]; + if (file.ghost) color = color.clone().lerp(colors.ghost, 0.45); + if (selected) color = colors.selected; + slots.push({ fileId: file.id, target: attentionHeight(touch, visits), color }); + present.add(file.id); + } else if (locHeights) { + const t = locFraction(file.lines, maxLog); + let color = locColor(t); + if (file.ghost) color = color.lerp(colors.ghost, 0.45); + if (selected) color = colors.selected; + slots.push({ fileId: file.id, target: locHeight(t), color }); + present.add(file.id); + } + } + // let columns that just went dark shrink back into the plain + for (const [fileId, cur] of heights) { + if (cur > 0.04 && !present.has(fileId)) { + slots.push({ fileId, target: 0, color: colors.unvisited }); + } else if (cur <= 0.04 && !present.has(fileId)) { + heights.delete(fileId); + } + } + + slots.forEach((slot, i) => terrain.setColorAt(i, slot.color)); + terrain.count = slots.length; + if (terrain.instanceColor) terrain.instanceColor.needsUpdate = true; + if (tiles.instanceColor) tiles.instanceColor.needsUpdate = true; + slotsRef.current = slots; + }, [city, playback, selectedPath, locHeights]); + + // the inspector opens over the right edge; pan the selected tile clear of it + useEffect(() => { + if (!city || !selectedPath) return; + const file = city.files.find((f) => f.path === selectedPath); + const camera = cameraRef.current; + const controls = controlsRef.current; + const canvas = rendererRef.current?.domElement; + if (!file || !camera || !controls || !canvas) return; + controls.autoRotate = false; + const top = heightsRef.current.get(file.id) ?? TILE_H; + const world = centerFor(file, bounds); + world.y = top; + ensureVisible( + camera, + controls, + world, + canvas.clientWidth, + canvas.clientHeight, + INSPECTOR_RESERVED_PX, + ); + }, [city, bounds, selectedPath]); + + // trail: ballistic arcs between recent fixations + the firefly at the head + useEffect(() => { + const trail = trailRef.current; + if (!trail || !city) return; + + // playback resolves fileId with the same path key the citymap uses, so a + // target still missing one has no tile to land on + const targetFiles = playback.recentTargets + .map((target) => (target.fileId !== undefined ? city.files[target.fileId] : undefined)) + .filter((file): file is CityFile => Boolean(file)); + + const peakFor = (file: CityFile): number => { + const touch = playback.touchByFile.get(file.id); + const visits = playback.visitsByFile.get(file.id) ?? 1; + return touch ? attentionHeight(touch, visits) : TILE_H; + }; + + const firefly = fireflyRef.current; + if (firefly) { + const head = targetFiles[targetFiles.length - 1]; + if (head) { + const p = centerFor(head, bounds); + firefly.position.set(p.x, peakFor(head) + 1.6, p.z); + firefly.visible = true; + } else { + firefly.visible = false; + } + } + + trail.update( + targetFiles.map((file) => { + const p = centerFor(file, bounds); + p.y = peakFor(file) + 0.4; + return p; + }), + ); + }, [city, playback, bounds]); + + return
; +} + +// Columns must read as phosphorescence, not paint: glow pools at the crest and +// falls off into the plain. Vertex shade multiplies the per-instance touch +// color; the top face sits under the side rims so edges catch the most light. +function attentionColumnGeometry(): THREE.BoxGeometry { + const geo = new THREE.BoxGeometry(1, 1, 1); + const pos = geo.getAttribute("position"); + const normal = geo.getAttribute("normal"); + const shade = new Float32Array(pos.count * 3); + for (let i = 0; i < pos.count; i++) { + const t = pos.getY(i) + 0.5; + const glow = normal.getY(i) === 1 ? 0.82 : 0.34 + 0.66 * t * t; + shade.fill(glow, i * 3, i * 3 + 3); + } + geo.setAttribute("color", new THREE.BufferAttribute(shade, 3)); + return geo; +} + +function baseColor(file: CityFile): THREE.Color { + if (file.ghost) return colors.ghost; + let h = 2166136261; + for (let i = 0; i < file.path.length; i++) { + h = Math.imul(h ^ file.path.charCodeAt(i), 16777619); + } + const jitter = ((h >>> 0) % 1000) / 1000 - 0.5; + return colors.unvisited.clone().offsetHSL(0, 0, jitter * 0.05); +} + +function centerFor(file: CityFile, bounds: { cx: number; cz: number }): THREE.Vector3 { + return new THREE.Vector3( + file.rect.x + file.rect.w / 2 - bounds.cx, + 0, + file.rect.z + file.rect.d / 2 - bounds.cz, + ); +} + +function dirBasename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1) || path; +} diff --git a/apps/web/src/mindwalk/scene/TreeScene.tsx b/apps/web/src/mindwalk/scene/TreeScene.tsx new file mode 100644 index 000000000000..78ea37f541ae --- /dev/null +++ b/apps/web/src/mindwalk/scene/TreeScene.tsx @@ -0,0 +1,705 @@ +import { useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { touchWord, type CityFile, type CityMap, type Touch } from "../types"; +import type { FilePlayback } from "../playback/reducer"; +import { DirLabelSet } from "./dirLabels"; +import { + disposeGroup, + ensureVisible, + fitDistance, + prefersReducedMotion, + SceneTip, + SKY, + touchColors, +} from "./sceneUtils"; +import { computeTreeLayout, type TreeLayout } from "./treeLayout"; +import { fireflyTexture, haloTexture } from "./textures"; +import { TrailRenderer } from "./trail"; + +interface TreeSceneProps { + city?: CityMap; + playback: FilePlayback; + selectedPath?: string; + onSelect: (path?: string) => void; + onCanvasReady?: (canvas: HTMLCanvasElement | null) => void; +} + +// Firefly tree: the repo is a radial tree — directories fork, files are +// leaves. The walker is a firefly that lights leaves as it lands; attention +// depth shows as the pool of light it leaves behind on the ground. +// +// One variable, one channel: leaf color says WHAT happened (touch state), +// halo radius says HOW MUCH (visits), branch brightness says WHERE the +// light is, and the selection is a shape (ring + beam), never a recolor. +const colors: Record = { + unvisited: new THREE.Color("#5a6375"), + ghost: new THREE.Color("#4d5464"), + ...touchColors, +}; +const EDGE_BASE = new THREE.Color("#3c424f"); +// branches leading to visited leaves brighten, but stay neutral: the branch +// guides the eye, the leaf carries the classification +const EDGE_LIT = new THREE.Color("#7d8496"); +// white-hot walker: keeps the firefly apart from edit-amber leaves +const FIREFLY_HOT = new THREE.Color("#ffeeda"); +const LEAF_Y = 0.7; + +// halo radius encodes revisits only — touch type already lives in the leaf +// color, and doubling it up here made the radius unreadable +function haloRadius(visits: number): number { + return Math.min(1.8 * (1 + 0.45 * Math.log2(Math.max(visits, 1))), 4.8); +} + +interface HaloSlot { + fileId: number; + target: number; + color: THREE.Color; +} + +const LABEL_Y = 1.8; +// the inspector docks on the right; selection pans the camera clear of it +const INSPECTOR_RESERVED_PX = 348; + +export function TreeScene({ + city, + playback, + selectedPath, + onSelect, + onCanvasReady, +}: TreeSceneProps) { + const hostRef = useRef(null); + const leafMeshRef = useRef(null); + const ghostMeshRef = useRef(null); + const ghostIndexRef = useRef>(new Map()); + const selectionRef = useRef<{ ring: THREE.Mesh; beam: THREE.Mesh } | null>(null); + const haloMeshRef = useRef(null); + const edgesRef = useRef(null); + const edgeMetaRef = useRef<{ childPath?: string; childFileId?: number; vertexCount: number }[]>( + [], + ); + const filesRef = useRef([]); + const layoutRef = useRef(null); + const slotsRef = useRef([]); + const radiiRef = useRef>(new Map()); + const rendererRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const sceneRef = useRef(null); + const groupRef = useRef(null); + const trailRef = useRef(null); + const fireflyRef = useRef(null); + const labelSetRef = useRef(null); + const frameRef = useRef(null); + const reducedRef = useRef(false); + // handlers live in the mount effect; they read playback through this ref + const playbackRef = useRef(playback); + playbackRef.current = playback; + // camera fit deferred while the viewport reports no size (hidden pane, + // background tab); resize retries it instead of leaving the camera at NaN + const fitPendingRef = useRef<(() => boolean) | null>(null); + + const layout = useMemo( + () => (city && city.files.length > 0 ? computeTreeLayout(city.files) : null), + [city], + ); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const reduced = prefersReducedMotion(); + reducedRef.current = reduced; + + const scene = new THREE.Scene(); + scene.background = SKY; + sceneRef.current = scene; + + const camera = new THREE.PerspectiveCamera( + 38, + host.clientWidth / host.clientHeight || 1, + 0.1, + 2400, + ); + camera.position.set(60, 110, 90); + cameraRef.current = camera; + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(host.clientWidth, host.clientHeight); + rendererRef.current = renderer; + host.appendChild(renderer.domElement); + onCanvasReady?.(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = !reduced; + controls.dampingFactor = 0.08; + controls.maxPolarAngle = Math.PI * 0.44; + controls.autoRotate = !reduced; + controls.autoRotateSpeed = -0.5; + const tip = new SceneTip(host); + controls.addEventListener("start", () => { + controls.autoRotate = false; + tip.hide(); + }); + controlsRef.current = controls; + + const sky = new THREE.HemisphereLight("#66779b", "#161922", 1.7); + scene.add(sky); + const moon = new THREE.DirectionalLight("#b6c5de", 1.1); + moon.position.set(-60, 120, -40); + scene.add(moon); + + // screen-space nearest-leaf picking: tiny orbs are hostile raycast + // targets, so pick whatever leaf lands closest to the pointer + const PICK_RADIUS = 18; + const projected = new THREE.Vector3(); + const pickFile = (event: PointerEvent): CityFile | undefined => { + const treeLayout = layoutRef.current; + if (!treeLayout || !cameraRef.current || !rendererRef.current) return undefined; + const rect = rendererRef.current.domElement.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + let best: CityFile | undefined; + let bestD2 = PICK_RADIUS * PICK_RADIUS; + for (const file of filesRef.current) { + const pos = treeLayout.leaf.get(file.id); + if (!pos) continue; + projected.set(pos.x, LEAF_Y, pos.z).project(cameraRef.current); + if (projected.z > 1) continue; + const sx = ((projected.x + 1) / 2) * rect.width; + const sy = ((1 - projected.y) / 2) * rect.height; + const d2 = (sx - px) * (sx - px) + (sy - py) * (sy - py); + if (d2 < bestD2) { + bestD2 = d2; + best = file; + } + } + return best; + }; + + let downAt: { x: number; y: number } | null = null; + const onPointerDown = (event: PointerEvent) => { + downAt = { x: event.clientX, y: event.clientY }; + }; + const onPointerUp = (event: PointerEvent) => { + if (!downAt) return; + const moved = Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y); + downAt = null; + if (moved > 5) return; + onSelect(pickFile(event)?.path); + }; + // hover: cursor + one-line readout, throttled to one pick per frame + let hoverRaf = 0; + const onPointerMove = (event: PointerEvent) => { + if (downAt) { + tip.hide(); + return; + } + if (hoverRaf) return; + hoverRaf = requestAnimationFrame(() => { + hoverRaf = 0; + const file = pickFile(event); + renderer.domElement.style.cursor = file ? "pointer" : ""; + if (!file) { + tip.hide(); + return; + } + const snapshot = playbackRef.current; + const touch = snapshot.touchByPath.get(file.path); + const visits = snapshot.visitsByFile.get(file.id) ?? 0; + const meta = touch + ? `${touchWord(touch)} · ${visits} visit${visits === 1 ? "" : "s"}` + : touchWord(undefined); + tip.show(file.path, file.ghost ? `${meta} · ghost` : meta, event.clientX, event.clientY); + }); + }; + const onPointerLeave = () => { + tip.hide(); + renderer.domElement.style.cursor = ""; + }; + renderer.domElement.addEventListener("pointerdown", onPointerDown); + renderer.domElement.addEventListener("pointerup", onPointerUp); + renderer.domElement.addEventListener("pointermove", onPointerMove); + renderer.domElement.addEventListener("pointerleave", onPointerLeave); + + const resize = () => { + if (!hostRef.current) return; + const w = hostRef.current.clientWidth; + const h = hostRef.current.clientHeight; + if (w === 0 || h === 0) return; + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + if (fitPendingRef.current?.()) fitPendingRef.current = null; + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + + const clock = new THREE.Clock(); + const matrix = new THREE.Matrix4(); + const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)); + const render = () => { + controls.update(); + labelSetRef.current?.updateTargets( + camera, + renderer.domElement.clientWidth, + renderer.domElement.clientHeight, + ); + labelSetRef.current?.ease(reducedRef.current); + + // pools of light grow toward their attention radius + const halos = haloMeshRef.current; + const slots = slotsRef.current; + const radii = radiiRef.current; + const treeLayout = layoutRef.current; + if (halos && treeLayout && slots.length > 0) { + let moving = false; + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]!; + const pos = treeLayout.leaf.get(slot.fileId); + if (!pos) continue; + let cur = radii.get(slot.fileId) ?? 0; + const diff = slot.target - cur; + if (Math.abs(diff) > 0.015) { + cur = reducedRef.current ? slot.target : cur + diff * 0.12; + radii.set(slot.fileId, cur); + moving = true; + } else if (cur !== slot.target) { + radii.set(slot.fileId, slot.target); + cur = slot.target; + moving = true; + } + matrix.compose( + new THREE.Vector3(pos.x, 0.06, pos.z), + quaternion, + new THREE.Vector3(Math.max(cur, 0.01) * 2, Math.max(cur, 0.01) * 2, 1), + ); + halos.setMatrixAt(i, matrix); + } + if (moving) halos.instanceMatrix.needsUpdate = true; + } + + const firefly = fireflyRef.current; + if (firefly?.visible) { + const t = clock.getElapsedTime(); + const base = firefly.userData.baseScale as number; + const pulse = reducedRef.current ? 1 : 1 + 0.1 * Math.sin(t * 2.4); + firefly.scale.setScalar(base * pulse); + } + renderer.render(scene, camera); + frameRef.current = requestAnimationFrame(render); + }; + render(); + + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + if (hoverRaf) cancelAnimationFrame(hoverRaf); + renderer.domElement.removeEventListener("pointerdown", onPointerDown); + renderer.domElement.removeEventListener("pointerup", onPointerUp); + renderer.domElement.removeEventListener("pointermove", onPointerMove); + renderer.domElement.removeEventListener("pointerleave", onPointerLeave); + tip.dispose(); + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + host.removeChild(renderer.domElement); + scene.clear(); + onCanvasReady?.(null); + }; + }, [onSelect, onCanvasReady]); + + // grow the skeleton: ground, edges, leaves, labels + useEffect(() => { + const scene = sceneRef.current; + if (!scene) return; + filesRef.current = city?.files ?? []; + layoutRef.current = layout; + slotsRef.current = []; + radiiRef.current = new Map(); + if (!city || !layout) return; + + const group = new THREE.Group(); + const size = layout.radius * 2.3; + + scene.fog = new THREE.Fog(SKY, size * 2.1, size * 4.2); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(size * 6, size * 6), + new THREE.MeshStandardMaterial({ color: "#14171e", roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -0.25; + group.add(ground); + + const grid = new THREE.GridHelper(size * 2.4, 40, "#1d222c", "#181c25"); + (grid.material as THREE.Material).transparent = true; + (grid.material as THREE.Material).opacity = 0.4; + grid.position.y = -0.24; + group.add(grid); + + // branch skeleton, one segment soup with per-vertex colors + const positions: number[] = []; + const edgeMeta: { childPath?: string; childFileId?: number; vertexCount: number }[] = []; + for (const edge of layout.edges) { + let count = 0; + for (let i = 0; i < edge.points.length - 1; i++) { + positions.push(edge.points[i]!.x, 0.12, edge.points[i]!.z); + positions.push(edge.points[i + 1]!.x, 0.12, edge.points[i + 1]!.z); + count += 2; + } + edgeMeta.push({ + childPath: edge.childPath, + childFileId: edge.childFileId, + vertexCount: count, + } as (typeof edgeMeta)[number]); + } + const edgeGeo = new THREE.BufferGeometry(); + edgeGeo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + const edgeColors = new Float32Array(positions.length); + edgeGeo.setAttribute("color", new THREE.BufferAttribute(edgeColors, 3)); + const edges = new THREE.LineSegments( + edgeGeo, + new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.9 }), + ); + edgesRef.current = edges; + edgeMetaRef.current = edgeMeta; + group.add(edges); + + // leaves: one orb per file, sized by sqrt(lines) — solid for files on + // disk, wireframe for ghosts (touched in the session, gone from the + // repo): shape separates them where a third grey never could. + // state colors must not fade with distance, so leaf-like materials + // opt out of the fog; only the ground plane keeps the depth cue + const leafGeo = new THREE.SphereGeometry(0.5, 10, 8); + const leafMat = new THREE.MeshBasicMaterial({ toneMapped: false, fog: false }); + const leaves = new THREE.InstancedMesh(leafGeo, leafMat, city.files.length); + leaves.instanceColor = new THREE.InstancedBufferAttribute( + new Float32Array(city.files.length * 3), + 3, + ); + const ghostFiles = city.files.filter((file) => file.ghost); + const ghostIndex = new Map(); + ghostFiles.forEach((file, i) => ghostIndex.set(file.id, i)); + ghostIndexRef.current = ghostIndex; + const matrix = new THREE.Matrix4(); + const hidden = new THREE.Matrix4().makeScale(0, 0, 0); + for (const file of city.files) { + const pos = layout.leaf.get(file.id); + if (!pos) continue; + if (file.ghost) { + leaves.setMatrixAt(file.id, hidden); + continue; + } + const scale = Math.min(0.24 + Math.sqrt(Math.max(file.lines, 1)) * 0.045, 1.05); + matrix.compose( + new THREE.Vector3(pos.x, LEAF_Y, pos.z), + new THREE.Quaternion(), + new THREE.Vector3(scale, scale, scale), + ); + leaves.setMatrixAt(file.id, matrix); + leaves.setColorAt(file.id, colors.unvisited); + } + leaves.instanceMatrix.needsUpdate = true; + if (leaves.instanceColor) leaves.instanceColor.needsUpdate = true; + leafMeshRef.current = leaves; + group.add(leaves); + + let ghosts: THREE.InstancedMesh | null = null; + if (ghostFiles.length > 0) { + ghosts = new THREE.InstancedMesh( + new THREE.SphereGeometry(0.55, 8, 5), + new THREE.MeshBasicMaterial({ + wireframe: true, + transparent: true, + opacity: 0.85, + toneMapped: false, + fog: false, + }), + ghostFiles.length, + ); + ghosts.instanceColor = new THREE.InstancedBufferAttribute( + new Float32Array(ghostFiles.length * 3), + 3, + ); + for (const file of ghostFiles) { + const pos = layout.leaf.get(file.id); + const i = ghostIndex.get(file.id)!; + if (!pos) { + ghosts.setMatrixAt(i, hidden); + continue; + } + const scale = Math.min(0.24 + Math.sqrt(Math.max(file.lines, 1)) * 0.045, 1.05) * 0.8; + matrix.compose( + new THREE.Vector3(pos.x, LEAF_Y, pos.z), + new THREE.Quaternion(), + new THREE.Vector3(scale, scale, scale), + ); + ghosts.setMatrixAt(i, matrix); + ghosts.setColorAt(i, colors.ghost); + } + ghosts.instanceMatrix.needsUpdate = true; + if (ghosts.instanceColor) ghosts.instanceColor.needsUpdate = true; + ghosts.raycast = () => undefined; + group.add(ghosts); + } + ghostMeshRef.current = ghosts; + + // pools of light under visited leaves — normal blending with a fixed + // alpha: additive stacking burned dense directories into one white + // blob and erased the per-file radius + const halos = new THREE.InstancedMesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial({ + map: haloTexture(), + transparent: true, + opacity: 0.55, + depthWrite: false, + toneMapped: false, + fog: false, + }), + city.files.length, + ); + halos.instanceColor = new THREE.InstancedBufferAttribute( + new Float32Array(city.files.length * 3), + 3, + ); + halos.count = 0; + halos.frustumCulled = false; + halos.raycast = () => undefined; + haloMeshRef.current = halos; + group.add(halos); + + // directory labels: screen-space LOD in the render loop decides which + // of the biggest subtrees show their name + labelSetRef.current = new DirLabelSet(layout.dirs, group, LABEL_Y); + + const firefly = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: fireflyTexture(), + color: FIREFLY_HOT, + blending: THREE.AdditiveBlending, + depthWrite: false, + transparent: true, + fog: false, + }), + ); + firefly.userData.baseScale = Math.max(size * 0.026, 2.2); + firefly.visible = false; + fireflyRef.current = firefly; + group.add(firefly); + + // selection is a shape, not a recolor: the ring + beam mark the leaf + // while its touch color stays intact + const selectionMat = new THREE.MeshBasicMaterial({ + color: colors.selected, + transparent: true, + opacity: 0.9, + side: THREE.DoubleSide, + depthWrite: false, + toneMapped: false, + fog: false, + }); + const ring = new THREE.Mesh(new THREE.RingGeometry(1.2, 1.5, 48), selectionMat); + ring.rotation.x = -Math.PI / 2; + ring.visible = false; + ring.raycast = () => undefined; + group.add(ring); + const beam = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.05, 6, 6, 1, true), + new THREE.MeshBasicMaterial({ + color: colors.selected, + transparent: true, + opacity: 0.32, + blending: THREE.AdditiveBlending, + depthWrite: false, + toneMapped: false, + fog: false, + }), + ); + beam.visible = false; + beam.raycast = () => undefined; + group.add(beam); + selectionRef.current = { ring, beam }; + + const trail = new TrailRenderer(1.6); + trailRef.current = trail; + group.add(trail.object); + + groupRef.current = group; + scene.add(group); + + // keep the canonical viewing direction, but pull back exactly far + // enough that every leaf fits the viewport's frustum — nominal radius + // ignores the aspect ratio and overflows short windows + const fitView = (): boolean => { + const camera = cameraRef.current; + const controls = controlsRef.current; + if (!camera || !controls) return true; + const dir = new THREE.Vector3(0.3, 0.66, 0.47).normalize(); + const points = [...layout.leaf.values()].map( + (pos) => new THREE.Vector3(pos.x, LEAF_Y, pos.z), + ); + const fitted = fitDistance(camera, dir, points); + if (fitted === null) return false; + // breathing room so edge leaves clear the HUD overlays + const distance = fitted * 1.12; + camera.position.copy(dir).multiplyScalar(distance); + controls.target.set(0, 0, 0); + controls.minDistance = size * 0.15; + controls.maxDistance = Math.max(size * 2.4, distance * 1.2); + controls.update(); + return true; + }; + fitPendingRef.current = fitView() ? null : fitView; + + return () => { + fitPendingRef.current = null; + disposeGroup(group); + scene.remove(group); + groupRef.current = null; + leafMeshRef.current = null; + ghostMeshRef.current = null; + ghostIndexRef.current = new Map(); + selectionRef.current = null; + haloMeshRef.current = null; + edgesRef.current = null; + trailRef.current = null; + fireflyRef.current = null; + labelSetRef.current = null; + }; + }, [city, layout]); + + // playback → leaf colors, halo targets, branch tinting + useEffect(() => { + const leaves = leafMeshRef.current; + const ghosts = ghostMeshRef.current; + const ghostIndex = ghostIndexRef.current; + const halos = haloMeshRef.current; + const edges = edgesRef.current; + if (!leaves || !halos || !edges || !city || !layout) return; + + const radii = radiiRef.current; + const slots: HaloSlot[] = []; + const present = new Set(); + // directories on a path to a touched file — branches brighten as a + // single neutral tone, classification stays on the leaves + const litDirs = new Set(); + for (const file of city.files) { + const touch = playback.touchByFile.get(file.id); + let leafColor = file.ghost ? colors.ghost : colors.unvisited; + if (touch) { + leafColor = colors[touch]; + const visits = playback.visitsByFile.get(file.id) ?? 1; + slots.push({ fileId: file.id, target: haloRadius(visits), color: colors[touch] }); + present.add(file.id); + const parts = file.path.split("/"); + let path = ""; + for (let i = 0; i < parts.length - 1; i++) { + path = path ? `${path}/${parts[i]}` : parts[i]!; + litDirs.add(path); + } + } + if (file.ghost) { + const i = ghostIndex.get(file.id); + if (ghosts && i !== undefined) ghosts.setColorAt(i, leafColor); + } else { + leaves.setColorAt(file.id, leafColor); + } + } + for (const [fileId, cur] of radii) { + if (cur > 0.04 && !present.has(fileId)) { + slots.push({ fileId, target: 0, color: colors.unvisited }); + } else if (cur <= 0.04 && !present.has(fileId)) { + radii.delete(fileId); + } + } + slots.forEach((slot, i) => halos.setColorAt(i, slot.color)); + halos.count = slots.length; + if (halos.instanceColor) halos.instanceColor.needsUpdate = true; + if (leaves.instanceColor) leaves.instanceColor.needsUpdate = true; + if (ghosts?.instanceColor) ghosts.instanceColor.needsUpdate = true; + slotsRef.current = slots; + + // brighten branches that lead to light + const colorAttr = edges.geometry.getAttribute("color") as THREE.BufferAttribute; + let vertex = 0; + for (const meta of edgeMetaRef.current) { + let lit = false; + if (meta.childFileId !== undefined) { + lit = playback.touchByFile.has(meta.childFileId); + } else if (meta.childPath) { + lit = litDirs.has(meta.childPath); + } + const tinted = lit ? EDGE_LIT : EDGE_BASE; + for (let v = 0; v < meta.vertexCount; v++) { + colorAttr.setXYZ(vertex++, tinted.r, tinted.g, tinted.b); + } + } + colorAttr.needsUpdate = true; + }, [city, layout, playback]); + + // selection marker follows the selected leaf + useEffect(() => { + const selection = selectionRef.current; + if (!selection || !city || !layout) return; + const file = selectedPath ? city.files.find((f) => f.path === selectedPath) : undefined; + const pos = file ? layout.leaf.get(file.id) : undefined; + if (pos) { + selection.ring.position.set(pos.x, 0.1, pos.z); + selection.beam.position.set(pos.x, 3, pos.z); + selection.ring.visible = true; + selection.beam.visible = true; + // the inspector opens over the right edge; pan the leaf clear of it + const camera = cameraRef.current; + const controls = controlsRef.current; + const canvas = rendererRef.current?.domElement; + if (camera && controls && canvas) { + controls.autoRotate = false; + ensureVisible( + camera, + controls, + new THREE.Vector3(pos.x, LEAF_Y, pos.z), + canvas.clientWidth, + canvas.clientHeight, + INSPECTOR_RESERVED_PX, + ); + } + } else { + selection.ring.visible = false; + selection.beam.visible = false; + } + }, [city, layout, selectedPath]); + + // trail arcs + firefly + useEffect(() => { + const trail = trailRef.current; + if (!trail || !city || !layout) return; + + // playback resolves fileId with the same path key the citymap uses, so a + // target still missing one has no leaf to land on + const targetFiles = playback.recentTargets + .map((target) => (target.fileId !== undefined ? city.files[target.fileId] : undefined)) + .filter((file): file is CityFile => Boolean(file && layout.leaf.get(file.id))); + + const firefly = fireflyRef.current; + if (firefly) { + const head = targetFiles[targetFiles.length - 1]; + if (head) { + const pos = layout.leaf.get(head.id)!; + firefly.position.set(pos.x, LEAF_Y + 1.7, pos.z); + firefly.visible = true; + } else { + firefly.visible = false; + } + } + + trail.update( + targetFiles.map((file) => { + const pos = layout.leaf.get(file.id)!; + return new THREE.Vector3(pos.x, LEAF_Y + 0.3, pos.z); + }), + ); + }, [city, layout, playback]); + + return
; +} diff --git a/apps/web/src/mindwalk/scene/dirLabels.ts b/apps/web/src/mindwalk/scene/dirLabels.ts new file mode 100644 index 000000000000..6698435d3335 --- /dev/null +++ b/apps/web/src/mindwalk/scene/dirLabels.ts @@ -0,0 +1,146 @@ +import * as THREE from "three"; +import { labelTexture } from "./textures"; + +// Map-style LOD for directory names, shared by both scenes: a label shows +// while its subtree spans enough screen pixels — zoom out and only the big +// forks stay named, push in and deeper directories surface where you're +// looking. Two colliding labels drop whichever names fewer files. + +export interface DirLabelEntry { + name: string; + x: number; + z: number; + /** world-space reach of the directory's subtree, drives the LOD cutoff */ + radius: number; + fileCount: number; + depth: number; +} + +interface DirLabel { + sprite: THREE.Sprite; + entry: DirLabelEntry; + aspect: number; + target: number; +} + +const LABEL_MIN_SUBTREE_PX = 60; +const LABEL_BUDGET = 120; + +export class DirLabelSet { + private readonly labels: DirLabel[] = []; + private readonly y: number; + private dirty = true; + private readonly lastCamPos = new THREE.Vector3(Infinity, Infinity, Infinity); + private readonly lastCamQuat = new THREE.Quaternion(0, 0, 0, 0); + private lastViewW = 0; + private lastViewH = 0; + private readonly point = new THREE.Vector3(); + + // sprites start invisible and fade in once the first projection pass runs; + // alwaysOnTop labels (terrain) skip the depth test so mountains never bury + // the district names + constructor(entries: DirLabelEntry[], group: THREE.Group, y: number, alwaysOnTop = false) { + this.y = y; + const budget = [...entries].sort((a, b) => b.fileCount - a.fileCount).slice(0, LABEL_BUDGET); + for (const entry of budget) { + const { texture, aspect } = labelTexture(entry.name); + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: !alwaysOnTop, + toneMapped: false, + fog: false, + }), + ); + if (alwaysOnTop) sprite.renderOrder = 20; + sprite.visible = false; + sprite.position.set(entry.x, y, entry.z); + sprite.raycast = () => undefined; + group.add(sprite); + this.labels.push({ sprite, entry, aspect, target: 0 }); + } + } + + markDirty() { + this.dirty = true; + } + + // reprojects subtrees whenever the camera or viewport moves, picks the + // labels whose subtree is prominent on screen, drops collision losers + updateTargets(camera: THREE.PerspectiveCamera, viewW: number, viewH: number) { + if (this.labels.length === 0 || viewW === 0 || viewH === 0) return; + const moved = + this.dirty || + !camera.position.equals(this.lastCamPos) || + !camera.quaternion.equals(this.lastCamQuat) || + viewW !== this.lastViewW || + viewH !== this.lastViewH; + if (!moved) return; + this.dirty = false; + this.lastCamPos.copy(camera.position); + this.lastCamQuat.copy(camera.quaternion); + this.lastViewW = viewW; + this.lastViewH = viewH; + + const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2); + const maxDim = Math.max(viewW, viewH); + interface Candidate { + label: DirLabel; + sx: number; + sy: number; + pw: number; + ph: number; + } + const candidates: Candidate[] = []; + for (const label of this.labels) { + this.point.set(label.entry.x, this.y, label.entry.z); + const dist = this.point.distanceTo(camera.position); + const pxPerWorld = viewH / (2 * dist * tanV); + const subtreePx = label.entry.radius * pxPerWorld; + this.point.project(camera); + const sx = ((this.point.x + 1) / 2) * viewW; + const sy = ((1 - this.point.y) / 2) * viewH; + const onScreen = + this.point.z < 1 && sx > -60 && sx < viewW + 60 && sy > -40 && sy < viewH + 40; + // too small to matter, or so large we're inside it — either way the + // name would float over unrelated geometry + if (!onScreen || subtreePx < LABEL_MIN_SUBTREE_PX || subtreePx > maxDim * 1.6) { + label.target = 0; + continue; + } + // constant screen-size type, like map labels + const ph = label.entry.depth <= 1 ? 15 : 13; + const worldH = ph / pxPerWorld; + label.sprite.scale.set(worldH * label.aspect, worldH, 1); + candidates.push({ label, sx, sy, pw: ph * label.aspect, ph }); + } + candidates.sort((a, b) => b.label.entry.fileCount - a.label.entry.fileCount); + const kept: Candidate[] = []; + for (const candidate of candidates) { + const clash = kept.some( + (other) => + Math.abs(other.sx - candidate.sx) < (other.pw + candidate.pw) / 2 + 14 && + Math.abs(other.sy - candidate.sy) < (other.ph + candidate.ph) / 2 + 10, + ); + candidate.label.target = clash ? 0 : 1; + if (!clash) kept.push(candidate); + } + } + + // labels ease toward their LOD targets each frame + ease(reduced: boolean) { + for (const label of this.labels) { + const material = label.sprite.material as THREE.SpriteMaterial; + const diff = label.target - material.opacity; + if (Math.abs(diff) > 0.02) { + material.opacity = reduced ? label.target : material.opacity + diff * 0.16; + } else if (material.opacity !== label.target) { + material.opacity = label.target; + } + label.sprite.visible = material.opacity > 0.02; + } + } +} diff --git a/apps/web/src/mindwalk/scene/sceneUtils.ts b/apps/web/src/mindwalk/scene/sceneUtils.ts new file mode 100644 index 000000000000..017514ff8899 --- /dev/null +++ b/apps/web/src/mindwalk/scene/sceneUtils.ts @@ -0,0 +1,144 @@ +import * as THREE from "three"; +import type { Touch } from "../types"; + +// Shared scene vocabulary. The touch colors are the meaning the HUD legend +// promises; both scenes must draw them identically. Ambient colors (ground, +// unvisited, ghost) stay per-scene tuning. +export const SKY = new THREE.Color("#12151c"); +export const EMBER = new THREE.Color("#ff9e5e"); + +export const touchColors: Record = { + hit: new THREE.Color("#8fb45f"), + // chromatic enough to read as blue on lit terrain columns — a paler tint + // washed out to white and stopped matching the HUD legend + read: new THREE.Color("#a5c8f1"), + edit: new THREE.Color("#f0ad5a"), + selected: new THREE.Color("#f6ead2"), +}; + +// Distance along `dir` that fits every point inside the camera frustum. +// Returns null while the viewport has no usable aspect (hidden pane, tab in +// the background, mid-layout) — fitting then would park the camera at NaN +// forever; callers must retry once a real resize lands. +export function fitDistance( + camera: THREE.PerspectiveCamera, + dir: THREE.Vector3, + points: Iterable, +): number | null { + if (!Number.isFinite(camera.aspect) || camera.aspect <= 0) return null; + const forward = dir.clone().negate(); + const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); + const up = new THREE.Vector3().crossVectors(right, forward); + const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2); + const tanH = tanV * camera.aspect; + let distance = 0; + for (const point of points) { + const depth = point.dot(forward); + distance = Math.max( + distance, + Math.abs(point.dot(right)) / tanH - depth, + Math.abs(point.dot(up)) / tanV - depth, + ); + } + return distance; +} + +// Pan the camera (position + orbit target together, so the view direction +// holds) until `world` projects inside the viewport's safe area. The right +// margin reserves room for the inspector panel, which would otherwise sit +// exactly on top of the leaf the user just selected. +export function ensureVisible( + camera: THREE.PerspectiveCamera, + controls: { target: THREE.Vector3; update: () => void }, + world: THREE.Vector3, + viewW: number, + viewH: number, + reservedRight: number, +) { + if (viewW === 0 || viewH === 0) return; + const forward = camera.getWorldDirection(new THREE.Vector3()); + const depth = world.clone().sub(camera.position).dot(forward); + if (depth <= 0) return; // behind the camera: panning math breaks down + const projected = world.clone().project(camera); + const sx = ((projected.x + 1) / 2) * viewW; + const sy = ((1 - projected.y) / 2) * viewH; + const safeL = 48; + const safeR = Math.max(safeL + 60, viewW - reservedRight - 48); + const safeT = 120; + const safeB = viewH - 100; + const targetX = Math.min(Math.max(sx, safeL), safeR); + const targetY = Math.min(Math.max(sy, safeT), safeB); + if (targetX === sx && targetY === sy) return; + const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2); + const tanH = tanV * camera.aspect; + const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); + const up = new THREE.Vector3().crossVectors(right, forward); + // moving the camera right shifts the point left on screen, and vice versa + const pan = right + .multiplyScalar(((sx - targetX) * 2 * depth * tanH) / viewW) + .addScaledVector(up, ((targetY - sy) * 2 * depth * tanV) / viewH); + camera.position.add(pan); + controls.target.add(pan); + controls.update(); +} + +// One-line hover readout, driven imperatively from the scenes' pointermove +// handlers so hovering never re-renders the React tree. +export class SceneTip { + private readonly el: HTMLDivElement; + private readonly pathEl: HTMLSpanElement; + private readonly metaEl: HTMLSpanElement; + + constructor(private readonly host: HTMLElement) { + this.el = document.createElement("div"); + this.el.className = "scene-tip"; + this.pathEl = document.createElement("span"); + this.metaEl = document.createElement("span"); + this.metaEl.className = "dim"; + this.el.append(this.pathEl, this.metaEl); + host.appendChild(this.el); + } + + show(path: string, meta: string, clientX: number, clientY: number) { + this.pathEl.textContent = path; + this.metaEl.textContent = ` · ${meta}`; + this.el.style.display = "block"; + const bounds = this.host.getBoundingClientRect(); + const x = clientX - bounds.left; + const y = clientY - bounds.top; + const left = Math.min(x + 14, Math.max(0, bounds.width - this.el.offsetWidth - 8)); + const top = Math.min(y + 16, Math.max(0, bounds.height - this.el.offsetHeight - 8)); + this.el.style.left = `${left}px`; + this.el.style.top = `${top}px`; + } + + hide() { + this.el.style.display = "none"; + } + + dispose() { + this.el.remove(); + } +} + +export const prefersReducedMotion = () => + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export function disposeGroup(group: THREE.Group) { + group.traverse((obj) => { + if (obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Sprite) { + obj.geometry?.dispose(); + const mat = obj.material as THREE.Material | THREE.Material[]; + if (Array.isArray(mat)) mat.forEach(disposeMaterial); + else if (mat) disposeMaterial(mat); + } + }); +} + +function disposeMaterial(mat: THREE.Material) { + // Material.dispose() does not free assigned textures; module-cached + // textures (fireflyTexture/haloTexture) are marked shared and must survive. + const map = (mat as THREE.Material & { map?: THREE.Texture | null }).map; + if (map && !map.userData.shared) map.dispose(); + mat.dispose(); +} diff --git a/apps/web/src/mindwalk/scene/textures.ts b/apps/web/src/mindwalk/scene/textures.ts new file mode 100644 index 000000000000..ddc00d91152c --- /dev/null +++ b/apps/web/src/mindwalk/scene/textures.ts @@ -0,0 +1,60 @@ +import * as THREE from "three"; + +let fireflyMap: THREE.Texture | null = null; +export function fireflyTexture(): THREE.Texture { + if (fireflyMap) return fireflyMap; + const size = 64; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + g.addColorStop(0, "rgba(255,255,255,1)"); + g.addColorStop(0.25, "rgba(255,210,160,0.55)"); + g.addColorStop(1, "rgba(255,158,94,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, size, size); + fireflyMap = new THREE.CanvasTexture(canvas); + fireflyMap.userData.shared = true; // module cache: disposeGroup must not free it + return fireflyMap; +} + +let haloMap: THREE.Texture | null = null; +export function haloTexture(): THREE.Texture { + if (haloMap) return haloMap; + const size = 128; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + g.addColorStop(0, "rgba(255,255,255,0.9)"); + g.addColorStop(0.4, "rgba(255,255,255,0.28)"); + g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, size, size); + haloMap = new THREE.CanvasTexture(canvas); + haloMap.userData.shared = true; // module cache: disposeGroup must not free it + return haloMap; +} + +export function labelTexture(text: string): { texture: THREE.Texture; aspect: number } { + const font = '500 30px "Schibsted Grotesk Variable", "PingFang SC", sans-serif'; + const measure = document.createElement("canvas").getContext("2d")!; + measure.font = font; + const width = Math.ceil(measure.measureText(text).width) + 24; + const height = 44; + const canvas = document.createElement("canvas"); + canvas.width = width * 2; + canvas.height = height * 2; + const ctx = canvas.getContext("2d")!; + ctx.scale(2, 2); + ctx.font = font; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(197, 205, 222, 0.95)"; + ctx.fillText(text, width / 2, height / 2 + 1); + const texture = new THREE.CanvasTexture(canvas); + texture.anisotropy = 4; + return { texture, aspect: width / height }; +} diff --git a/apps/web/src/mindwalk/scene/trail.ts b/apps/web/src/mindwalk/scene/trail.ts new file mode 100644 index 000000000000..f1683fc82fbe --- /dev/null +++ b/apps/web/src/mindwalk/scene/trail.ts @@ -0,0 +1,79 @@ +import * as THREE from "three"; +import { EMBER } from "./sceneUtils"; + +const SAMPLES = 14; +const MAX_ARCS = 11; // playback keeps 12 recent targets → at most 11 arcs + +// Fixed-capacity trail: the arc soup changes every playback tick, so the +// geometry and material are allocated once and only the attribute contents +// (plus the draw range) are rewritten in place. +export class TrailRenderer { + readonly object: THREE.LineSegments; + private readonly positions: THREE.BufferAttribute; + private readonly colors: THREE.BufferAttribute; + private readonly lift: number; + private readonly arcPoints = Array.from({ length: SAMPLES + 1 }, () => new THREE.Vector3()); + private readonly mid = new THREE.Vector3(); + private readonly curve = new THREE.QuadraticBezierCurve3(); + private readonly color = new THREE.Color(); + + constructor(lift: number) { + this.lift = lift; + const vertexCount = MAX_ARCS * SAMPLES * 2; + const geometry = new THREE.BufferGeometry(); + this.positions = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3); + this.positions.setUsage(THREE.DynamicDrawUsage); + this.colors = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3); + this.colors.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute("position", this.positions); + geometry.setAttribute("color", this.colors); + geometry.setDrawRange(0, 0); + const material = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + fog: false, + }); + this.object = new THREE.LineSegments(geometry, material); + this.object.frustumCulled = false; + } + + // points are the recent fixations, oldest first, y already at arc height + update(points: THREE.Vector3[]) { + const arcs = Math.min(Math.max(points.length - 1, 0), MAX_ARCS); + if (arcs === 0) { + this.object.geometry.setDrawRange(0, 0); + return; + } + const start = points.length - 1 - arcs; + let v = 0; + for (let i = 1; i <= arcs; i++) { + const a = points[start + i - 1]!; + const b = points[start + i]!; + this.mid.copy(a).lerp(b, 0.5); + this.mid.y = Math.max(a.y, b.y) + a.distanceTo(b) * 0.22 + this.lift; + this.curve.v0.copy(a); + this.curve.v1.copy(this.mid); + this.curve.v2.copy(b); + for (let s = 0; s <= SAMPLES; s++) { + this.curve.getPoint(s / SAMPLES, this.arcPoints[s]!); + } + const recency = i / arcs; + this.color.copy(EMBER).multiplyScalar(0.05 + 0.95 * recency * recency); + for (let s = 0; s < SAMPLES; s++) { + const pa = this.arcPoints[s]!; + const pb = this.arcPoints[s + 1]!; + this.positions.setXYZ(v, pa.x, pa.y, pa.z); + this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b); + v++; + this.positions.setXYZ(v, pb.x, pb.y, pb.z); + this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b); + v++; + } + } + this.object.geometry.setDrawRange(0, v); + this.positions.needsUpdate = true; + this.colors.needsUpdate = true; + } +} diff --git a/apps/web/src/mindwalk/scene/treeLayout.ts b/apps/web/src/mindwalk/scene/treeLayout.ts new file mode 100644 index 000000000000..ff39742e9f70 --- /dev/null +++ b/apps/web/src/mindwalk/scene/treeLayout.ts @@ -0,0 +1,182 @@ +import type { CityFile } from "../types"; + +// Deterministic radial tree: root at the origin, directories as branch +// forks, files as leaf slots. Every leaf gets an equal angular share in +// path-sorted order, so the same commit always grows the same tree. + +export interface TreeDir { + path: string; + name: string; + x: number; + z: number; + depth: number; + fileCount: number; + // how far the subtree's leaves reach from the fork, in layout units — + // the scene projects this to screen pixels to decide when the directory + // is prominent enough to deserve its label + radius: number; +} + +export interface TreeEdge { + childPath?: string; + childFileId?: number; + points: { x: number; z: number }[]; +} + +export interface TreeLayout { + radius: number; + leaf: Map; + dirs: TreeDir[]; + edges: TreeEdge[]; +} + +interface Node { + name: string; + path: string; + depth: number; + children: Map; + files: CityFile[]; + leafCount: number; + angle: number; +} + +export function computeTreeLayout(files: CityFile[]): TreeLayout { + const root: Node = { + name: "", + path: "", + depth: 0, + children: new Map(), + files: [], + leafCount: 0, + angle: 0, + }; + const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : 1)); + for (const file of sorted) { + const parts = file.path.split("/").filter(Boolean); + let node = root; + for (const part of parts.slice(0, -1)) { + let next = node.children.get(part); + if (!next) { + next = { + name: part, + path: node.path ? `${node.path}/${part}` : part, + depth: node.depth + 1, + children: new Map(), + files: [], + leafCount: 0, + angle: 0, + }; + node.children.set(part, next); + } + node = next; + } + node.files.push(file); + } + + let maxDepth = 1; + const countLeaves = (node: Node): number => { + node.leafCount = node.files.length; + if (node.files.length > 0) maxDepth = Math.max(maxDepth, node.depth + 1); + for (const child of node.children.values()) { + node.leafCount += countLeaves(child); + } + return node.leafCount; + }; + countLeaves(root); + + const total = Math.max(root.leafCount, 1); + const radius = Math.max(55, Math.sqrt(total) * 4); + const step = radius / Math.max(maxDepth, 1); + const slotAngle = (Math.PI * 2) / total; + + const layout: TreeLayout = { radius, leaf: new Map(), dirs: [], edges: [] }; + const polar = (r: number, angle: number) => ({ x: r * Math.cos(angle), z: r * Math.sin(angle) }); + + const sampleEdge = ( + r1: number, + a1: number, + r2: number, + a2: number, + ): { x: number; z: number }[] => { + // straight spokes out of the root, eased sweeps between rings + const from = r1 < 1e-6 ? a2 : a1; + let delta = a2 - from; + while (delta > Math.PI) delta -= Math.PI * 2; + while (delta < -Math.PI) delta += Math.PI * 2; + const points: { x: number; z: number }[] = []; + const SEGMENTS = 8; + for (let s = 0; s <= SEGMENTS; s++) { + const t = s / SEGMENTS; + const ease = t * t * (3 - 2 * t); + points.push(polar(r1 + (r2 - r1) * t, from + delta * ease)); + } + return points; + }; + + let cursor = 0; + const place = (node: Node) => { + node.angle = (cursor + node.leafCount / 2) * slotAngle; + const dirs = [...node.children.values()].sort((a, b) => (a.name < b.name ? -1 : 1)); + const files = [...node.files].sort((a, b) => (a.path < b.path ? -1 : 1)); + for (const child of dirs) { + place(child); + } + for (const file of files) { + const angle = (cursor + 0.5) * slotAngle; + cursor += 1; + const pos = polar((node.depth + 1) * step, angle); + layout.leaf.set(file.id, pos); + layout.edges.push({ + childFileId: file.id, + points: sampleEdge(node.depth * step, node.angle, (node.depth + 1) * step, angle), + }); + } + if (node.path !== "") { + const pos = polar(node.depth * step, node.angle); + layout.dirs.push({ + path: node.path, + name: node.name, + x: pos.x, + z: pos.z, + depth: node.depth, + fileCount: node.leafCount, + radius: 0, + }); + } + }; + place(root); + + // parent→dir edges need final angles, so wire them after placement + const nodeByPath = new Map(); + const collect = (node: Node) => { + nodeByPath.set(node.path, node); + for (const child of node.children.values()) collect(child); + }; + collect(root); + for (const dir of layout.dirs) { + const node = nodeByPath.get(dir.path)!; + const parentPath = dir.path.includes("/") ? dir.path.slice(0, dir.path.lastIndexOf("/")) : ""; + const parent = nodeByPath.get(parentPath)!; + layout.edges.push({ + childPath: dir.path, + points: sampleEdge(parent.depth * step, parent.angle, node.depth * step, node.angle), + }); + } + + // subtree reach: every leaf stretches the radius of each ancestor dir + const dirByPath = new Map(layout.dirs.map((dir) => [dir.path, dir])); + for (const file of files) { + const pos = layout.leaf.get(file.id); + if (!pos) continue; + const parts = file.path.split("/").filter(Boolean); + let path = ""; + for (let i = 0; i < parts.length - 1; i++) { + path = path ? `${path}/${parts[i]}` : parts[i]!; + const dir = dirByPath.get(path); + if (!dir) continue; + dir.radius = Math.max(dir.radius, Math.hypot(pos.x - dir.x, pos.z - dir.z)); + } + } + + return layout; +} diff --git a/apps/web/src/mindwalk/types.ts b/apps/web/src/mindwalk/types.ts new file mode 100644 index 000000000000..e0546cc3d145 --- /dev/null +++ b/apps/web/src/mindwalk/types.ts @@ -0,0 +1,271 @@ +export type Action = "search" | "read" | "edit" | "exec" | "verify" | "other"; +export type Touch = "hit" | "read" | "edit"; + +/** the words the HUD legend uses for each touch state — every surface that + * names a touch must speak this vocabulary, not the wire values */ +export function touchWord(touch?: Touch): string { + switch (touch) { + case "hit": + return "seen"; + case "read": + return "read"; + case "edit": + return "edited"; + default: + return "unvisited"; + } +} + +export interface SessionMeta { + key: string; + id: string; + harness: string; + title?: string; + path: string; + cwd?: string; + model?: string; + gitBranch?: string; + startedAt?: string; + endedAt?: string; + eventCount: number; + /** user-turn count; with eventCount, the badge's cheap staleness signal */ + userTurns?: number; + /** evaluation state for the rail badge; absent when never evaluated */ + reportState?: "running" | "done" | "stale" | "failed"; +} + +export type AgentKind = "main" | "subagent"; +export type AgentStatus = "main" | "launched" | "failed" | "unknown"; +export type TraceAvailability = "available" | "missing" | "unavailable"; +export type AgentLinkQuality = "exact" | "derived" | "unavailable"; +export type AgentLinkMethod = + | "root" + | "codex-agent-id" + | "codex-parent-thread-id" + | "claude-tool-use-id" + | "claude-subagents-directory" + | "unavailable"; + +export interface AgentGraph { + version: number; + rootSessionKey: string; + agents: AgentNode[]; +} + +export interface AgentNode { + id: string; + parentId?: string; + depth: number; + kind: AgentKind; + label: string; + role?: string; + instructionPreview?: string; + launchSeq?: number; + launchCallId?: string; + status: AgentStatus; + traceAvailability: TraceAvailability; + traceSessionKey?: string; + traceEventCount: number; + linkQuality: AgentLinkQuality; + linkMethod: AgentLinkMethod; +} + +export interface Rect { + x: number; + z: number; + w: number; + d: number; +} + +export interface CityMap { + version: number; + repo: { + root: string; + commit?: string; + dirty: boolean; + generatedAt: string; + }; + files: CityFile[]; + dirs: CityDir[]; + layout: { + algorithm: string; + weight: string; + }; +} + +export interface CityFile { + id: number; + path: string; + dir: string; + lines: number; + bytes: number; + lang?: string; + rect: Rect; + ghost: boolean; +} + +export interface CityDir { + path: string; + depth: number; + rect: Rect; + fileCount: number; + lines: number; +} + +export interface Trace { + version: number; + session: { + id: string; + harness: string; + model?: string; + title?: string; + cwd?: string; + commit?: string; + startedAt?: string; + endedAt?: string; + eventCount: number; + path?: string; + }; + events: TraceEvent[]; + marks: Mark[]; + stats: Stats; +} + +export interface TraceEvent { + seq: number; + ts?: string; + tool: string; + action: Action; + targets: Target[]; + outside?: OutsideTouch[]; + resultBytes: number; + isError: boolean; + summary: string; +} + +export interface Target { + path: string; + fileId?: number; + touch: Touch; + lines?: [number, number][]; + weak?: boolean; +} + +export interface OutsideTouch { + scope: "home" | "tmp" | "other"; + path: string; +} + +export interface Mark { + seq: number; + type: "compaction" | "user-message" | "subagent"; + note?: string; +} + +export interface Stats { + filesInRepo: number; + fovea: number; + parafovea: number; + edited: number; + eventsBeforeFirstEdit: number; + regressionRate: number; + errorRate: number; + actions: ActionCounts; + errors: ActionCounts; + maxEditsPerFile: number; + /** files edited in three or more events */ + churnFiles: number; + userTurns: number; + compactions: number; + subagents: number; + resultBytes: number; + /** edit events after the last verify event; every edit event when the session never verified */ + editsAfterLastVerify: number; + observability: Observability; +} + +/** + * Grades each derived metric's source signal: "exact" when the harness + * records it structurally, "estimated" when inferred from command or output + * text, "unavailable" when the log carries no usable signal. + */ +export interface Observability { + reads: MetricObservability; + errors: MetricObservability; +} + +export type MetricObservability = "exact" | "estimated" | "unavailable"; + +/** LLM-assisted evaluation of one session; verdicts are server-derived + * from finding severities, never decided by the judge itself */ +export interface Report { + version: number; + session: { + id: string; + harness: string; + model?: string; + /** event count the report was generated from — the staleness signal */ + eventCount: number; + }; + judge: { + cli: string; + /** the LLM that actually judged, as reported by the CLI itself */ + model?: string; + promptVersion: number; + generatedAt: string; + }; + taskSummary: string; + dimensions: ReportDimension[]; + notableMoments?: ReportMoment[]; + narrative: string; +} + +export type Verdict = "good" | "warning" | "problem" | "insufficient-data"; +export type Severity = "info" | "warning" | "problem"; +export type DimensionName = "exploration" | "scope" | "wandering" | "verification"; + +export interface ReportDimension { + name: DimensionName; + verdict: Verdict; + findings: ReportFinding[]; +} + +export interface ReportFinding { + claim: string; + severity: Severity; + evidenceSeqs?: number[]; +} + +export interface ReportMoment { + seq: number; + note: string; +} + +export interface ReportStatus { + state: "none" | "running" | "done" | "failed"; + /** done, but generated from fewer events than the trace now has */ + stale: boolean; + report?: Report; + error?: string; + judgeAvailable: boolean; + /** default judge (first available) */ + judgeCli?: string; + /** every installed judge CLI, preference order first */ + judgeClis?: string[]; +} + +/** the panel's judge selection, sent with the analyze request */ +export interface JudgeChoice { + cli: string; + /** empty string keeps the CLI's default model */ + model: string; +} + +export interface ActionCounts { + search: number; + read: number; + edit: number; + exec: number; + verify: number; + other: number; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714e..adc29cbc0952 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as Mindwalk3dRouteImport } from './routes/mindwalk-3d' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' @@ -37,6 +38,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const Mindwalk3dRoute = Mindwalk3dRouteImport.update({ + id: '/mindwalk-3d', + path: '/mindwalk-3d', + getParentRoute: () => rootRouteImport, +} as any) const ConnectRoute = ConnectRouteImport.update({ id: '/connect', path: '/connect', @@ -116,6 +122,7 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute + '/mindwalk-3d': typeof Mindwalk3dRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute @@ -133,6 +140,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/connect': typeof ConnectRoute + '/mindwalk-3d': typeof Mindwalk3dRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute @@ -153,6 +161,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute + '/mindwalk-3d': typeof Mindwalk3dRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute @@ -174,6 +183,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/connect' + | '/mindwalk-3d' | '/pair' | '/settings' | '/connect/callback' @@ -191,6 +201,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/connect' + | '/mindwalk-3d' | '/pair' | '/settings' | '/connect/callback' @@ -210,6 +221,7 @@ export interface FileRouteTypes { | '__root__' | '/_chat' | '/connect' + | '/mindwalk-3d' | '/pair' | '/settings' | '/connect_/callback' @@ -230,6 +242,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute + Mindwalk3dRoute: typeof Mindwalk3dRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren ConnectCallbackRoute: typeof ConnectCallbackRoute @@ -251,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/mindwalk-3d': { + id: '/mindwalk-3d' + path: '/mindwalk-3d' + fullPath: '/mindwalk-3d' + preLoaderRoute: typeof Mindwalk3dRouteImport + parentRoute: typeof rootRouteImport + } '/connect': { id: '/connect' path: '/connect' @@ -404,6 +424,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, + Mindwalk3dRoute: Mindwalk3dRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, ConnectCallbackRoute: ConnectCallbackRoute, diff --git a/apps/web/src/routes/mindwalk-3d.tsx b/apps/web/src/routes/mindwalk-3d.tsx new file mode 100644 index 000000000000..d4ac7b3b144a --- /dev/null +++ b/apps/web/src/routes/mindwalk-3d.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { lazy, Suspense } from "react"; + +// three.js and the ported scenes load only when this route is visited, so the +// main bundle pays nothing for them. +const MindwalkHarness = lazy(() => import("../mindwalk/MindwalkHarness")); + +export const Route = createFileRoute("/mindwalk-3d")({ + validateSearch: (search: Record) => ({ + threadId: typeof search.threadId === "string" ? search.threadId : "", + }), + component: Mindwalk3dRouteView, +}); + +function Mindwalk3dRouteView() { + const { threadId } = Route.useSearch(); + return ( +
+ + + +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85afdd75d7a6..ad057c7bb347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,6 +609,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + three: + specifier: ^0.182.0 + version: 0.182.0 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -637,6 +640,9 @@ importers: '@types/react-dom': specifier: ~19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/three': + specifier: ^0.182.0 + version: 0.182.0 '@vercel/config': specifier: ^0.3.0 version: 0.3.0 @@ -1841,6 +1847,9 @@ packages: '@cloudflare/workers-types@5.20260726.1': resolution: {integrity: sha512-fKgRSm3sDmOdak1LGWehS4vSPSj7/zeu0NfmE62VPjBMqWgODcOGljYvq6A75sL+7YfY3iGFGb0jVEDYq+hlmw==} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@distilled.cloud/aws@0.30.2': resolution: {integrity: sha512-Uw2yZf7PJ2ienrKG49HN1ajnke65mTtHBUrSb/3ykeZ/8cLaSgwVhPscHxSzGByY0TEtYcYKNNmfUVISgGQl9g==} peerDependencies: @@ -4770,6 +4779,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -4871,9 +4883,15 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/three@0.182.0': + resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4883,6 +4901,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -5142,6 +5163,9 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@webgpu/types@0.1.71': + resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -6903,6 +6927,9 @@ packages: ffi-rs@1.3.2: resolution: {integrity: sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -7954,6 +7981,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@0.22.0: + resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} + metro-babel-transformer@0.84.4: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -9704,6 +9734,9 @@ packages: engines: {node: '>=10'} hasBin: true + three@0.182.0: + resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -11657,6 +11690,8 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -14903,6 +14938,8 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -15025,14 +15062,28 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/stats.js@0.17.4': {} + '@types/statuses@2.0.6': {} + '@types/three@0.182.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.71 + fflate: 0.8.3 + meshoptimizer: 0.22.0 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@types/webidl-conversions@7.0.3': {} + '@types/webxr@0.5.24': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -15267,6 +15318,8 @@ snapshots: '@vscode/l10n@0.0.18': {} + '@webgpu/types@0.1.71': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -17450,6 +17503,8 @@ snapshots: '@yuuang/ffi-rs-win32-ia32-msvc': 1.3.2 '@yuuang/ffi-rs-win32-x64-msvc': 1.3.2 + fflate@0.8.3: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -18610,6 +18665,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@0.22.0: {} + metro-babel-transformer@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -20971,6 +21028,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + three@0.182.0: {} + throat@5.0.0: {} timestring@6.0.0: {}