/** * The graph column's own state: what is expanded, what is highlighted, and * what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3). * * `graph.model.ts` decides how a *node* is drawn. This module decides what the * *column* is currently showing — which is a different question with a * different lifetime, and keeping them apart is what stops either becoming a * grab-bag: * * ```text * payload + selection + view state ← this module * │ * ▼ clusterAggregate (core) which nodes exist right now * ▼ positions.ts where they are * ▼ graph.model.ts how each one looks * ▼ project.ts → renderer.ts drawn * ``` * * Every branch lives here for §10's reason: sigma needs a canvas, so a * conditional inside the renderer is a conditional no test can reach. * `Graph.tsx` is left with a `useState`, three effects and four handlers. * * ## The §1.3 context bus, from the graph's side * * There is one signal, `selectedId`, and the graph both writes and reads it. * Clicking a node writes it — which drives the note column and the context * rail with no further wiring. Selecting *anywhere* (tree row, wikilink, * context rail) is read back here as {@link highlightFor}, so "selecting in * the tree highlights in the graph" needs no code beyond deriving the * neighbourhood from the same signal. That is §1.3's whole claim, and the * assertion that proves it is a test showing tree-driven and graph-driven * selection produce the identical highlight set. * * ## Tier rules (§2) * * `src/web/client/**`. Core view-models via `../../shared/view` — the one * sanctioned door (§2.1.1), through which P3 brings `clusterAggregate`, * `focusNeighborhood` and `degreeOf`. No DOM type is named and no npm package * is imported, so the root `tsconfig.json` project compiles the tests. */ import type { Point } from "../../shared/layout"; import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view"; import { clusterAggregate, focusNeighborhood } from "../../shared/view"; import type { GraphPayload, WireGraphEdge } from "../../shared/wire"; import { viewModel } from "../tree/tree.model"; import type { ColorScheme, RenderGraph } from "./graph.model"; import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model"; import { groupNodeColors } from "./groups"; import type { PositionStorage } from "./positions"; import { resolveLayout } from "./positions"; // --- the view state ------------------------------------------------------------------ /** * The canvas draws whatever the user has expanded — there is no note cap. * * Clustering is the bound: a collapsed folder is one node standing in for its * whole subtree, and `initialGraphView` opens the roots rather than the tree, * so the default frame stays small at any vault size. A bound on the payload * instead would decide visibility by a note's position in the list, which is * unrelated to what the user is looking at and hides notes inside folders * they explicitly opened. * * If a vault ever does overwhelm the layout, bound the *expansion* (how much * one expand reveals) or the simulation itself. */ /** The graph column's state. Owned by the column; never on the context bus. */ export interface GraphViewState { /** * Cluster ids the user has expanded (§7.4). * * Passed straight to `clusterAggregate`, whose contract is that a node is * visible when every containment ancestor between it and a root is * expanded. An empty set is therefore the maximally collapsed view — the * roots alone — which is what the `[collapse]` control and the tree give * the user. The *default* is the other end: {@link initialGraphView} opens * everything, so the first frame is the graph the tree beside it has * already described. */ readonly expanded: ReadonlySet; } /** * The state a freshly loaded graph opens in: **fully expanded**. * * The alternative was tried and failed: `AUTO_COLLAPSE_ABOVE` opened any * graph over 120 nodes as its bare roots — measured on this repository as * "2 of 237 nodes" — and a pane showing the count while the tree beside it * showed the same knowledge whole reads as an empty column, not a collapsed * one. So the first frame is the whole graph, and §8's layout budget puts the * cost of that at tens of milliseconds; undoing it is the one `[collapse]` * press the control strip already offers. `expanded` still names the clusters * explicitly rather than carrying an "all" flag, because `clusterAggregate` * takes a set and the first thing a user does is collapse one — which has to * leave the others open. * * On cost, measured (244 nodes / 251 edges, ticks: 300): a *cold* cache-miss * layout once blocked the main thread for ~0.4–0.8 s — not "tens of * milliseconds". Two corrections landed: `forceCollide` dropped to one pass * per tick (~45 % faster), and the honest number is now on the record here. * `warm` re-layouts stay in the ~250 ms range; the `[collapse]` control * remains the escape hatch for everything. */ export function initialGraphView(model: ViewGraphModel): GraphViewState { return { expanded: new Set(clusterAggregate(model, new Set()).clusters.keys()) }; } /** * The state to render with: the user's, or the default for this payload. * * The column holds `GraphViewState | null` and `null` means "the user has not * touched the expansion yet" — which is not the same as "nothing is expanded", * because {@link initialGraphView} opens a small graph whole. Resolving that * here rather than in the component keeps a real branch out of a `.tsx` (§10) * and, more importantly, makes the rule testable: a refetch must **not** reset * an expansion the user has chosen, and the watcher fires on every file save. */ export function effectiveView(payload: GraphPayload | null, state: GraphViewState | null): GraphViewState { if (state !== null) return state; if (payload === null) return { expanded: new Set() }; return initialGraphView(viewModel(payload)); } /** * Open or close one cluster. Returns a new state. * * The `[expand]` / `[collapse]` control that used to pair with this is gone * (§15.10): the graph opens fully expanded, so "expand" was a no-op on arrival * and "collapse" threw the whole picture away to show two root nodes. Clicking * a collapsed cluster still opens it — that is `graphClick` — and per-level * walking is what the tree column is for. */ export function toggleCluster(state: GraphViewState, id: string): GraphViewState { const expanded = new Set(state.expanded); if (!expanded.delete(id)) expanded.add(id); return { ...state, expanded }; } // --- the highlight (§1.3, §7.4) --------------------------------------------------------- /** * The set of ids that stay lit when `selectedId` is selected. * * `focusNeighborhood` **verbatim** — core's function, the same one the * context rail's "Related" is built from, reached through the §2.1.1 door * rather than reimplemented. (The `[depth 1 ▾]` control once let the user * widen this to two and three hops; it read as decoration next to the rail's * fixed-depth "Related" and is gone — one definition of "related", one * behaviour everywhere.) * * `null` in, `null` out: nothing selected means no highlight at all, which the * reducers treat as "render everything normally" — deliberately not the same * as an empty set (see `graph.model.ts`). * * Computed over the **currently visible** edges, not the full model. A * neighbour inside a collapsed cluster is not on screen, and `clusterAggregate` * has already retargeted the edge onto the cluster standing in for it — so * highlighting the hidden id would light nothing while leaving the cluster * that actually represents it dimmed. */ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null): Set | null { if (selectedId === null) return null; return focusNeighborhood(selectedId, edges); } /** * The highlight while the pointer is over a node — **hover wins, selection is * what it falls back to**. * * Obsidian's gesture, and the reason it is the same function as * {@link highlightFor} rather than a second visual language: hovering asks * exactly the question clicking asks ("what is one hop from here?"), so the * two must produce the same set and reach the same reducers. Anything else * drifts into a graph where the pointer and the click disagree about what * "related" means. * * `fallback` is the selection's highlight, precomputed by the column — passed * rather than recomputed so leaving a node restores the selection's picture * without the caller having to remember what it was. `null` hovered and * `null` fallback is nothing selected and nothing hovered, which the reducers * read as "render everything normally". */ export function hoverHighlight( edges: readonly WireGraphEdge[], hoveredId: string | null, fallback: Set | null, ): Set | null { return hoveredId === null ? fallback : focusNeighborhood(hoveredId, edges); } // --- the control strip (§1.2) --------------------------------------------------------------- /** The `[fit]` control. Constant, but named here so the component holds no copy. */ export const FIT_LABEL = "fit"; export const FIT_HINT = "frame the whole graph"; /** * The `[sliders]` control, which shows and hides the tuner panel (§15.7). * * Labelled for what the button *opens* rather than for what the panel edits: * "forces" names the physics, which is the one thing a reader who has not read * `layout.ts` has no word for. The constants keep the `FORCES_` prefix because * the module they drive is still the force layout. */ export const FORCES_LABEL = "sliders"; export const FORCES_HINT = "tune the layout physics and colours"; /** * The legend under the canvas, from the §1.2 mock: * `◉ selected ● neighborhood`. */ export const LEGEND = { selected: "selected", neighborhood: "neighborhood", dimmed: "not related" } as const; /** * The count line: what is on screen out of what exists. * * Both numbers, always. "89 nodes" next to a canvas showing 5 is the reading * that makes a collapsed graph look broken; "5 of 89 nodes" makes it obvious * that there is more behind the clusters. */ export function graphCountLabel(visible: number, total: number): string { const noun = total === 1 ? "node" : "nodes"; return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`; } /** What the column says instead of a canvas. `null` means "there is a graph". */ export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null { if (payload === null) return "Loading…"; if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan."; // A payload with nodes but nothing visible cannot happen through the UI // (`clusterAggregate` always keeps the roots), but a truncated response // could produce it, and a blank canvas with no explanation reads as a crash. return visible === 0 ? "Nothing to draw at this expansion." : null; } /** * The boot-failure sentence, and why it is not a branch of * {@link graphEmptyMessage}: that function answers "the graph has no shape to * draw", and its callers reach it with a `payload` that may be `null` only at * boot. A failed boot is a different *fact* — the server was reached and * could not answer (or could not be reached), while the socket's `offline` * wording may or may not also apply — so it gets its own sentence and its own * boolean. Suggesting a named action matters more here than anywhere else: * a failure at boot is the workspace's first impression. */ export const BOOT_FAILED_MESSAGE = "The workspace did not load — ⟳ retries now, and the next change to the vault retries on its own."; // --- the whole column state ----------------------------------------------------------------- /** Everything the column needs to render one frame. */ export interface GraphColumnModel { /** What sigma draws. */ readonly graph: RenderGraph; /** What the reducers dim outside of. `null` when nothing is selected. */ readonly highlight: Set | null; /** The layout's cache key, so the caller can tell one shape from another. */ readonly key: string; /** True when the layout came from `localStorage` rather than the simulation. */ readonly cached: boolean; /** Every cluster in the *unreduced* model — what `[expand]` operates on. */ readonly clusters: ReadonlyMap; /** Visible / total, for the count line. */ readonly visible: number; readonly total: number; /** Non-null when there is nothing to draw. */ readonly empty: string | null; } /** The column with nothing in it. What the first render produces. */ export const EMPTY_COLUMN: GraphColumnModel = { graph: EMPTY_RENDER_GRAPH, highlight: null, key: "", cached: false, clusters: new Map(), visible: 0, total: 0, empty: "Loading…", }; /** * Reduce, lay out, resolve and highlight — one frame of the graph column. * * The order is the design, and each step depends on the one before it: * * 1. **Reduce** (`clusterAggregate`, core). Collapsed subtrees become their * parent and boundary-crossing edges are retargeted onto it. The renderer * is never handed a node it is expected to hide (§7.4). * 2. **Lay out** the *reduced* graph. Laying out the full model and then * hiding nodes would leave the visible ones spread across the holes the * hidden ones left — the collapsed view would have the geometry of the * expanded one, which is the opposite of the point. * 3. **Resolve** to a `RenderGraph`. * 4. **Highlight** over the reduced edges, so a neighbour inside a collapsed * cluster lights the cluster rather than nothing. * * Step 2 is also why the layout cache is keyed by shape: every expand and * collapse produces a genuinely different graph, gets its own key, and is * remembered independently — so collapsing and re-expanding returns the * arrangement the user had, rather than a fresh one. */ export function graphColumnModel( payload: GraphPayload | null, selectedId: string | null, state: GraphViewState, storage: PositionStorage, scheme: ColorScheme, // Optional so every existing caller and test keeps its shape; only the // shell's boot-failure signal has a reason to pass it. bootFailed = false, /** * Colour nodes by their group's hue rather than by kind (§15.8). * * A parameter rather than a constant because it is a taste decision the * user owns — the kind palette is three greys and an accent, which is calm * but says nothing about which blob is which. Defaults to `true`: the * grouping is what the forces went to the trouble of separating, so leaving * it uncoloured by default would waste the layout. */ groupColors = true, ): GraphColumnModel { if (payload === null) // Identity preserved on the ordinary path (`EMPTY_COLUMN` is compared by // callers); only a failed boot earns a fresh one, with its own sentence. return bootFailed ? { ...EMPTY_COLUMN, empty: BOOT_FAILED_MESSAGE } : EMPTY_COLUMN; const model = viewModel(payload); const reduced: ClusterAggregate = clusterAggregate(model, state.expanded); const vaultOpen = state.expanded.has("vault"); const edges = vaultOpen ? reduced.edges.filter((edge) => edge.source !== "vault" && edge.target !== "vault") : reduced.edges; const layout = resolveLayout(storage, reduced.nodes, edges); // Over the *reduced* nodes and the same edges the layout used, so a // collapsed cluster is coloured by the group it stands in for rather than // by a branch that is not on screen. const fills = groupColors ? groupNodeColors(reduced.nodes, edges, scheme) : undefined; return { graph: renderGraph(reduced.nodes, edges, layout.positions, scheme, fills), highlight: highlightFor(edges, selectedId), key: layout.key, cached: layout.cached, clusters: reduced.clusters, visible: reduced.nodes.length, total: model.nodes.length, empty: graphEmptyMessage(payload, reduced.nodes.length), }; } // --- clicking a node ------------------------------------------------------------------------- /** What a click on the canvas produced. */ export interface GraphClick { /** The next view state — changed only when a collapsed cluster was opened. */ readonly state: GraphViewState; /** What to write to `selectedId`. Always set, even when a cluster expanded. */ readonly selectedId: string | null; } /** * Apply a click on a node (or on empty stage, with `null`). * * A click does **two** things when it lands on a collapsed cluster: it selects * it *and* it expands it (§7.4, "expand on click"). Both, deliberately — the * selection is what drives the note column and the context rail, and dropping * it in favour of the expansion would make clicking a module the one gesture * in the workspace that does not update the other columns. * * A click on an already-expanded cluster selects without collapsing. Collapse * is the `[expand]` control and the tree; making a second click close a * cluster would make it impossible to select one and read it. */ export function graphClick(state: GraphViewState, clusters: ReadonlyMap, id: string | null): GraphClick { if (id === null) return { state, selectedId: null }; const cluster = clusters.get(id); if (cluster === undefined || state.expanded.has(id)) return { state, selectedId: id }; return { state: toggleCluster(state, id), selectedId: id }; } /* * There were two tooltip builders here — `nodeTooltip` (label · N links · N * hidden) and `clusterBadge`. Nothing ever rendered either one; they were the * remains of a hover pass that never reached the canvas, and the hover * gesture now answers the same question by *lighting the neighbourhood* and * floating the node's own name (see `graph.model.ts`'s `drawHoverLabel`), * which says "N links" by showing them. Deleted rather than left as covered * dead code. */ /** Positions keyed by id, for a caller warm-starting a re-run. */ export type LayoutSnapshot = ReadonlyMap;