feat(observatory): GPU picking — click a memory in the field to inspect it

The living field is now interactive, killing Classic's main reason to
exist. Clicking a node in the WebGPU field opens the same Memory Detail
inspector (content, FSRS bars, retention forecast, promote/demote,
explore) that Classic's Three.js picking drove.

How: NodeRenderer.pickAt(ndcX, ndcY) — the one sanctioned GPU readback,
input-driven and click-frequency only, so the render loop's determinism
contract (spec §6) holds. It copies the live NodeState buffer (post
force-sim positions) to a MAP_READ staging buffer, reprojects every node
on the CPU through the SAME deterministic orbit camera the frame used
(engine params phase + canvas aspect, column-major viewProj), and picks
the nearest node whose projected disc (r·f/w with a floor for faint
nodes, 1.6x halo slop) contains the click. NodeState gains COPY_SRC for
the copy; a fresh staging buffer per click is created and destroyed.

ObservatoryStage gains an onpick prop wired to the canvas layer
(crosshair cursor when interactive); the graph page passes onNodeSelect
so field picks open the existing panel.

Verified live: click at field center hit a real memory, panel opened
with real FSRS data (retention 65.4%, forecast 65%->37% @30d), zero
console errors. svelte-check 940 files 0/0; 96 observatory tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 19:22:07 -07:00
parent c920f93b10
commit 9f5a1fde4a
3 changed files with 116 additions and 7 deletions

View file

