pi-weave 0.3.9 → 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 +5 -2
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +392 -153
- package/src/web/client/graph/Graph.tsx +143 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -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/Shell.tsx +61 -19
- 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 +11 -1
- 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 +252 -47
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -40,9 +40,29 @@ 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";
|
|
48
68
|
import type { TrailGlow } from "./graph.model";
|
|
@@ -95,6 +115,28 @@ export interface GraphProps {
|
|
|
95
115
|
fit: { current: (() => void) | null };
|
|
96
116
|
/** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
|
|
97
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;
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
|
|
@@ -157,13 +199,25 @@ export function Graph(props: GraphProps) {
|
|
|
157
199
|
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
|
|
158
200
|
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
|
|
159
201
|
);
|
|
160
|
-
|
|
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);
|
|
161
215
|
|
|
162
216
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
163
217
|
const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
|
|
164
218
|
trailRef.current = props.trail ?? NO_GLOWS;
|
|
165
|
-
const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
166
|
-
live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
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 };
|
|
167
221
|
|
|
168
222
|
useEffect(() => {
|
|
169
223
|
const instance = props.renderer(scheme);
|
|
@@ -180,11 +234,13 @@ export function Graph(props: GraphProps) {
|
|
|
180
234
|
// state would enter the `model` memo below and re-derive the layout,
|
|
181
235
|
// the group colours and the whole `RenderGraph` on every pointer move.
|
|
182
236
|
instance.onHover((id) => {
|
|
183
|
-
const { model: current,
|
|
237
|
+
const { model: current, picture: shown } = live.current;
|
|
184
238
|
// The *drawn* edges, which are exactly the visible ones the column's own
|
|
185
239
|
// highlight was computed over — a neighbour inside a collapsed cluster
|
|
186
240
|
// has already been retargeted onto the cluster standing in for it.
|
|
187
|
-
|
|
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);
|
|
188
244
|
});
|
|
189
245
|
instance.onDragStart((id) => {
|
|
190
246
|
const at = renderer.current?.positions().get(id);
|
|
@@ -210,7 +266,8 @@ export function Graph(props: GraphProps) {
|
|
|
210
266
|
// the render this effect was created in), so a remount carries whatever
|
|
211
267
|
// the column is already showing.
|
|
212
268
|
instance.setGraph(live.current.model.graph);
|
|
213
|
-
|
|
269
|
+
const shown = live.current.picture;
|
|
270
|
+
instance.setHighlight(shown.highlight, shown.selectedId, shown.style);
|
|
214
271
|
instance.setTrail(trailRef.current);
|
|
215
272
|
props.fit.current = () => instance.fit();
|
|
216
273
|
return () => {
|
|
@@ -256,21 +313,93 @@ export function Graph(props: GraphProps) {
|
|
|
256
313
|
);
|
|
257
314
|
|
|
258
315
|
useEffect(() => {
|
|
259
|
-
renderer.current?.setHighlight(
|
|
260
|
-
}, [
|
|
316
|
+
renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
|
|
317
|
+
}, [picture]);
|
|
261
318
|
|
|
262
319
|
useEffect(() => {
|
|
263
320
|
renderer.current?.setTrail(trailRef.current);
|
|
264
321
|
}, [props.trail]);
|
|
265
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);
|
|
331
|
+
|
|
266
332
|
return (
|
|
267
|
-
<div class="weave-graph">
|
|
333
|
+
<div class="weave-graph" onKeyDown={escape}>
|
|
268
334
|
{/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
|
|
269
335
|
and what is left here is whether to render the paragraph it returned. */}
|
|
270
336
|
{model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
|
|
271
337
|
{/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
|
|
272
338
|
comment. The tree's target is the rows `<ul>`, which has its own. */}
|
|
273
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
|
+
)}
|
|
274
403
|
{preview === null ? null : (
|
|
275
404
|
<aside class="weave-graph-preview" aria-label="Selected graph node">
|
|
276
405
|
<div class="weave-graph-preview-info">
|
|
@@ -290,8 +419,10 @@ export function Graph(props: GraphProps) {
|
|
|
290
419
|
</button>
|
|
291
420
|
<span class="weave-graph-legend">
|
|
292
421
|
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
293
|
-
|
|
294
|
-
|
|
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>
|
|
295
426
|
</span>
|
|
296
427
|
</div>
|
|
297
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. */
|
|
@@ -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),
|
|
@@ -375,6 +375,8 @@ export interface HoverLabelNode {
|
|
|
375
375
|
readonly y: number;
|
|
376
376
|
readonly size: number;
|
|
377
377
|
readonly label: string | null;
|
|
378
|
+
/** Set by the spotlight on a node whose label already stands — see {@link hoverLabelPainter}. */
|
|
379
|
+
readonly forceLabel?: boolean;
|
|
378
380
|
}
|
|
379
381
|
|
|
380
382
|
/** The settings a hover painter reads. Both `GraphSettings` and sigma's satisfy it. */
|
|
@@ -431,6 +433,9 @@ export function hoverLabelPainter(
|
|
|
431
433
|
// `nodeReducer` blanks the label of everything outside the highlight, and
|
|
432
434
|
// a node with no name has nothing to float.
|
|
433
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;
|
|
434
439
|
const palette = graphPalette(scheme);
|
|
435
440
|
context.save();
|
|
436
441
|
context.font = `${settings.labelSize}px ${settings.labelFont}`;
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GraphTheme } from "../../shared/themes";
|
|
2
2
|
import type { Point } from "../../shared/layout";
|
|
3
|
-
import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
3
|
+
import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
4
|
import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
5
5
|
import type { ProjectedGraph } from "./project";
|
|
6
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
@@ -130,6 +130,8 @@ export function sigmaRenderer(
|
|
|
130
130
|
let highlight: ReadonlySet<string> | null = null;
|
|
131
131
|
/** The selection inside that neighbourhood — see `nodeReducer`. */
|
|
132
132
|
let selected: string | null = null;
|
|
133
|
+
/** Which treatment `highlight` gets — a neighbourhood or a spotlight. */
|
|
134
|
+
let style: HighlightStyle = "neighbourhood";
|
|
133
135
|
/** Recall-trail glows, layered over the selection — see `trailOverride`. */
|
|
134
136
|
let trail: ReadonlyMap<string, TrailGlow> = new Map();
|
|
135
137
|
/**
|
|
@@ -176,8 +178,8 @@ export function sigmaRenderer(
|
|
|
176
178
|
* until the next unrelated frame.
|
|
177
179
|
*/
|
|
178
180
|
const applyReducers = (instance: SigmaLike): void => {
|
|
179
|
-
const nodes = nodeReducer(highlight, selected, fade);
|
|
180
|
-
const edges = edgeReducer(highlight, selected, fade);
|
|
181
|
+
const nodes = nodeReducer(highlight, selected, fade, style);
|
|
182
|
+
const edges = edgeReducer(highlight, selected, fade, style);
|
|
181
183
|
const glows = trail;
|
|
182
184
|
instance.setSetting("nodeReducer", (id, data) => {
|
|
183
185
|
const reduced = { ...data, ...nodes(id, data, scheme) };
|
|
@@ -279,10 +281,11 @@ export function sigmaRenderer(
|
|
|
279
281
|
sigma?.refresh();
|
|
280
282
|
},
|
|
281
283
|
|
|
282
|
-
setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null) {
|
|
284
|
+
setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null, nextStyle: HighlightStyle = "neighbourhood") {
|
|
283
285
|
if (clock === undefined) {
|
|
284
286
|
highlight = next;
|
|
285
287
|
selected = selectedId;
|
|
288
|
+
style = nextStyle;
|
|
286
289
|
fade = next === null ? 0 : 1;
|
|
287
290
|
fadeTo = fade;
|
|
288
291
|
if (sigma !== null) applyReducers(sigma);
|
|
@@ -297,9 +300,11 @@ export function sigmaRenderer(
|
|
|
297
300
|
} else {
|
|
298
301
|
// A new neighbourhood replaces the old one immediately — a cross-fade
|
|
299
302
|
// between two of them is a picture of neither — and only the *presence*
|
|
300
|
-
// of the highlight is animated.
|
|
303
|
+
// of the highlight is animated. The style swaps with the set; a clear
|
|
304
|
+
// (above) keeps the outgoing style so a spotlight fades out *as* one.
|
|
301
305
|
highlight = next;
|
|
302
306
|
selected = selectedId;
|
|
307
|
+
style = nextStyle;
|
|
303
308
|
fadeTo = 1;
|
|
304
309
|
}
|
|
305
310
|
if (sigma !== null) applyReducers(sigma);
|
|
@@ -338,8 +343,17 @@ export function sigmaRenderer(
|
|
|
338
343
|
// onto that box. `animatedReset` settles when the animation ends. Nothing
|
|
339
344
|
// awaits a camera move, and a rejection from a camera killed mid-flight
|
|
340
345
|
// is noise.
|
|
341
|
-
sigma
|
|
342
|
-
|
|
346
|
+
const instance = sigma;
|
|
347
|
+
if (instance === null) return;
|
|
348
|
+
instance.setCustomBBox(boxOf());
|
|
349
|
+
// Sigma leaves edges out of frames drawn while the camera moves
|
|
350
|
+
// (`hideEdgesOnMove`) and repaints when the camera changes. A reset onto
|
|
351
|
+
// where the camera already is changes nothing, so the frame drawn during
|
|
352
|
+
// it, without edges, stayed on screen until something else repainted.
|
|
353
|
+
// One refresh once it settles draws them back.
|
|
354
|
+
instance.getCamera().animatedReset().then(() => {
|
|
355
|
+
if (sigma === instance) instance.refresh();
|
|
356
|
+
}, () => {});
|
|
343
357
|
},
|
|
344
358
|
|
|
345
359
|
positions() {
|