/** * The graph column (weave-workspace §1.2, §7, P3). * * Props in, JSX out, and one `ref` handed to a renderer. Every string, every * branch and every set comes from `column.model.ts`, `graph.model.ts` or * `positions.ts`; what is left here is a `useState`, three effects and four * handlers. §10's rule, and the reason the 95 % gate survives a phase whose * whole deliverable is a WebGL canvas. * * The three effects, all one-liners over injected units: * * 1. **mount** — build a renderer, attach it to the `
`, subscribe to * selection. The returned `destroy` is the cleanup, so a hot reload cannot * leak a WebGL context. * 2. **graph** — push the drawn model whenever the reduction or the layout * changes. Keyed on `model.key`, which is the *shape* digest, so an SSE * tick that changed a note's body does not re-upload the graph (§7.3: * re-run on drag or expand/collapse, not every frame). * 3. **highlight** — push the neighbourhood whenever the selection moves. * This is the §1.3 bus arriving from the tree, a wikilink or the context * rail; the graph does not care which, and that is the whole mechanism * behind "selecting anywhere highlights everywhere". * * ## The `live` ref * * The renderer's `onSelect` is registered once at mount and outlives every * render, so a handler that closed over `view` and `model` directly would keep * answering with the state as it was at mount. The same `live.current` pattern * `Shell.tsx` uses for its drag handlers, for the same reason. */ import { useEffect, useRef, useState, useCallback, useMemo } from "preact/hooks"; import type { GraphPayload } from "../../shared/wire"; import type { GraphViewState } from "./column.model"; import { FIT_HINT, FIT_LABEL, FORCES_HINT, FORCES_LABEL, LEGEND, effectiveView, graphClick, graphColumnModel, graphCountLabel, hoverHighlight, } from "./column.model"; import type { PositionStorage } from "./positions"; import type { GraphRenderer, RendererFactory } from "./renderer"; import { schemeOf } from "./scheme"; import type { SchemeHost } from "./scheme"; import type { ColorScheme } from "./graph.model"; import { createGraphSimulation } from "./dynamics"; import type { GraphSimulation } from "./dynamics"; import { ForceTuner } from "./ForceTuner"; import { POSITIONS_STORAGE_KEY } from "./positions"; import { loadGroupColors, saveGroupColors } from "./tuner.model"; export interface GraphProps { graph: GraphPayload | null; selectedId: string | null; onSelect: (id: string | null) => void; /** Injected: `createSigmaRenderer` in the browser, a fake in a test. */ renderer: RendererFactory; /** Injected: `localStorage`. See `positions.ts` for why it is a port. */ storage: PositionStorage; /** Injected: `window`. Read once, for `prefers-color-scheme`. */ host: SchemeHost; /** * The effective scheme, resolved by the shell from the user's theme choice * (`shell/theme.model.ts`), or `null`/omitted to read the OS via * {@link schemeOf}. A change remounts the renderer — the one response to a * palette change `renderer.ts` calls honest — because a WebGL palette is * fixed at construction. */ scheme: ColorScheme | null; /** * Whether the boot graph fetch failed (`state.ts`'s `graphFailed`). The * empty column's sentence switches from "Loading…" to a named recovery: * a canvas that never arrives needs a first sentence that says why. */ bootFailed?: boolean; /** * A slot the column writes its `fit` into, so the global `g` key can reach * it (§11 P4). * * A ref rather than lifting the renderer into the shell: the renderer is * created, owned and destroyed by the mount effect below, and hoisting it * would move a WebGL lifecycle out of the one component that can see the * `
` it attaches to. A slot the column fills and clears on unmount * keeps the ownership where it is and costs one line at each end. */ fit: { current: (() => void) | null }; /** * Show the hidden force tuner (`?sliders=1`, docs/weave-workspace.md §15.7). * * A prop rather than a `location.search` read here, for the same reason * `cwd` and `platform` are props: the decision is `slidersFlag`'s, and it is * testable where a `location` read is not. */ tuner?: boolean; } export function Graph(props: GraphProps) { const canvas = useRef(null); const renderer = useRef(null); const dynamics = useRef(null); const clock = useRef(null); /** * Arm the simulation clock — idempotent. * * The layout is always live; the clock still sleeps whenever the engine's * alpha reaches its floor (a graph that is holding still costs zero frames) * and re-arms when a drag pins a node or a re-layout hands over a new * engine. */ const armClock = useCallback(() => { if (clock.current !== null) return; const step = () => { clock.current = null; const engine = dynamics.current; if (engine === null) return; engine.tick(); // Asleep: let the clock die. The next pin re-arms it. if (!engine.awake()) return; renderer.current?.setPositions(engine.positions()); clock.current = requestAnimationFrame(step); }; clock.current = requestAnimationFrame(step); }, []); // `null` means "the user has not touched the expansion" — not "nothing is // expanded". `effectiveView` resolves the difference; see its doc comment. const [state, setState] = useState(null); /** * Bumped by the tuner after each write to `FORCES`. It enters the model memo * below purely as a cache-buster: the graph's *shape* has not changed, so * nothing else would recompute, and the whole point is that the same shape * now lays out differently. */ const [forceRev, setForceRev] = useState(0); /** * Colour nodes by group hue (§15.8). Persisted, because it is a taste the * user holds across sessions rather than a per-visit mode, and read through * the same `PositionStorage` port the layout cache uses so the column still * names no browser global. */ const [groupColors, setGroupColors] = useState(() => loadGroupColors(props.storage)); /** * Whether the tuner panel is open. * * Seeded from the `?sliders=1` flag, then owned by the `[sliders]` chip — so * the URL is still the way to arrive with it open, and the button is the way * to get at it once you are here. The chip is always present: the panel was * unreachable without knowing a query string, which is the right gate for a * half-built instrument and the wrong one for a finished control. */ const [tunerOpen, setTunerOpen] = useState(props.tuner === true); // The shell's decision wins; `schemeOf` stays for a host-driven default. const scheme = props.scheme ?? schemeOf(props.host); const view = effectiveView(props.graph, state); // Memoized for two reasons, one cheap and one load-bearing. Cheap: the // shell re-renders on every editor keystroke and every divider pixel, and // an un-memoized run re-reads `localStorage` and re-parses the position // map for a model nothing uses. Load-bearing: the effects below key on // `model.highlight`'s *identity* — an un-memoized model hands them a fresh // `Set` every render, so typing in the note would repaint the whole WebGL // graph. Identity is the whole contract; do not switch the effect to // comparing set contents, the memo makes comparison unnecessary. const model = useMemo( () => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors), [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceRev, groupColors], ); // Read by the mount-time `onSelect`, which outlives this render. const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId }); live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId }; useEffect(() => { const instance = props.renderer(scheme); instance.onSelect((id) => { const next = graphClick(live.current.view, live.current.model.clusters, id); setState(next.state); live.current.onSelect(next.selectedId); }); // Hover drives the *same* reducers a click does (§7.4): the pointer and // the selection ask one question, and `hoverHighlight` answers it with the // selection's own picture the moment the pointer leaves. // // Straight to the renderer, deliberately not through `useState`: hover // state would enter the `model` memo below and re-derive the layout, // the group colours and the whole `RenderGraph` on every pointer move. instance.onHover((id) => { const { model: current, selectedId } = live.current; // The *drawn* edges, which are exactly the visible ones the column's own // highlight was computed over — a neighbour inside a collapsed cluster // has already been retargeted onto the cluster standing in for it. instance.setHighlight(hoverHighlight(current.graph.edges, id, current.highlight), id ?? selectedId); }); instance.onDragStart((id) => { const at = renderer.current?.positions().get(id); if (at) { dynamics.current?.pin(id, at); // The pin re-heats the engine; if the clock was asleep, wake it. armClock(); } }); instance.onDragMove((id, at) => { dynamics.current?.pin(id, at); }); instance.onDragEnd((id) => { dynamics.current?.release(id); }); instance.mount(canvas.current ?? { clientWidth: 0, clientHeight: 0 }); renderer.current = instance; // A remount from a scheme flip re-runs *this* effect while the effects // below stay keyed on `model.key` / `model.highlight` — which have not // changed, because the theme switch did not touch the graph's shape. The // fresh canvas would therefore sit empty until the next expand, collapse // or selection moved those keys. Push from `live` (current render, not // the render this effect was created in), so a remount carries whatever // the column is already showing. instance.setGraph(live.current.model.graph); instance.setHighlight(live.current.model.highlight, live.current.selectedId); props.fit.current = () => instance.fit(); return () => { instance.destroy(); renderer.current = null; // Cleared, not left dangling: `g` after a breakpoint has unmounted the // graph column would otherwise call `fit` on a destroyed WebGL context. props.fit.current = null; }; }, [props.renderer, scheme]); useEffect(() => { renderer.current?.setGraph(model.graph); }, [model.key, forceRev, groupColors]); // Live layout, in two effects so pause/resume and re-layout are independent. // // Effect 1 (keyed on `model.key`) owns the *simulation*: it is created once // per graph shape, warm-started from the renderer's current positions so a // drag or an expand does not make the graph jump, and disposed when the shape // changes. A fresh engine starts at settle alpha — the "alive" answer to an // expand — so the clock must arm here if it had gone to sleep. useEffect(() => { const engine = createGraphSimulation(model.graph, renderer.current?.positions()); dynamics.current = engine; if (engine !== null) armClock(); return () => { dynamics.current = null; }; }, [model.key, armClock, forceRev]); // Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is // effect 1's, and the step self-terminates whenever the engine settles, so // the only teardown left here is a frame that could outlive the component. useEffect( () => () => { if (clock.current !== null) { cancelAnimationFrame(clock.current); clock.current = null; } }, [], ); useEffect(() => { renderer.current?.setHighlight(model.highlight, props.selectedId); }, [model.highlight, props.selectedId]); return (
{/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`, and what is left here is whether to render the paragraph it returned. */} {model.empty === null ? null :

{model.empty}

} {/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching comment. The tree's target is the rows `
    `, which has its own. */} ); }