pi-weave 0.3.8 → 0.3.10
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 +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -40,11 +40,32 @@ import {
|
|
|
40
40
|
graphClick,
|
|
41
41
|
graphColumnModel,
|
|
42
42
|
graphCountLabel,
|
|
43
|
+
graphPicture,
|
|
43
44
|
graphPreview,
|
|
44
|
-
|
|
45
|
+
hoverPicture,
|
|
45
46
|
} from "./column.model";
|
|
47
|
+
import {
|
|
48
|
+
CONNECTED_HEADING,
|
|
49
|
+
LIT_BECAUSE,
|
|
50
|
+
SHEET_CLOSE,
|
|
51
|
+
SHEET_LABEL,
|
|
52
|
+
SHEET_OPEN,
|
|
53
|
+
SHEET_OPEN_SPLIT,
|
|
54
|
+
SPOTLIGHT_CLEAR,
|
|
55
|
+
SPOTLIGHT_CLEAR_HINT,
|
|
56
|
+
SPOTLIGHT_LABEL,
|
|
57
|
+
SPOTLIGHT_LEGEND,
|
|
58
|
+
SPOTLIGHT_PLACEHOLDER,
|
|
59
|
+
connectedMore,
|
|
60
|
+
graphSheet,
|
|
61
|
+
litLabel,
|
|
62
|
+
litOnCanvas,
|
|
63
|
+
spotlightEscapes,
|
|
64
|
+
spotlightOf,
|
|
65
|
+
} from "./spotlight.model";
|
|
46
66
|
import type { PositionStorage } from "./positions";
|
|
47
67
|
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
68
|
+
import type { TrailGlow } from "./graph.model";
|
|
48
69
|
import { schemeOf } from "./scheme";
|
|
49
70
|
import type { SchemeHost } from "./scheme";
|
|
50
71
|
import type { GraphTheme } from "../../shared/themes";
|
|
@@ -92,14 +113,43 @@ export interface GraphProps {
|
|
|
92
113
|
* keeps the ownership where it is and costs one line at each end.
|
|
93
114
|
*/
|
|
94
115
|
fit: { current: (() => void) | null };
|
|
95
|
-
|
|
116
|
+
/** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
|
|
117
|
+
trail?: ReadonlyMap<string, TrailGlow>;
|
|
118
|
+
/**
|
|
119
|
+
* The spotlight query (`spotlight.model.ts`), controlled by the shell so
|
|
120
|
+
* other UI — the search palette's "light up in graph" — can set it.
|
|
121
|
+
* Omitted or `""` means no spotlight.
|
|
122
|
+
*/
|
|
123
|
+
query?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Node ids the search palette found for `query`, when it lit the graph up.
|
|
126
|
+
* They light alongside the spotlight's own matches; omitted means none.
|
|
127
|
+
*/
|
|
128
|
+
found?: readonly string[];
|
|
129
|
+
/**
|
|
130
|
+
* Where the spotlight field writes. **Its presence is what makes this the
|
|
131
|
+
* full graph view**: with it the column shows the floating spotlight field
|
|
132
|
+
* and the side sheet; without it (the context rail's note graph, a narrow
|
|
133
|
+
* column) it keeps the compact preview card it always had.
|
|
134
|
+
*/
|
|
135
|
+
onQuery?: (query: string) => void;
|
|
136
|
+
/** The sheet's "Open in split". Omitted hides the button. */
|
|
137
|
+
onOpenSplit?: (id: string) => void;
|
|
138
|
+
/** The shell's clock, for the sheet's "2h ago". Omitted reads the wall clock. */
|
|
139
|
+
now?: number;
|
|
96
140
|
}
|
|
97
141
|
|
|
142
|
+
const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
|
|
143
|
+
|
|
98
144
|
export function Graph(props: GraphProps) {
|
|
99
145
|
const canvas = useRef<HTMLDivElement | null>(null);
|
|
100
146
|
const renderer = useRef<GraphRenderer | null>(null);
|
|
101
147
|
const dynamics = useRef<GraphSimulation | null>(null);
|
|
102
148
|
const clock = useRef<number | null>(null);
|
|
149
|
+
// Whether the camera has been fitted to the first settled layout. The
|
|
150
|
+
// mount frames the *starting* positions, which a settling simulation then
|
|
151
|
+
// pushes past the edges — a two-note vault opened with clipped circles.
|
|
152
|
+
const fitted = useRef(false);
|
|
103
153
|
/**
|
|
104
154
|
* Arm the simulation clock — idempotent.
|
|
105
155
|
*
|
|
@@ -116,7 +166,13 @@ export function Graph(props: GraphProps) {
|
|
|
116
166
|
if (engine === null) return;
|
|
117
167
|
engine.tick();
|
|
118
168
|
// Asleep: let the clock die. The next pin re-arms it.
|
|
119
|
-
if (!engine.awake())
|
|
169
|
+
if (!engine.awake()) {
|
|
170
|
+
if (!fitted.current) {
|
|
171
|
+
fitted.current = true;
|
|
172
|
+
renderer.current?.fit();
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
120
176
|
renderer.current?.setPositions(engine.positions());
|
|
121
177
|
clock.current = requestAnimationFrame(step);
|
|
122
178
|
};
|
|
@@ -143,11 +199,25 @@ export function Graph(props: GraphProps) {
|
|
|
143
199
|
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
|
|
144
200
|
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
|
|
145
201
|
);
|
|
146
|
-
|
|
202
|
+
// The full graph view has a spotlight and a side sheet; the context rail's
|
|
203
|
+
// note graph keeps the compact card. See `GraphProps.onQuery`.
|
|
204
|
+
const full = props.onQuery !== undefined;
|
|
205
|
+
const query = full ? props.query ?? "" : "";
|
|
206
|
+
const spot = useMemo(() => spotlightOf(props.graph, query, props.found), [props.graph, query, props.found]);
|
|
207
|
+
// Same identity contract as `model` above: the highlight effect keys on the
|
|
208
|
+
// picture's identity, so each step is memoized on exactly what it reads.
|
|
209
|
+
const lit = useMemo(() => litOnCanvas(spot, model.representative), [spot, model.representative]);
|
|
210
|
+
const picture = useMemo(() => graphPicture(model.highlight, props.selectedId, lit), [model.highlight, props.selectedId, lit]);
|
|
211
|
+
const preview = full ? null : graphPreview(props.graph, props.previewId);
|
|
212
|
+
const now = props.now ?? Date.now();
|
|
213
|
+
const sheet = useMemo(() => (full ? graphSheet(props.graph, props.previewId, spot, now) : null), [full, props.graph, props.previewId, spot, now]);
|
|
214
|
+
const field = useRef<HTMLInputElement | null>(null);
|
|
147
215
|
|
|
148
216
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
149
|
-
const
|
|
150
|
-
|
|
217
|
+
const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
|
|
218
|
+
trailRef.current = props.trail ?? NO_GLOWS;
|
|
219
|
+
const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
220
|
+
live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
151
221
|
|
|
152
222
|
useEffect(() => {
|
|
153
223
|
const instance = props.renderer(scheme);
|
|
@@ -164,11 +234,13 @@ export function Graph(props: GraphProps) {
|
|
|
164
234
|
// state would enter the `model` memo below and re-derive the layout,
|
|
165
235
|
// the group colours and the whole `RenderGraph` on every pointer move.
|
|
166
236
|
instance.onHover((id) => {
|
|
167
|
-
const { model: current,
|
|
237
|
+
const { model: current, picture: shown } = live.current;
|
|
168
238
|
// The *drawn* edges, which are exactly the visible ones the column's own
|
|
169
239
|
// highlight was computed over — a neighbour inside a collapsed cluster
|
|
170
240
|
// has already been retargeted onto the cluster standing in for it.
|
|
171
|
-
|
|
241
|
+
// Under a spotlight `hoverPicture` hands the spotlight straight back.
|
|
242
|
+
const next = hoverPicture(current.graph.edges, id, shown);
|
|
243
|
+
instance.setHighlight(next.highlight, next.selectedId, next.style);
|
|
172
244
|
});
|
|
173
245
|
instance.onDragStart((id) => {
|
|
174
246
|
const at = renderer.current?.positions().get(id);
|
|
@@ -194,7 +266,9 @@ export function Graph(props: GraphProps) {
|
|
|
194
266
|
// the render this effect was created in), so a remount carries whatever
|
|
195
267
|
// the column is already showing.
|
|
196
268
|
instance.setGraph(live.current.model.graph);
|
|
197
|
-
|
|
269
|
+
const shown = live.current.picture;
|
|
270
|
+
instance.setHighlight(shown.highlight, shown.selectedId, shown.style);
|
|
271
|
+
instance.setTrail(trailRef.current);
|
|
198
272
|
props.fit.current = () => instance.fit();
|
|
199
273
|
return () => {
|
|
200
274
|
instance.destroy();
|
|
@@ -239,17 +313,93 @@ export function Graph(props: GraphProps) {
|
|
|
239
313
|
);
|
|
240
314
|
|
|
241
315
|
useEffect(() => {
|
|
242
|
-
renderer.current?.setHighlight(
|
|
243
|
-
}, [
|
|
316
|
+
renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
|
|
317
|
+
}, [picture]);
|
|
318
|
+
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
renderer.current?.setTrail(trailRef.current);
|
|
321
|
+
}, [props.trail]);
|
|
322
|
+
|
|
323
|
+
// `Esc` clears the spotlight before the shell's document listener sees it —
|
|
324
|
+
// or, when `spotlightEscapes` says no, passes through to "clear selection".
|
|
325
|
+
const escape = (event: KeyboardEvent): void => {
|
|
326
|
+
if (event.key !== "Escape" || !spotlightEscapes(query, event.target === field.current, sheet !== null)) return;
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
props.onQuery?.("");
|
|
329
|
+
};
|
|
330
|
+
const more = sheet === null ? null : connectedMore(sheet.connected.length, sheet.connectedTotal);
|
|
244
331
|
|
|
245
332
|
return (
|
|
246
|
-
<div class="weave-graph">
|
|
333
|
+
<div class="weave-graph" onKeyDown={escape}>
|
|
247
334
|
{/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
|
|
248
335
|
and what is left here is whether to render the paragraph it returned. */}
|
|
249
336
|
{model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
|
|
250
337
|
{/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
|
|
251
338
|
comment. The tree's target is the rows `<ul>`, which has its own. */}
|
|
252
339
|
<div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
|
|
340
|
+
{!full ? null : (
|
|
341
|
+
<div class={`weave-spotlight${spot === null ? "" : " weave-spotlight-on"}`} role="search">
|
|
342
|
+
<svg class="weave-spotlight-icon" viewBox="0 0 20 20" width={14} height={14} fill="none" stroke="currentColor" stroke-width={1.8} aria-hidden="true">
|
|
343
|
+
<circle cx="8.5" cy="8.5" r="5.5" />
|
|
344
|
+
<path d="m13 13 4 4" />
|
|
345
|
+
</svg>
|
|
346
|
+
<input ref={field} class="weave-spotlight-input" type="search" value={query} placeholder={SPOTLIGHT_PLACEHOLDER}
|
|
347
|
+
aria-label={SPOTLIGHT_LABEL} spellcheck={false} autoComplete="off" onInput={(event) => props.onQuery?.(event.currentTarget.value)} />
|
|
348
|
+
{spot === null ? null : (
|
|
349
|
+
<>
|
|
350
|
+
<span class={`weave-spotlight-count${spot.count === 0 ? " weave-spotlight-none" : ""}`} role="status">{litLabel(spot.count)}</span>
|
|
351
|
+
<button type="button" class="weave-spotlight-clear" title={SPOTLIGHT_CLEAR_HINT} aria-label={SPOTLIGHT_CLEAR_HINT}
|
|
352
|
+
onClick={() => { props.onQuery?.(""); field.current?.focus(); }}>{SPOTLIGHT_CLEAR}</button>
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
{sheet === null ? null : (
|
|
358
|
+
<aside class="weave-sheet" aria-label={SHEET_LABEL}>
|
|
359
|
+
<button type="button" class="weave-sheet-close" aria-label={SHEET_CLOSE} title={SHEET_CLOSE} onClick={() => props.onSelect(null)}>×</button>
|
|
360
|
+
<p class="weave-sheet-crumb">
|
|
361
|
+
<Icon name={sheet.crumb === "" ? sheet.icon : "module"} class="weave-icon" />
|
|
362
|
+
<span>{sheet.crumb === "" ? sheet.kind : sheet.crumb}</span>
|
|
363
|
+
</p>
|
|
364
|
+
<h2 class="weave-sheet-title">{sheet.title}</h2>
|
|
365
|
+
<p class="weave-sheet-meta">
|
|
366
|
+
{sheet.who === null ? null : <span class={`weave-prov weave-prov-${sheet.who.provenance}`} title={sheet.who.title} aria-hidden="true">{sheet.who.glyph}</span>}
|
|
367
|
+
<span>{sheet.who === null ? sheet.kind : sheet.who.word}</span>
|
|
368
|
+
{sheet.when === "" ? null : <span class="weave-sheet-when">· {sheet.when}</span>}
|
|
369
|
+
</p>
|
|
370
|
+
{sheet.text === null ? null : (
|
|
371
|
+
<div class="weave-sheet-text">
|
|
372
|
+
<span class="weave-sheet-text-head">{sheet.text.heading}</span>
|
|
373
|
+
{sheet.text.parts.map((part) => (part.hit ? <mark class="weave-sheet-mark">{part.text}</mark> : part.text))}
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
{sheet.litBecause === null ? null : (
|
|
377
|
+
<p class="weave-sheet-why"><span aria-hidden="true">✳</span>{LIT_BECAUSE} {sheet.litBecause}</p>
|
|
378
|
+
)}
|
|
379
|
+
{sheet.connectedTotal === 0 ? null : (
|
|
380
|
+
<section class="weave-sheet-section" aria-label={CONNECTED_HEADING}>
|
|
381
|
+
<h3 class="weave-sheet-heading">{CONNECTED_HEADING}<span class="weave-sheet-count">{sheet.connectedTotal}</span></h3>
|
|
382
|
+
<ul class="weave-sheet-rows">
|
|
383
|
+
{sheet.connected.map((row) => (
|
|
384
|
+
<li key={row.id}>
|
|
385
|
+
<button type="button" class="weave-sheet-row" title={row.label} aria-label={row.ariaLabel} onClick={() => props.onSelect(row.id)}>
|
|
386
|
+
<span class={`weave-sheet-dot${row.lit ? " weave-sheet-dot-lit" : ""}`} aria-hidden="true" />
|
|
387
|
+
<span class="weave-sheet-row-label">{row.label}</span>
|
|
388
|
+
</button>
|
|
389
|
+
</li>
|
|
390
|
+
))}
|
|
391
|
+
</ul>
|
|
392
|
+
{more === null ? null : <p class="weave-sheet-more">{more}</p>}
|
|
393
|
+
</section>
|
|
394
|
+
)}
|
|
395
|
+
<div class="weave-sheet-actions">
|
|
396
|
+
<button type="button" class="weave-sheet-open" onClick={() => props.onOpen(sheet.id)}>{SHEET_OPEN}</button>
|
|
397
|
+
{props.onOpenSplit === undefined ? null : (
|
|
398
|
+
<button type="button" class="weave-sheet-split" onClick={() => props.onOpenSplit?.(sheet.id)}>{SHEET_OPEN_SPLIT}</button>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
</aside>
|
|
402
|
+
)}
|
|
253
403
|
{preview === null ? null : (
|
|
254
404
|
<aside class="weave-graph-preview" aria-label="Selected graph node">
|
|
255
405
|
<div class="weave-graph-preview-info">
|
|
@@ -269,8 +419,10 @@ export function Graph(props: GraphProps) {
|
|
|
269
419
|
</button>
|
|
270
420
|
<span class="weave-graph-legend">
|
|
271
421
|
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
272
|
-
|
|
273
|
-
|
|
422
|
+
{spot === null
|
|
423
|
+
? <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
|
|
424
|
+
: <span class="weave-legend-lit">● {SPOTLIGHT_LEGEND.lit}</span>}
|
|
425
|
+
<span class="weave-legend-dim">· {spot === null ? LEGEND.dimmed : SPOTLIGHT_LEGEND.dimmed}</span>
|
|
274
426
|
</span>
|
|
275
427
|
</div>
|
|
276
428
|
<p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
|
|
@@ -46,7 +46,7 @@ import { clusterAggregate, focusNeighborhood } from "../../shared/view";
|
|
|
46
46
|
import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
|
|
47
47
|
import { kindIcon, viewModel } from "../tree/tree.model";
|
|
48
48
|
import type { IconName } from "../shell/icons.model";
|
|
49
|
-
import type { RenderGraph } from "./graph.model";
|
|
49
|
+
import type { HighlightStyle, RenderGraph } from "./graph.model";
|
|
50
50
|
import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
|
|
51
51
|
import { groupNodeColors } from "./groups";
|
|
52
52
|
import type { PositionStorage } from "./positions";
|
|
@@ -208,6 +208,55 @@ export function hoverHighlight(
|
|
|
208
208
|
return hoveredId === null ? fallback : focusNeighborhood(hoveredId, edges);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// --- the picture: neighbourhood or spotlight ------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What the renderer is told to draw: a set, the node it is about, and which
|
|
215
|
+
* treatment the set gets (`graph.model.ts`'s {@link HighlightStyle}).
|
|
216
|
+
*
|
|
217
|
+
* The three travel together because they are one decision — the renderer's
|
|
218
|
+
* `setHighlight(highlight, selectedId, style)` takes exactly this, and a
|
|
219
|
+
* caller that passed them separately could pair a spotlight's set with a
|
|
220
|
+
* neighbourhood's treatment.
|
|
221
|
+
*/
|
|
222
|
+
export interface GraphPicture {
|
|
223
|
+
readonly highlight: Set<string> | null;
|
|
224
|
+
readonly selectedId: string | null;
|
|
225
|
+
readonly style: HighlightStyle;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The column's picture: **a spotlight wins over the selection's neighbourhood**.
|
|
230
|
+
*
|
|
231
|
+
* `lit` is the spotlight's set already mapped onto the canvas
|
|
232
|
+
* (`spotlight.model.ts`'s `litOnCanvas`), or `null` when no query is set — and
|
|
233
|
+
* then the picture is the selection's neighbourhood exactly as it was before
|
|
234
|
+
* the spotlight existed. The selection still rides along under a spotlight,
|
|
235
|
+
* because the node the side sheet is about has to stand out among the lit ones.
|
|
236
|
+
*/
|
|
237
|
+
export function graphPicture(neighbourhood: Set<string> | null, selectedId: string | null, lit: Set<string> | null): GraphPicture {
|
|
238
|
+
return lit === null
|
|
239
|
+
? { highlight: neighbourhood, selectedId, style: "neighbourhood" }
|
|
240
|
+
: { highlight: lit, selectedId, style: "spotlight" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The picture while the pointer is over a node.
|
|
245
|
+
*
|
|
246
|
+
* Without a spotlight, {@link hoverHighlight} as ever: hover wins and the
|
|
247
|
+
* hovered node is the subject. **Under a spotlight the lit set holds** — it is
|
|
248
|
+
* something the user typed, and moving the pointer across the canvas to reach
|
|
249
|
+
* a lit node must not keep swapping it for neighbourhoods. Only the subject
|
|
250
|
+
* moves: the hovered node takes the selection's place while the pointer is on
|
|
251
|
+
* it, so a faint dot answers with its full colour and its name (a dimmed
|
|
252
|
+
* node has no label for the hover painter to float), and leaving it hands the
|
|
253
|
+
* emphasis back to the selection.
|
|
254
|
+
*/
|
|
255
|
+
export function hoverPicture(edges: readonly WireGraphEdge[], hoveredId: string | null, picture: GraphPicture): GraphPicture {
|
|
256
|
+
if (picture.style === "spotlight") return hoveredId === null ? picture : { ...picture, selectedId: hoveredId };
|
|
257
|
+
return { highlight: hoverHighlight(edges, hoveredId, picture.highlight), selectedId: hoveredId ?? picture.selectedId, style: "neighbourhood" };
|
|
258
|
+
}
|
|
259
|
+
|
|
211
260
|
// --- the control strip (§1.2) ---------------------------------------------------------------
|
|
212
261
|
|
|
213
262
|
/** The `[fit]` control. Constant, but named here so the component holds no copy. */
|
|
@@ -235,7 +284,7 @@ export function graphCountLabel(visible: number, total: number): string {
|
|
|
235
284
|
/** What the column says instead of a canvas. `null` means "there is a graph". */
|
|
236
285
|
export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
|
|
237
286
|
if (payload === null) return "Loading…";
|
|
238
|
-
if (payload.model.nodes.length === 0) return "Nothing
|
|
287
|
+
if (payload.model.nodes.length === 0) return "Nothing here yet — add a note to get started.";
|
|
239
288
|
// A payload with nodes but nothing visible cannot happen through the UI
|
|
240
289
|
// (`clusterAggregate` always keeps the roots), but a truncated response
|
|
241
290
|
// could produce it, and a blank canvas with no explanation reads as a crash.
|
|
@@ -269,6 +318,12 @@ export interface GraphColumnModel {
|
|
|
269
318
|
readonly cached: boolean;
|
|
270
319
|
/** Every cluster in the *unreduced* model — what `[expand]` operates on. */
|
|
271
320
|
readonly clusters: ReadonlyMap<string, ClusterInfo>;
|
|
321
|
+
/**
|
|
322
|
+
* Hidden id → the visible cluster standing in for it (`clusterAggregate`'s
|
|
323
|
+
* mapping), so a set computed over the whole payload — the spotlight's —
|
|
324
|
+
* can be retargeted onto what is drawn.
|
|
325
|
+
*/
|
|
326
|
+
readonly representative: ReadonlyMap<string, string>;
|
|
272
327
|
/** Visible / total, for the count line. */
|
|
273
328
|
readonly visible: number;
|
|
274
329
|
readonly total: number;
|
|
@@ -283,6 +338,7 @@ export const EMPTY_COLUMN: GraphColumnModel = {
|
|
|
283
338
|
key: "",
|
|
284
339
|
cached: false,
|
|
285
340
|
clusters: new Map(),
|
|
341
|
+
representative: new Map(),
|
|
286
342
|
visible: 0,
|
|
287
343
|
total: 0,
|
|
288
344
|
empty: "Loading…",
|
|
@@ -351,6 +407,7 @@ export function graphColumnModel(
|
|
|
351
407
|
key: layout.key,
|
|
352
408
|
cached: layout.cached,
|
|
353
409
|
clusters: reduced.clusters,
|
|
410
|
+
representative: reduced.representative,
|
|
354
411
|
visible: reduced.nodes.length,
|
|
355
412
|
total: model.nodes.length,
|
|
356
413
|
empty: graphEmptyMessage(payload, reduced.nodes.length),
|
|
@@ -83,11 +83,8 @@ export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, stri
|
|
|
83
83
|
* - `note` keeps `text` — notes are the product (§1.1), and their identity
|
|
84
84
|
* is carried by the provenance badge, not by a hue.
|
|
85
85
|
* - Generated code — modules, packages, entry points, git, externals, files
|
|
86
|
-
* — takes `dim`, the same grey a `generated` row takes in the tree.
|
|
87
|
-
*
|
|
88
|
-
* success/warning; the two media disagree on purpose, because a pi theme
|
|
89
|
-
* has no dark/light stylesheet to violate and a terminal can restyle
|
|
90
|
-
* everything. Here the sheet decides, and the sheet says calm.
|
|
86
|
+
* — takes `dim`, the same grey a `generated` row takes in the tree. Here
|
|
87
|
+
* the sheet decides, and the sheet says calm.
|
|
91
88
|
*/
|
|
92
89
|
export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
|
|
93
90
|
vault: "accent",
|
|
@@ -378,6 +375,8 @@ export interface HoverLabelNode {
|
|
|
378
375
|
readonly y: number;
|
|
379
376
|
readonly size: number;
|
|
380
377
|
readonly label: string | null;
|
|
378
|
+
/** Set by the spotlight on a node whose label already stands — see {@link hoverLabelPainter}. */
|
|
379
|
+
readonly forceLabel?: boolean;
|
|
381
380
|
}
|
|
382
381
|
|
|
383
382
|
/** The settings a hover painter reads. Both `GraphSettings` and sigma's satisfy it. */
|
|
@@ -434,6 +433,9 @@ export function hoverLabelPainter(
|
|
|
434
433
|
// `nodeReducer` blanks the label of everything outside the highlight, and
|
|
435
434
|
// a node with no name has nothing to float.
|
|
436
435
|
if (data.label === null || data.label === "") return;
|
|
436
|
+
// A forced label (the spotlight's lit nodes) is already standing beside the
|
|
437
|
+
// disc; floating it again underneath would print the same name twice.
|
|
438
|
+
if (data.forceLabel === true) return;
|
|
437
439
|
const palette = graphPalette(scheme);
|
|
438
440
|
context.save();
|
|
439
441
|
context.font = `${settings.labelSize}px ${settings.labelFont}`;
|
|
@@ -513,7 +515,8 @@ export interface ViewBox {
|
|
|
513
515
|
* session: the node stays under the cursor, the canvas bounds the drag, and
|
|
514
516
|
* `[fit]` re-frames onto whatever the current positions are.
|
|
515
517
|
*
|
|
516
|
-
* Use the position extent
|
|
518
|
+
* Use the position extent padded by the largest node size, so no circle
|
|
519
|
+
* at the edge is cut off, with a minimum span of four collision radii.
|
|
517
520
|
* A single node (or coincident nodes) has no extent; Sigma scales layout-unit
|
|
518
521
|
* node sizes by that span, so a near-zero box magnifies one node across the
|
|
519
522
|
* canvas. Pixel-based `stagePadding` cannot prevent that.
|
|
@@ -536,7 +539,9 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
|
|
|
536
539
|
if (p.y > maxY) maxY = p.y;
|
|
537
540
|
}
|
|
538
541
|
if (seen === 0) return null;
|
|
539
|
-
|
|
542
|
+
// The extent is of node *centres*; the circles reach past it by up to a
|
|
543
|
+
// full node size, which clipped the outermost nodes on a small graph.
|
|
544
|
+
const padding = Math.max(MAX_NODE_SIZE, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
|
|
540
545
|
return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
|
|
541
546
|
}
|
|
542
547
|
|
|
@@ -670,8 +675,31 @@ export interface NodeDisplayOverride {
|
|
|
670
675
|
readonly label?: string | null;
|
|
671
676
|
readonly zIndex?: number;
|
|
672
677
|
readonly size?: number;
|
|
678
|
+
/**
|
|
679
|
+
* Draw the label whatever the zoom and the label grid say — sigma's own
|
|
680
|
+
* display attribute. Only the spotlight sets it: a lit node has to name
|
|
681
|
+
* itself, and on a graph over {@link SMALL_GRAPH_NODES} the size threshold
|
|
682
|
+
* would otherwise keep a lit leaf silent.
|
|
683
|
+
*/
|
|
684
|
+
readonly forceLabel?: boolean;
|
|
673
685
|
}
|
|
674
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Which picture a highlight set paints.
|
|
689
|
+
*
|
|
690
|
+
* - `neighbourhood` — a selection (or hover) and its one-hop neighbours: the
|
|
691
|
+
* selection is the subject, its links are drawn in the accent.
|
|
692
|
+
* - `spotlight` — the nodes a query lit (`spotlight.model.ts`): every lit node
|
|
693
|
+
* is a subject, grown and labelled, the edges *between* lit nodes are drawn
|
|
694
|
+
* in the highlighter's warm slot, and everything else drops back further
|
|
695
|
+
* than a neighbourhood's cloud does.
|
|
696
|
+
*
|
|
697
|
+
* One set, one fade, two treatments — the spotlight rides the exact
|
|
698
|
+
* `setHighlight` path a selection does instead of a second layer, so the two
|
|
699
|
+
* can never be on screen at once and argue.
|
|
700
|
+
*/
|
|
701
|
+
export type HighlightStyle = "neighbourhood" | "spotlight";
|
|
702
|
+
|
|
675
703
|
/** What sigma's `edgeReducer` may override. */
|
|
676
704
|
export interface EdgeDisplayOverride {
|
|
677
705
|
readonly color?: string;
|
|
@@ -715,12 +743,15 @@ export function nodeReducer(
|
|
|
715
743
|
* about the clock.
|
|
716
744
|
*/
|
|
717
745
|
progress: number = 1,
|
|
746
|
+
/** Which treatment the set gets — see {@link HighlightStyle}. */
|
|
747
|
+
style: HighlightStyle = "neighbourhood",
|
|
718
748
|
): (id: string, data: RenderNode, scheme: GraphTheme) => NodeDisplayOverride {
|
|
719
749
|
return (id, data, scheme) => {
|
|
720
750
|
// `progress` at zero is indistinguishable from no highlight, and saying so
|
|
721
751
|
// here is what lets the fade end by *returning* to the unhighlighted graph
|
|
722
752
|
// rather than by approaching it.
|
|
723
753
|
if (highlight === null || progress <= 0) return {};
|
|
754
|
+
if (style === "spotlight") return spotlightNode(highlight, selectedId ?? null, progress, id, data, scheme);
|
|
724
755
|
if (id === selectedId && highlight.has(id)) {
|
|
725
756
|
// The subject of the gesture: lifted clear of its own neighbourhood and
|
|
726
757
|
// grown by a ratio rather than to a fixed radius, so a hub still reads
|
|
@@ -743,6 +774,75 @@ export function nodeReducer(
|
|
|
743
774
|
};
|
|
744
775
|
}
|
|
745
776
|
|
|
777
|
+
// --- the spotlight treatment (G1) ------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* How far an unlit node or edge recedes while a spotlight is up.
|
|
781
|
+
*
|
|
782
|
+
* Further than a neighbourhood's {@link RECESS_STRENGTH}: a selection dims the
|
|
783
|
+
* cloud to *place* a node inside it, where a spotlight asks "where are the
|
|
784
|
+
* things I named?" and the answer is the lit nodes alone — the rest is
|
|
785
|
+
* context for position, faint dots and hairlines and nothing more.
|
|
786
|
+
*/
|
|
787
|
+
export const SPOTLIGHT_RECESS = 0.92;
|
|
788
|
+
|
|
789
|
+
/** A lit node's growth — a step past a neighbour's, because every lit node is a subject. */
|
|
790
|
+
export const SPOTLIGHT_GROWTH = 1.4;
|
|
791
|
+
|
|
792
|
+
/** The selected node while a spotlight is up: the one the side sheet is about. */
|
|
793
|
+
export const SPOTLIGHT_SELECTED_GROWTH = 1.7;
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* An unlit node shrinks toward a dot as it recedes.
|
|
797
|
+
*
|
|
798
|
+
* Unlike a neighbourhood's cloud (which keeps its size so the graph does not
|
|
799
|
+
* appear to move on a click), the spotlight is a mode the user typed into,
|
|
800
|
+
* and the shrink is what makes "everything else drops back to faint dots"
|
|
801
|
+
* true at a glance. Positions are untouched, so nothing reflows.
|
|
802
|
+
*/
|
|
803
|
+
export const SPOTLIGHT_SHRINK = 0.75;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Above this many lit nodes, labels go back to the size threshold.
|
|
807
|
+
*
|
|
808
|
+
* Forcing every label is right for the handful of matches a query usually
|
|
809
|
+
* names, and wrong for a query like `src` that lights a whole subtree: two
|
|
810
|
+
* hundred forced labels are one unreadable smear. Past the cap the lit nodes
|
|
811
|
+
* still grow and keep full colour; sigma's grid decides which of them speak.
|
|
812
|
+
*/
|
|
813
|
+
export const SPOTLIGHT_LABEL_LIMIT = 40;
|
|
814
|
+
|
|
815
|
+
/** The slot an edge between two lit nodes is painted in — the highlighter's warm voice. */
|
|
816
|
+
export const SPOTLIGHT_EDGE_SLOT: ColorSlot = "warning";
|
|
817
|
+
|
|
818
|
+
/** One node under a spotlight. The selection is always a subject, lit or not. */
|
|
819
|
+
function spotlightNode(
|
|
820
|
+
lit: ReadonlySet<string>,
|
|
821
|
+
selectedId: string | null,
|
|
822
|
+
progress: number,
|
|
823
|
+
id: string,
|
|
824
|
+
data: RenderNode,
|
|
825
|
+
scheme: GraphTheme,
|
|
826
|
+
): NodeDisplayOverride {
|
|
827
|
+
const speaks = progress >= LABEL_DROP_AT;
|
|
828
|
+
if (id === selectedId) {
|
|
829
|
+
return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 2, size: data.size * growth(SPOTLIGHT_SELECTED_GROWTH, progress), forceLabel: speaks };
|
|
830
|
+
}
|
|
831
|
+
if (lit.has(id)) {
|
|
832
|
+
return {
|
|
833
|
+
zIndex: data.zIndex + HIGHLIGHT_Z_LIFT,
|
|
834
|
+
size: data.size * growth(SPOTLIGHT_GROWTH, progress),
|
|
835
|
+
forceLabel: speaks && lit.size <= SPOTLIGHT_LABEL_LIMIT,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress),
|
|
840
|
+
label: speaks ? null : data.label,
|
|
841
|
+
size: data.size * growth(SPOTLIGHT_SHRINK, progress),
|
|
842
|
+
zIndex: 0,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
746
846
|
// --- the highlight fade (§7.4) -----------------------------------------------------
|
|
747
847
|
|
|
748
848
|
/**
|
|
@@ -856,9 +956,23 @@ export function edgeReducer(
|
|
|
856
956
|
selectedId?: string | null,
|
|
857
957
|
/** The fade's progress — see {@link nodeReducer}'s. */
|
|
858
958
|
progress: number = 1,
|
|
959
|
+
/** Which treatment the set gets — see {@link HighlightStyle}. */
|
|
960
|
+
style: HighlightStyle = "neighbourhood",
|
|
859
961
|
): (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride {
|
|
860
962
|
return (_key, data, scheme) => {
|
|
861
963
|
if (highlight === null || progress <= 0) return {};
|
|
964
|
+
if (style === "spotlight") {
|
|
965
|
+
// Lit only when it joins two lit nodes: the lines *between* matches are
|
|
966
|
+
// what the spotlight is for — how the things you named connect.
|
|
967
|
+
if (!highlight.has(data.source) || !highlight.has(data.target)) {
|
|
968
|
+
return { color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress) };
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
zIndex: 2,
|
|
972
|
+
size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
|
|
973
|
+
color: blendHex(data.color, graphPalette(scheme)[SPOTLIGHT_EDGE_SLOT], progress),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
862
976
|
if (!highlight.has(data.source) || !highlight.has(data.target)) {
|
|
863
977
|
return { color: recessColor(data.color, scheme, RECESS_STRENGTH * progress) };
|
|
864
978
|
}
|
|
@@ -882,6 +996,54 @@ export function edgeReducer(
|
|
|
882
996
|
};
|
|
883
997
|
}
|
|
884
998
|
|
|
999
|
+
// --- the recall trail (issue #78) -----------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
/** The slice of `trail.model.ts`'s `TrailMark` this layer reads, restated to keep this file import-free of it. */
|
|
1002
|
+
export interface TrailGlow {
|
|
1003
|
+
readonly kind: "searched" | "read" | "wrote";
|
|
1004
|
+
readonly strength: number;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* How each kind of touch is painted: which slot it blends toward, how far, and
|
|
1009
|
+
* how much the node grows. A search hit is a soft amber hint, a read is a
|
|
1010
|
+
* filled glow, and a write is the accent at full presence — the one place
|
|
1011
|
+
* this canvas spends the accent on a note, because a write is the event.
|
|
1012
|
+
*/
|
|
1013
|
+
export const TRAIL_STYLE: Readonly<Record<TrailGlow["kind"], { slot: ColorSlot; blend: number; growth: number }>> = {
|
|
1014
|
+
searched: { slot: "warning", blend: 0.35, growth: 1.08 },
|
|
1015
|
+
read: { slot: "success", blend: 0.85, growth: 1.3 },
|
|
1016
|
+
wrote: { slot: "accent", blend: 1, growth: 1.5 },
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The recall-trail layer, applied **over** the selection reducer's answer.
|
|
1021
|
+
*
|
|
1022
|
+
* Over, because the trail is the live event: a note the agent just wrote must
|
|
1023
|
+
* read as such even when it sits in the receded cloud of an unrelated
|
|
1024
|
+
* selection. It is handed the *reduced* node, and colour and size scale with
|
|
1025
|
+
* the glow's strength, so the fade is a smooth return to whatever the
|
|
1026
|
+
* selection layer said; the label is kept so a glowing node always names itself.
|
|
1027
|
+
*/
|
|
1028
|
+
export function trailOverride(
|
|
1029
|
+
glow: TrailGlow | undefined,
|
|
1030
|
+
/** The node as the selection layer left it, so the glow composes over recession and growth. */
|
|
1031
|
+
data: Pick<RenderNode, "color" | "size" | "zIndex">,
|
|
1032
|
+
scheme: GraphTheme,
|
|
1033
|
+
/** The node's own label — the selection layer may have dropped it, and a glowing node always names itself. */
|
|
1034
|
+
label: string,
|
|
1035
|
+
): NodeDisplayOverride {
|
|
1036
|
+
if (glow === undefined || !(glow.strength > 0)) return {};
|
|
1037
|
+
const style = TRAIL_STYLE[glow.kind];
|
|
1038
|
+
const strength = Math.min(1, glow.strength);
|
|
1039
|
+
return {
|
|
1040
|
+
color: blendHex(data.color, graphPalette(scheme)[style.slot], style.blend * strength),
|
|
1041
|
+
size: data.size * growth(style.growth, strength),
|
|
1042
|
+
zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 3,
|
|
1043
|
+
label,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
885
1047
|
// --- sigma settings (§7.4) -------------------------------------------------------------
|
|
886
1048
|
|
|
887
1049
|
/**
|
|
@@ -929,6 +1091,20 @@ export interface GraphSettings {
|
|
|
929
1091
|
*/
|
|
930
1092
|
export const LABEL_SIZE_THRESHOLD = (MIN_NODE_SIZE + MAX_NODE_SIZE) / 2;
|
|
931
1093
|
|
|
1094
|
+
/**
|
|
1095
|
+
* Below this many nodes every node carries its name.
|
|
1096
|
+
*
|
|
1097
|
+
* The size threshold exists to keep a dense graph legible; on a vault of
|
|
1098
|
+
* two or three notes it left all but one circle unnamed, and a small graph
|
|
1099
|
+
* has no density to protect.
|
|
1100
|
+
*/
|
|
1101
|
+
export const SMALL_GRAPH_NODES = 30;
|
|
1102
|
+
|
|
1103
|
+
/** `labelRenderedSizeThreshold` for a graph of `nodeCount` nodes. */
|
|
1104
|
+
export function labelThreshold(nodeCount: number): number {
|
|
1105
|
+
return nodeCount < SMALL_GRAPH_NODES ? 0 : LABEL_SIZE_THRESHOLD;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
932
1108
|
/**
|
|
933
1109
|
* Label collision grid, in screen pixels.
|
|
934
1110
|
*
|