pi-weave 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph column (weave-workspace §1.2, §7, P3).
|
|
3
|
+
*
|
|
4
|
+
* Props in, JSX out, and one `ref` handed to a renderer. Every string, every
|
|
5
|
+
* branch and every set comes from `column.model.ts`, `graph.model.ts` or
|
|
6
|
+
* `positions.ts`; what is left here is a `useState`, three effects and four
|
|
7
|
+
* handlers. §10's rule, and the reason the 95 % gate survives a phase whose
|
|
8
|
+
* whole deliverable is a WebGL canvas.
|
|
9
|
+
*
|
|
10
|
+
* The three effects, all one-liners over injected units:
|
|
11
|
+
*
|
|
12
|
+
* 1. **mount** — build a renderer, attach it to the `<div>`, subscribe to
|
|
13
|
+
* selection. The returned `destroy` is the cleanup, so a hot reload cannot
|
|
14
|
+
* leak a WebGL context.
|
|
15
|
+
* 2. **graph** — push the drawn model whenever the reduction or the layout
|
|
16
|
+
* changes. Keyed on `model.key`, which is the *shape* digest, so an SSE
|
|
17
|
+
* tick that changed a note's body does not re-upload the graph (§7.3:
|
|
18
|
+
* re-run on drag or expand/collapse, not every frame).
|
|
19
|
+
* 3. **highlight** — push the neighbourhood whenever the selection moves.
|
|
20
|
+
* This is the §1.3 bus arriving from the tree, a wikilink or the context
|
|
21
|
+
* rail; the graph does not care which, and that is the whole mechanism
|
|
22
|
+
* behind "selecting anywhere highlights everywhere".
|
|
23
|
+
*
|
|
24
|
+
* ## The `live` ref
|
|
25
|
+
*
|
|
26
|
+
* The renderer's `onSelect` is registered once at mount and outlives every
|
|
27
|
+
* render, so a handler that closed over `view` and `model` directly would keep
|
|
28
|
+
* answering with the state as it was at mount. The same `live.current` pattern
|
|
29
|
+
* `Shell.tsx` uses for its drag handlers, for the same reason.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
33
|
+
import type { GraphPayload } from "../../shared/wire";
|
|
34
|
+
import type { GraphViewState } from "./column.model";
|
|
35
|
+
import {
|
|
36
|
+
DEPTHS,
|
|
37
|
+
FIT_HINT,
|
|
38
|
+
FIT_LABEL,
|
|
39
|
+
LEGEND,
|
|
40
|
+
allExpanded,
|
|
41
|
+
depthHint,
|
|
42
|
+
depthLabel,
|
|
43
|
+
effectiveView,
|
|
44
|
+
expandHint,
|
|
45
|
+
expandLabel,
|
|
46
|
+
graphClick,
|
|
47
|
+
graphColumnModel,
|
|
48
|
+
graphCountLabel,
|
|
49
|
+
parseDepth,
|
|
50
|
+
setDepth,
|
|
51
|
+
toggleExpandAll,
|
|
52
|
+
} from "./column.model";
|
|
53
|
+
import type { PositionStorage } from "./positions";
|
|
54
|
+
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
55
|
+
import { schemeOf } from "./scheme";
|
|
56
|
+
import type { SchemeHost } from "./scheme";
|
|
57
|
+
|
|
58
|
+
export interface GraphProps {
|
|
59
|
+
graph: GraphPayload | null;
|
|
60
|
+
selectedId: string | null;
|
|
61
|
+
onSelect: (id: string | null) => void;
|
|
62
|
+
/** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
|
|
63
|
+
renderer: RendererFactory;
|
|
64
|
+
/** Injected: `localStorage`. See `positions.ts` for why it is a port. */
|
|
65
|
+
storage: PositionStorage;
|
|
66
|
+
/** Injected: `window`. Read once, for `prefers-color-scheme`. */
|
|
67
|
+
host: SchemeHost;
|
|
68
|
+
/**
|
|
69
|
+
* A slot the column writes its `fit` into, so the global `g` key can reach
|
|
70
|
+
* it (§11 P4).
|
|
71
|
+
*
|
|
72
|
+
* A ref rather than lifting the renderer into the shell: the renderer is
|
|
73
|
+
* created, owned and destroyed by the mount effect below, and hoisting it
|
|
74
|
+
* would move a WebGL lifecycle out of the one component that can see the
|
|
75
|
+
* `<div>` it attaches to. A slot the column fills and clears on unmount
|
|
76
|
+
* keeps the ownership where it is and costs one line at each end.
|
|
77
|
+
*/
|
|
78
|
+
fit: { current: (() => void) | null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function Graph(props: GraphProps) {
|
|
82
|
+
const canvas = useRef<HTMLDivElement | null>(null);
|
|
83
|
+
const renderer = useRef<GraphRenderer | null>(null);
|
|
84
|
+
// `null` means "the user has not touched the expansion" — not "nothing is
|
|
85
|
+
// expanded". `effectiveView` resolves the difference; see its doc comment.
|
|
86
|
+
const [state, setState] = useState<GraphViewState | null>(null);
|
|
87
|
+
|
|
88
|
+
const scheme = useMemo(() => schemeOf(props.host), [props.host]);
|
|
89
|
+
const view = effectiveView(props.graph, state);
|
|
90
|
+
const model = graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme);
|
|
91
|
+
const everything = allExpanded(view, model.clusters);
|
|
92
|
+
|
|
93
|
+
// Read by the mount-time `onSelect`, which outlives this render.
|
|
94
|
+
const live = useRef({ view, model, onSelect: props.onSelect });
|
|
95
|
+
live.current = { view, model, onSelect: props.onSelect };
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const instance = props.renderer(scheme);
|
|
99
|
+
instance.onSelect((id) => {
|
|
100
|
+
const next = graphClick(live.current.view, live.current.model.clusters, id);
|
|
101
|
+
setState(next.state);
|
|
102
|
+
live.current.onSelect(next.selectedId);
|
|
103
|
+
});
|
|
104
|
+
instance.mount(canvas.current ?? { clientWidth: 0, clientHeight: 0 });
|
|
105
|
+
renderer.current = instance;
|
|
106
|
+
props.fit.current = () => instance.fit();
|
|
107
|
+
return () => {
|
|
108
|
+
instance.destroy();
|
|
109
|
+
renderer.current = null;
|
|
110
|
+
// Cleared, not left dangling: `g` after a breakpoint has unmounted the
|
|
111
|
+
// graph column would otherwise call `fit` on a destroyed WebGL context.
|
|
112
|
+
props.fit.current = null;
|
|
113
|
+
};
|
|
114
|
+
}, [props.renderer, scheme]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
renderer.current?.setGraph(model.graph);
|
|
118
|
+
}, [model.key]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
renderer.current?.setHighlight(model.highlight);
|
|
122
|
+
}, [model.highlight]);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div class="weave-graph">
|
|
126
|
+
{/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
|
|
127
|
+
and what is left here is whether to render the paragraph it returned. */}
|
|
128
|
+
{model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
|
|
129
|
+
<div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" />
|
|
130
|
+
<div class="weave-graph-controls">
|
|
131
|
+
<button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
|
|
132
|
+
{FIT_LABEL}
|
|
133
|
+
</button>
|
|
134
|
+
<button type="button" class="weave-chip" title={expandHint(everything)} onClick={() => setState(toggleExpandAll(view, model.clusters))}>
|
|
135
|
+
{expandLabel(everything)}
|
|
136
|
+
</button>
|
|
137
|
+
<select
|
|
138
|
+
class="weave-chip weave-depth"
|
|
139
|
+
title={depthHint(view.depth)}
|
|
140
|
+
aria-label="Highlight depth"
|
|
141
|
+
value={String(view.depth)}
|
|
142
|
+
onChange={(event) => setState(setDepth(view, parseDepth(event.currentTarget.value, view.depth)))}
|
|
143
|
+
>
|
|
144
|
+
{DEPTHS.map((depth) => (
|
|
145
|
+
<option key={depth} value={String(depth)}>
|
|
146
|
+
{depthLabel(depth)}
|
|
147
|
+
</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
<span class="weave-graph-legend">
|
|
151
|
+
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
152
|
+
<span class="weave-legend-near">● {LEGEND.neighborhood}</span>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph column's own state: what is expanded, what is highlighted, and
|
|
3
|
+
* what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3).
|
|
4
|
+
*
|
|
5
|
+
* `graph.model.ts` decides how a *node* is drawn. This module decides what the
|
|
6
|
+
* *column* is currently showing — which is a different question with a
|
|
7
|
+
* different lifetime, and keeping them apart is what stops either becoming a
|
|
8
|
+
* grab-bag:
|
|
9
|
+
*
|
|
10
|
+
* ```text
|
|
11
|
+
* payload + selection + view state ← this module
|
|
12
|
+
* │
|
|
13
|
+
* ▼ clusterAggregate (core) which nodes exist right now
|
|
14
|
+
* ▼ positions.ts where they are
|
|
15
|
+
* ▼ graph.model.ts how each one looks
|
|
16
|
+
* ▼ project.ts → renderer.ts drawn
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Every branch lives here for §10's reason: sigma needs a canvas, so a
|
|
20
|
+
* conditional inside the renderer is a conditional no test can reach.
|
|
21
|
+
* `Graph.tsx` is left with a `useState`, three effects and four handlers.
|
|
22
|
+
*
|
|
23
|
+
* ## The §1.3 context bus, from the graph's side
|
|
24
|
+
*
|
|
25
|
+
* There is one signal, `selectedId`, and the graph both writes and reads it.
|
|
26
|
+
* Clicking a node writes it — which drives the note column and the context
|
|
27
|
+
* rail with no further wiring. Selecting *anywhere* (tree row, wikilink,
|
|
28
|
+
* context rail) is read back here as {@link highlightFor}, so "selecting in
|
|
29
|
+
* the tree highlights in the graph" needs no code beyond deriving the
|
|
30
|
+
* neighbourhood from the same signal. That is §1.3's whole claim, and the
|
|
31
|
+
* assertion that proves it is a test showing tree-driven and graph-driven
|
|
32
|
+
* selection produce the identical highlight set.
|
|
33
|
+
*
|
|
34
|
+
* ## Tier rules (§2)
|
|
35
|
+
*
|
|
36
|
+
* `src/web/client/**`. Core view-models via `../../shared/view` — the one
|
|
37
|
+
* sanctioned door (§2.1.1), through which P3 brings `clusterAggregate`,
|
|
38
|
+
* `focusNeighborhood` and `degreeOf`. No DOM type is named and no npm package
|
|
39
|
+
* is imported, so the root `tsconfig.json` project compiles the tests.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { Point } from "../../shared/layout";
|
|
43
|
+
import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
|
|
44
|
+
import { clusterAggregate, degreeOf, focusNeighborhood } from "../../shared/view";
|
|
45
|
+
import type { GraphPayload, WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
46
|
+
import { viewModel } from "../tree/tree.model";
|
|
47
|
+
import type { ColorScheme, RenderGraph } from "./graph.model";
|
|
48
|
+
import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
|
|
49
|
+
import type { PositionStorage } from "./positions";
|
|
50
|
+
import { resolveLayout } from "./positions";
|
|
51
|
+
|
|
52
|
+
// --- the view state ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How many hops of neighbourhood the highlight covers.
|
|
56
|
+
*
|
|
57
|
+
* The `[depth 1 ▾]` control from the §1.2 mock. 1 is core's
|
|
58
|
+
* `focusNeighborhood` verbatim; 2 and 3 are that set expanded by re-applying
|
|
59
|
+
* it, which is the only definition that cannot disagree with the context
|
|
60
|
+
* rail's at depth 1.
|
|
61
|
+
*
|
|
62
|
+
* Capped at 3 deliberately. On this repository's shape the 60-child hub is two
|
|
63
|
+
* hops from most of the graph, so depth 4 highlights nearly everything and a
|
|
64
|
+
* highlight that covers everything conveys nothing. A number rather than a
|
|
65
|
+
* boolean because "just the neighbours" and "the neighbourhood around this
|
|
66
|
+
* module" are genuinely different questions.
|
|
67
|
+
*/
|
|
68
|
+
export type Depth = 1 | 2 | 3;
|
|
69
|
+
|
|
70
|
+
/** Every depth the control offers, in order. */
|
|
71
|
+
export const DEPTHS: readonly Depth[] = [1, 2, 3];
|
|
72
|
+
|
|
73
|
+
/** The graph column's state. Owned by the column; never on the context bus. */
|
|
74
|
+
export interface GraphViewState {
|
|
75
|
+
/**
|
|
76
|
+
* Cluster ids the user has expanded (§7.4).
|
|
77
|
+
*
|
|
78
|
+
* Passed straight to `clusterAggregate`, whose contract is that a node is
|
|
79
|
+
* visible when every containment ancestor between it and a root is
|
|
80
|
+
* expanded. An empty set is therefore the maximally collapsed view — the
|
|
81
|
+
* roots alone — which is the right first frame for a repository with
|
|
82
|
+
* thousands of files and the wrong one for a vault with nine notes. See
|
|
83
|
+
* {@link initialGraphView}.
|
|
84
|
+
*/
|
|
85
|
+
readonly expanded: ReadonlySet<string>;
|
|
86
|
+
/** How far the highlight reaches. */
|
|
87
|
+
readonly depth: Depth;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many nodes a graph may have before it opens collapsed.
|
|
92
|
+
*
|
|
93
|
+
* Not a preference — a legibility bound. Above roughly this many, a
|
|
94
|
+
* force-directed layout of a containment tree is a disc of overlapping labels
|
|
95
|
+
* whichever way you draw it, and the honest first frame is the five clusters
|
|
96
|
+
* with their counts. Below it, collapsing hides a graph the user could simply
|
|
97
|
+
* have read.
|
|
98
|
+
*
|
|
99
|
+
* 120 is the point at which this repository's own fixture (89 nodes) still
|
|
100
|
+
* opens whole while a real `src/` scan (hundreds of files) does not.
|
|
101
|
+
*/
|
|
102
|
+
export const AUTO_COLLAPSE_ABOVE = 120;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The state a freshly loaded graph opens in.
|
|
106
|
+
*
|
|
107
|
+
* Everything expanded for a small graph, nothing for a large one. `expanded`
|
|
108
|
+
* has to name the clusters explicitly rather than carry an "all" flag, because
|
|
109
|
+
* `clusterAggregate` takes a set and the first thing a user does is collapse
|
|
110
|
+
* one — which has to leave the others open.
|
|
111
|
+
*/
|
|
112
|
+
export function initialGraphView(model: ViewGraphModel): GraphViewState {
|
|
113
|
+
const expanded =
|
|
114
|
+
model.nodes.length > AUTO_COLLAPSE_ABOVE ? new Set<string>() : new Set(clusterAggregate(model, new Set()).clusters.keys());
|
|
115
|
+
return { expanded, depth: 1 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The state to render with: the user's, or the default for this payload.
|
|
120
|
+
*
|
|
121
|
+
* The column holds `GraphViewState | null` and `null` means "the user has not
|
|
122
|
+
* touched the expansion yet" — which is not the same as "nothing is expanded",
|
|
123
|
+
* because {@link initialGraphView} opens a small graph whole. Resolving that
|
|
124
|
+
* here rather than in the component keeps a real branch out of a `.tsx` (§10)
|
|
125
|
+
* and, more importantly, makes the rule testable: a refetch must **not** reset
|
|
126
|
+
* an expansion the user has chosen, and the watcher fires on every file save.
|
|
127
|
+
*/
|
|
128
|
+
export function effectiveView(payload: GraphPayload | null, state: GraphViewState | null): GraphViewState {
|
|
129
|
+
if (state !== null) return state;
|
|
130
|
+
if (payload === null) return { expanded: new Set(), depth: 1 };
|
|
131
|
+
return initialGraphView(viewModel(payload));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Open or close one cluster. Returns a new state; see {@link expandAll}. */
|
|
135
|
+
export function toggleCluster(state: GraphViewState, id: string): GraphViewState {
|
|
136
|
+
const expanded = new Set(state.expanded);
|
|
137
|
+
if (!expanded.delete(id)) expanded.add(id);
|
|
138
|
+
return { ...state, expanded };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Open every cluster. The `[expand]` control from the §1.2 mock.
|
|
143
|
+
*
|
|
144
|
+
* `clusters` is `ClusterAggregate.clusters`, which holds **every** node with a
|
|
145
|
+
* containment child whether or not it is currently visible — so one press
|
|
146
|
+
* opens the whole tree rather than one level of it. That is deliberate: the
|
|
147
|
+
* per-level walk is what the tree column is for, and a graph control that
|
|
148
|
+
* needed six presses to show the graph would be a worse version of it.
|
|
149
|
+
*/
|
|
150
|
+
export function expandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
|
|
151
|
+
return { ...state, expanded: new Set(clusters.keys()) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Close every cluster, back to the roots. The other half of `[expand]`. */
|
|
155
|
+
export function collapseAll(state: GraphViewState): GraphViewState {
|
|
156
|
+
return { ...state, expanded: new Set() };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Set the highlight depth. */
|
|
160
|
+
export function setDepth(state: GraphViewState, depth: Depth): GraphViewState {
|
|
161
|
+
return state.depth === depth ? state : { ...state, depth };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- the highlight (§1.3, §7.4) ---------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The set of ids that stay lit when `selectedId` is selected.
|
|
168
|
+
*
|
|
169
|
+
* Depth 1 is `focusNeighborhood` **exactly** — core's function, the same one
|
|
170
|
+
* the context rail's "Related" is built from, reached through the §2.1.1 door
|
|
171
|
+
* rather than reimplemented. Deeper is that operation applied again, which is
|
|
172
|
+
* the only definition of "2 hops" that cannot disagree with "1 hop" at the
|
|
173
|
+
* boundary.
|
|
174
|
+
*
|
|
175
|
+
* `null` in, `null` out: nothing selected means no highlight at all, which the
|
|
176
|
+
* reducers treat as "render everything normally" — deliberately not the same
|
|
177
|
+
* as an empty set (see `graph.model.ts`).
|
|
178
|
+
*
|
|
179
|
+
* Computed over the **currently visible** edges, not the full model. A
|
|
180
|
+
* neighbour inside a collapsed cluster is not on screen, and `clusterAggregate`
|
|
181
|
+
* has already retargeted the edge onto the cluster standing in for it — so
|
|
182
|
+
* highlighting the hidden id would light nothing while leaving the cluster
|
|
183
|
+
* that actually represents it dimmed.
|
|
184
|
+
*/
|
|
185
|
+
export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null, depth: Depth): Set<string> | null {
|
|
186
|
+
if (selectedId === null) return null;
|
|
187
|
+
let frontier = focusNeighborhood(selectedId, edges);
|
|
188
|
+
for (let hop = 1; hop < depth; hop++) {
|
|
189
|
+
const next = new Set(frontier);
|
|
190
|
+
for (const id of frontier) for (const neighbour of focusNeighborhood(id, edges)) next.add(neighbour);
|
|
191
|
+
// A neighbourhood that stopped growing has reached its whole connected
|
|
192
|
+
// component, so further hops cannot add anything and the loop is waste.
|
|
193
|
+
if (next.size === frontier.size) break;
|
|
194
|
+
frontier = next;
|
|
195
|
+
}
|
|
196
|
+
return frontier;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- the control strip (§1.2) ---------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/** The `[depth 1 ▾]` control's label. */
|
|
202
|
+
export function depthLabel(depth: Depth): string {
|
|
203
|
+
return `depth ${depth}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Its tooltip — says what the number means, not what it is. */
|
|
207
|
+
export function depthHint(depth: Depth): string {
|
|
208
|
+
return depth === 1
|
|
209
|
+
? "highlighting direct neighbours of the selection"
|
|
210
|
+
: `highlighting everything within ${depth} hops of the selection`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The `[expand]` control's label, which is really a toggle. */
|
|
214
|
+
export function expandLabel(allExpanded: boolean): string {
|
|
215
|
+
return allExpanded ? "collapse" : "expand";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Its tooltip. */
|
|
219
|
+
export function expandHint(allExpanded: boolean): string {
|
|
220
|
+
return allExpanded ? "collapse every cluster back to the roots" : "expand every cluster";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether every cluster in the graph is currently open. */
|
|
224
|
+
export function allExpanded(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): boolean {
|
|
225
|
+
if (clusters.size === 0) return false;
|
|
226
|
+
for (const id of clusters.keys()) if (!state.expanded.has(id)) return false;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What the `[expand]` control does when pressed.
|
|
232
|
+
*
|
|
233
|
+
* One function rather than a ternary in the component, for §10's reason: the
|
|
234
|
+
* choice between expanding and collapsing is a branch, and a branch in a
|
|
235
|
+
* `.tsx` is a branch no test can reach. It is also the *only* place the
|
|
236
|
+
* toggle's two halves are paired, so its label and its effect cannot disagree
|
|
237
|
+
* — {@link expandLabel} reads the same predicate.
|
|
238
|
+
*/
|
|
239
|
+
export function toggleExpandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
|
|
240
|
+
return allExpanded(state, clusters) ? collapseAll(state) : expandAll(state, clusters);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse the depth `<select>`'s value.
|
|
245
|
+
*
|
|
246
|
+
* A `<select>` yields a string and the component must not cast it — an
|
|
247
|
+
* `as Depth` on unvalidated input is exactly how a `depth 7` ends up in state
|
|
248
|
+
* and the highlight silently covers the whole graph. Anything unrecognised
|
|
249
|
+
* falls back to the current depth, so a malformed event changes nothing.
|
|
250
|
+
*/
|
|
251
|
+
export function parseDepth(value: string, fallback: Depth): Depth {
|
|
252
|
+
const parsed = Number(value);
|
|
253
|
+
return (DEPTHS as readonly number[]).includes(parsed) ? (parsed as Depth) : fallback;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The `[fit]` control. Constant, but named here so the component holds no copy. */
|
|
257
|
+
export const FIT_LABEL = "fit";
|
|
258
|
+
export const FIT_HINT = "frame the whole graph";
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The legend under the canvas, from the §1.2 mock:
|
|
262
|
+
* `◉ selected ● neighborhood`.
|
|
263
|
+
*/
|
|
264
|
+
export const LEGEND = { selected: "selected", neighborhood: "neighborhood", dimmed: "not related" } as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The count line: what is on screen out of what exists.
|
|
268
|
+
*
|
|
269
|
+
* Both numbers, always. "89 nodes" next to a canvas showing 5 is the reading
|
|
270
|
+
* that makes a collapsed graph look broken; "5 of 89 nodes" makes it obvious
|
|
271
|
+
* that there is more behind the clusters.
|
|
272
|
+
*/
|
|
273
|
+
export function graphCountLabel(visible: number, total: number): string {
|
|
274
|
+
const noun = total === 1 ? "node" : "nodes";
|
|
275
|
+
return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** What the column says instead of a canvas. `null` means "there is a graph". */
|
|
279
|
+
export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
|
|
280
|
+
if (payload === null) return "Loading…";
|
|
281
|
+
if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan.";
|
|
282
|
+
// A payload with nodes but nothing visible cannot happen through the UI
|
|
283
|
+
// (`clusterAggregate` always keeps the roots), but a truncated response
|
|
284
|
+
// could produce it, and a blank canvas with no explanation reads as a crash.
|
|
285
|
+
return visible === 0 ? "Nothing to draw at this expansion." : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- the whole column state -----------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/** Everything the column needs to render one frame. */
|
|
291
|
+
export interface GraphColumnModel {
|
|
292
|
+
/** What sigma draws. */
|
|
293
|
+
readonly graph: RenderGraph;
|
|
294
|
+
/** What the reducers dim outside of. `null` when nothing is selected. */
|
|
295
|
+
readonly highlight: Set<string> | null;
|
|
296
|
+
/** The layout's cache key, so the caller can tell one shape from another. */
|
|
297
|
+
readonly key: string;
|
|
298
|
+
/** True when the layout came from `localStorage` rather than the simulation. */
|
|
299
|
+
readonly cached: boolean;
|
|
300
|
+
/** Every cluster in the *unreduced* model — what `[expand]` operates on. */
|
|
301
|
+
readonly clusters: ReadonlyMap<string, ClusterInfo>;
|
|
302
|
+
/** Visible / total, for the count line. */
|
|
303
|
+
readonly visible: number;
|
|
304
|
+
readonly total: number;
|
|
305
|
+
/** Non-null when there is nothing to draw. */
|
|
306
|
+
readonly empty: string | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The column with nothing in it. What the first render produces. */
|
|
310
|
+
export const EMPTY_COLUMN: GraphColumnModel = {
|
|
311
|
+
graph: EMPTY_RENDER_GRAPH,
|
|
312
|
+
highlight: null,
|
|
313
|
+
key: "",
|
|
314
|
+
cached: false,
|
|
315
|
+
clusters: new Map(),
|
|
316
|
+
visible: 0,
|
|
317
|
+
total: 0,
|
|
318
|
+
empty: "Loading…",
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reduce, lay out, resolve and highlight — one frame of the graph column.
|
|
323
|
+
*
|
|
324
|
+
* The order is the design, and each step depends on the one before it:
|
|
325
|
+
*
|
|
326
|
+
* 1. **Reduce** (`clusterAggregate`, core). Collapsed subtrees become their
|
|
327
|
+
* parent and boundary-crossing edges are retargeted onto it. The renderer
|
|
328
|
+
* is never handed a node it is expected to hide (§7.4).
|
|
329
|
+
* 2. **Lay out** the *reduced* graph. Laying out the full model and then
|
|
330
|
+
* hiding nodes would leave the visible ones spread across the holes the
|
|
331
|
+
* hidden ones left — the collapsed view would have the geometry of the
|
|
332
|
+
* expanded one, which is the opposite of the point.
|
|
333
|
+
* 3. **Resolve** to a `RenderGraph`.
|
|
334
|
+
* 4. **Highlight** over the reduced edges, so a neighbour inside a collapsed
|
|
335
|
+
* cluster lights the cluster rather than nothing.
|
|
336
|
+
*
|
|
337
|
+
* Step 2 is also why the layout cache is keyed by shape: every expand and
|
|
338
|
+
* collapse produces a genuinely different graph, gets its own key, and is
|
|
339
|
+
* remembered independently — so collapsing and re-expanding returns the
|
|
340
|
+
* arrangement the user had, rather than a fresh one.
|
|
341
|
+
*/
|
|
342
|
+
export function graphColumnModel(
|
|
343
|
+
payload: GraphPayload | null,
|
|
344
|
+
selectedId: string | null,
|
|
345
|
+
state: GraphViewState,
|
|
346
|
+
storage: PositionStorage,
|
|
347
|
+
scheme: ColorScheme,
|
|
348
|
+
): GraphColumnModel {
|
|
349
|
+
if (payload === null) return EMPTY_COLUMN;
|
|
350
|
+
|
|
351
|
+
const model = viewModel(payload);
|
|
352
|
+
const reduced: ClusterAggregate = clusterAggregate(model, state.expanded);
|
|
353
|
+
const layout = resolveLayout(storage, reduced.nodes, reduced.edges);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
graph: renderGraph(reduced.nodes, reduced.edges, layout.positions, scheme),
|
|
357
|
+
highlight: highlightFor(reduced.edges, selectedId, state.depth),
|
|
358
|
+
key: layout.key,
|
|
359
|
+
cached: layout.cached,
|
|
360
|
+
clusters: reduced.clusters,
|
|
361
|
+
visible: reduced.nodes.length,
|
|
362
|
+
total: model.nodes.length,
|
|
363
|
+
empty: graphEmptyMessage(payload, reduced.nodes.length),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- clicking a node -------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
/** What a click on the canvas produced. */
|
|
370
|
+
export interface GraphClick {
|
|
371
|
+
/** The next view state — changed only when a collapsed cluster was opened. */
|
|
372
|
+
readonly state: GraphViewState;
|
|
373
|
+
/** What to write to `selectedId`. Always set, even when a cluster expanded. */
|
|
374
|
+
readonly selectedId: string | null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Apply a click on a node (or on empty stage, with `null`).
|
|
379
|
+
*
|
|
380
|
+
* A click does **two** things when it lands on a collapsed cluster: it selects
|
|
381
|
+
* it *and* it expands it (§7.4, "expand on click"). Both, deliberately — the
|
|
382
|
+
* selection is what drives the note column and the context rail, and dropping
|
|
383
|
+
* it in favour of the expansion would make clicking a module the one gesture
|
|
384
|
+
* in the workspace that does not update the other columns.
|
|
385
|
+
*
|
|
386
|
+
* A click on an already-expanded cluster selects without collapsing. Collapse
|
|
387
|
+
* is the `[expand]` control and the tree; making a second click close a
|
|
388
|
+
* cluster would make it impossible to select one and read it.
|
|
389
|
+
*/
|
|
390
|
+
export function graphClick(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>, id: string | null): GraphClick {
|
|
391
|
+
if (id === null) return { state, selectedId: null };
|
|
392
|
+
const cluster = clusters.get(id);
|
|
393
|
+
if (cluster === undefined || state.expanded.has(id)) return { state, selectedId: id };
|
|
394
|
+
return { state: toggleCluster(state, id), selectedId: id };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- tooltips ---------------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* What a node says when it stands in for a collapsed subtree.
|
|
401
|
+
*
|
|
402
|
+
* `ClusterInfo.members` is exactly the hidden descendants this cluster is
|
|
403
|
+
* currently representing — not `descendants`, which overlaps freely between a
|
|
404
|
+
* cluster and its ancestors and would report the same file under both
|
|
405
|
+
* `src/core` and `repository`. `members` partitions the hidden set across the
|
|
406
|
+
* visible clusters, so the counts add up to the number of nodes that are not
|
|
407
|
+
* on screen.
|
|
408
|
+
*/
|
|
409
|
+
export function clusterBadge(cluster: ClusterInfo | undefined): string | null {
|
|
410
|
+
if (cluster === undefined || cluster.members.length === 0) return null;
|
|
411
|
+
return cluster.members.length === 1 ? "1 hidden" : `${cluster.members.length} hidden`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A node's hover text: its label, its degree, and what it is standing in for.
|
|
416
|
+
*
|
|
417
|
+
* `degreeOf` is core's, reached through the §2.1.1 door. The bulk `degrees`
|
|
418
|
+
* pass in `graph.model.ts` is a different algorithm for a different question
|
|
419
|
+
* (every node at once, O(edges)); this is one node, which is what a tooltip
|
|
420
|
+
* asks.
|
|
421
|
+
*/
|
|
422
|
+
export function nodeTooltip(node: WireGraphNode, edges: readonly WireGraphEdge[], cluster: ClusterInfo | undefined): string {
|
|
423
|
+
const degree = degreeOf(node.id, edges);
|
|
424
|
+
const parts = [node.label, degree === 1 ? "1 link" : `${degree} links`];
|
|
425
|
+
const badge = clusterBadge(cluster);
|
|
426
|
+
if (badge !== null) parts.push(badge);
|
|
427
|
+
return parts.join(" · ");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Positions keyed by id, for a caller warm-starting a re-run. */
|
|
431
|
+
export type LayoutSnapshot = ReadonlyMap<string, Point>;
|