Repository navigation
feat(web): render mindwalk's Tree and Terrain scenes against a T3 citymap #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1e879f1
84454cb
b26f71b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CityMap | undefined>(); | ||
| const [error, setError] = useState<string | undefined>(); | ||
| const [view, setView] = useState<View>("terrain"); | ||
| const [selectedPath, setSelectedPath] = useState<string | undefined>(); | ||
|
|
||
| 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 ( | ||
| <div className="mindwalk-harness bg-[#12151c]"> | ||
| {city ? ( | ||
| view === "tree" ? ( | ||
| <TreeScene city={city} playback={playback} onSelect={setSelectedPath} {...selection} /> | ||
| ) : ( | ||
| <CityScene | ||
| city={city} | ||
| playback={playback} | ||
| onSelect={setSelectedPath} | ||
| locHeights | ||
| {...selection} | ||
| /> | ||
| ) | ||
| ) : ( | ||
| <div className="flex h-full items-center justify-center text-sm text-white/60"> | ||
| {error | ||
| ? `Citymap failed: ${error}` | ||
| : threadId | ||
| ? "Building the map…" | ||
| : "Pass ?threadId=<id> to load a citymap."} | ||
| </div> | ||
| )} | ||
| <div className="absolute top-3 left-3 z-40 flex gap-1 rounded-md bg-black/50 p-1 text-xs text-white"> | ||
| {(["terrain", "tree"] as const).map((candidate) => ( | ||
| <button | ||
| key={candidate} | ||
| type="button" | ||
| onClick={() => setView(candidate)} | ||
| className={`rounded px-2 py-1 ${view === candidate ? "bg-white/20" : "hover:bg-white/10"}`} | ||
| > | ||
| {candidate} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RecordResult> { | ||
| 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[] = []; | ||
|
Comment on lines
+75
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
+ return new Promise<RecordResult>((resolve, reject) => {
+ let rafId = 0;
+ let startTs = 0;
+ let settled = false;
+
+ const fps = opts.fps ?? DEFAULT_FPS;
+ const stream = canvas.captureStream(fps);
+ const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
+ const chunks: BlobPart[] = [];🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
|
|
||
| return new Promise<RecordResult>((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); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.