@ -57,6 +57,11 @@
* provides all chrome (the main graph's field renderer).
*/
chrome?: 'full' | 'none';
/**
* GPU picking: when provided, clicking a memory node in the field calls
* back with its memory id (the host opens its inspector panel).
*/
onpick?: (memoryId: string) => void;
}
let {
@ -68,9 +73,22 @@
ondemochange,
onexit,
embedded = false,
chrome = 'full'
chrome = 'full',
onpick
}: Props = $props();
// GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click).
let canvasLayerEl: HTMLDivElement | null = $state(null);
async function handleFieldClick(e: MouseEvent) {
if (!onpick || !renderer || !canvasLayerEl) return;
const rect = canvasLayerEl.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
const hit = await renderer.pickAt(ndcX, ndcY);
if (hit) onpick(hit.id);
}
// Human labels for the switcher chips — short, mono, uppercase (visual DNA §7.3).
const DEMO_LABELS: Record<DemoMode, string> = {
'recall-path': 'RECALL',
@ -266,8 +284,15 @@
class="{embedded ? 'absolute' : 'fixed'} inset-0 overflow-hidden bg-[#05060a]"
class:cursor-none={capture}
>
<!-- Canvas layer (z-index 0) — the living memory field -->
<div class="absolute inset-0 z-0">
<!-- Canvas layer (z-index 0) — the living memory field. When the host
passes onpick, clicks GPU-pick the memory under the cursor. -->
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
bind:this={canvasLayerEl}
class="absolute inset-0 z-0"
class:cursor-crosshair={!!onpick && !capture}
onclick={handleFieldClick}
>
<ObservatoryCanvas
{demo}
{seed}

View file

@ -6,7 +6,9 @@
* written from the deterministic loop phase each frame (orbit), nodes draw
* as instanced soft-glow sprites straight from the storage buffer.
*
* No GPU readback, no wall-clock state (spec §6).
* The RENDER LOOP does no GPU readback and holds no wall-clock state
* (spec §6 deterministic frames). pickAt() is the one sanctioned,
* input-driven readback: click-frequency only, never per-frame.
*/
import type { GraphResponse } from '$types';
@ -18,7 +20,7 @@ import {
buildNodeStateArray,
buildEdgeIndexArray
} from './graph-upload';
import type { ObservatoryGraph } from './types';
import { FLOATS_PER_NODE, NODE_LANE, type ObservatoryGraph } from './types';
import { renderNodesWGSL } from './shaders/render-nodes.wgsl';
import { simulateWGSL } from './shaders/simulate.wgsl';
import { renderPathWGSL } from './shaders/render-path.wgsl';
@ -79,10 +81,15 @@ export class NodeRenderer implements FramePass {
this.nodeCount = nodeCount;
this.nodeBuffer?.destroy();
// COPY_SRC exists solely for pickAt()'s click-time readback.
this.nodeBuffer = device.createBuffer({
label: 'observatory-node-state',
size: Math.max(data.byteLength, 64),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX
usage:
GPUBufferUsage.STORAGE |
GPUBufferUsage.COPY_DST |
GPUBufferUsage.COPY_SRC |
GPUBufferUsage.VERTEX
});
device.queue.writeBuffer(this.nodeBuffer, 0, data.buffer as ArrayBuffer);
@ -319,6 +326,77 @@ export class NodeRenderer implements FramePass {
return this.pathSteps;
}
/**
* Click picking the ONE sanctioned GPU readback (input-driven, never
* per-frame, so the render loop's determinism contract holds).
*
* Copies the live NodeState buffer (post force-sim positions) to a staging
* buffer, reprojects every node through the SAME deterministic orbit camera
* the current frame used (engine params: phase + canvas size), and returns
* the nearest node whose projected disc contains the click.
*
* @param ndcX click x in NDC (-1..1, right = +)
* @param ndcY click y in NDC (-1..1, up = +)
*/
async pickAt(ndcX: number, ndcY: number): Promise<{ index: number; id: string } | null> {
const device = this.engine.gpuDevice;
if (!device || !this.nodeBuffer || !this.graph || this.nodeCount === 0) return null;
const byteSize = this.nodeCount * FLOATS_PER_NODE * 4;
const staging = device.createBuffer({
label: 'observatory-pick-staging',
size: byteSize,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});
const encoder = device.createCommandEncoder({ label: 'observatory-pick-copy' });
encoder.copyBufferToBuffer(this.nodeBuffer, 0, staging, 0, byteSize);
device.queue.submit([encoder.finish()]);
let data: Float32Array;
try {
await staging.mapAsync(GPUMapMode.READ);
data = new Float32Array(staging.getMappedRange().slice(0));
} catch {
staging.destroy();
return null;
}
staging.unmap();
staging.destroy();
// Same camera inputs the frame pass uses (compute() above).
const w = this.engine.params[6] || 1;
const h = this.engine.params[7] || 1;
const phase = this.engine.params[1];
const m = orbitCamera(phase, w / h, ORBIT_DISTANCE).viewProj; // column-major
// Projected-disc hit test: fovY 50° → f = 1/tan(25°); a node of world
// radius r at clip-w distance projects to ~r·f/w in NDC y. A small
// floor keeps faint distant nodes clickable; score <1.6 allows a
// forgiving halo around the disc; lowest score (closest relative to
// its disc) wins.
const f = 1 / Math.tan((50 * Math.PI) / 360);
let best = -1;
let bestScore = Infinity;
for (let i = 0; i < this.nodeCount; i++) {
const b = i * FLOATS_PER_NODE + NODE_LANE.posRadius;
const x = data[b];
const y = data[b + 1];
const z = data[b + 2];
const r = data[b + 3];
const cw = m[3] * x + m[7] * y + m[11] * z + m[15];
if (cw <= 0) continue; // behind the camera
const cx = (m[0] * x + m[4] * y + m[8] * z + m[12]) / cw;
const cy = (m[1] * x + m[5] * y + m[9] * z + m[13]) / cw;
const projR = Math.max((r * f) / cw, 0.012);
const score = Math.hypot(cx - ndcX, cy - ndcY) / projR;
if (score < 1.6 && score < bestScore) {
bestScore = score;
best = i;
}
}
if (best < 0) return null;
return { index: best, id: this.graph.nodes[best].id };
}
dispose(): void {
this.nodeBuffer?.destroy();
this.edgeBuffer?.destroy();

View file

@ -318,7 +318,13 @@ disown</code>
MAIN renderer: GPU force sim, HDR bloom, the recall wavefront
sweeping the real graph on a 12s loop. Page chrome stays DOM. -->
<div class="absolute inset-0">
<ObservatoryStage embedded chrome="none" demo="recall-path" showSwitcher={false} />
<ObservatoryStage
embedded
chrome="none"
demo="recall-path"
showSwitcher={false}
onpick={onNodeSelect}
/>
</div>
{:else}
<Graph3D