pi-weave 0.3.1 → 0.3.2
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 +14 -1
- package/package.json +2 -1
- package/src/web/client/dist/app.js +88 -124
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +11 -74
- package/src/web/client/graph/column.model.ts +3 -13
- package/src/web/client/graph/graph.model.ts +30 -62
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +2 -2
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +5 -4
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +11 -1
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +61 -24
- package/src/web/client/shell/StatusBar.tsx +1 -5
- package/src/web/client/shell/keys.model.ts +14 -8
- package/src/web/client/shell/shell.model.ts +1 -21
- package/src/web/client/shell/theme.model.ts +26 -96
- package/src/web/client/shell/theme.ts +20 -145
- package/src/web/client/shell/workspace.css.ts +27 -4
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +12 -1
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +44 -4
|
@@ -1,99 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import type { SliderSpec } from "./tuner.model";
|
|
16
|
-
import { FORCE_SLIDERS, HAIRBALL, forcesSnippet, formatValue, isDefault, parseSlider, sliderValue } from "./tuner.model";
|
|
17
|
-
|
|
18
|
-
export interface ForceTunerProps {
|
|
19
|
-
/**
|
|
20
|
-
* Called after every write to `FORCES`, so the column can drop its cached
|
|
21
|
-
* layout and re-run the simulation. The panel never lays out anything
|
|
22
|
-
* itself — it mutates the constants and says so.
|
|
23
|
-
*/
|
|
24
|
-
onChange: () => void;
|
|
25
|
-
/** Whether nodes are coloured by group hue (§15.8). */
|
|
26
|
-
groupColors: boolean;
|
|
27
|
-
/** Toggle that. Owned by the column, which persists it. */
|
|
28
|
-
onGroupColors: (next: boolean) => void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function ForceTuner(props: ForceTunerProps) {
|
|
32
|
-
// The panel's own re-render trigger. `FORCES` is a plain mutable object, so
|
|
33
|
-
// preact cannot observe it; a counter is the whole subscription.
|
|
34
|
-
const [, bump] = useState(0);
|
|
35
|
-
const [copied, setCopied] = useState(false);
|
|
36
|
-
|
|
37
|
-
const apply = (spec: SliderSpec, raw: string): void => {
|
|
38
|
-
setForces({ [spec.key]: parseSlider(spec, raw) });
|
|
39
|
-
setCopied(false);
|
|
40
|
-
bump((n) => n + 1);
|
|
41
|
-
props.onChange();
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const applyAll = (next: Partial<typeof FORCES>): void => {
|
|
45
|
-
setForces(next);
|
|
46
|
-
setCopied(false);
|
|
47
|
-
bump((n) => n + 1);
|
|
48
|
-
props.onChange();
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div class="weave-tuner">
|
|
53
|
-
<p class="weave-tuner-title">graph forces · ?sliders=1</p>
|
|
54
|
-
<label class="weave-tuner-toggle" title="hue per containment group, shade per kind">
|
|
55
|
-
<input type="checkbox" checked={props.groupColors} onChange={(event) => props.onGroupColors((event.currentTarget as HTMLInputElement).checked)} />
|
|
56
|
-
<span>colour by group</span>
|
|
57
|
-
</label>
|
|
58
|
-
{FORCE_SLIDERS.map((spec) => (
|
|
59
|
-
<label class="weave-tuner-row" key={spec.key} title={spec.hint}>
|
|
60
|
-
<span class="weave-tuner-label">
|
|
61
|
-
{spec.label}
|
|
62
|
-
<b>{formatValue(FORCES[spec.key])}</b>
|
|
63
|
-
</span>
|
|
64
|
-
<input
|
|
65
|
-
type="range"
|
|
66
|
-
min={spec.min}
|
|
67
|
-
max={spec.max}
|
|
68
|
-
step={spec.step}
|
|
69
|
-
value={sliderValue(FORCES, spec)}
|
|
70
|
-
onInput={(event) => apply(spec, (event.currentTarget as HTMLInputElement).value)}
|
|
71
|
-
/>
|
|
72
|
-
</label>
|
|
73
|
-
))}
|
|
74
|
-
<div class="weave-tuner-actions">
|
|
75
|
-
<button type="button" class="weave-chip" disabled={isDefault(FORCES)} onClick={() => applyAll(FORCE_DEFAULTS)}>
|
|
76
|
-
reset
|
|
77
|
-
</button>
|
|
78
|
-
<button type="button" class="weave-chip" title="the pre-tuner constants, for comparison — see HAIRBALL" onClick={() => applyAll(HAIRBALL)}>
|
|
79
|
-
before
|
|
80
|
-
</button>
|
|
81
|
-
<button
|
|
82
|
-
type="button"
|
|
83
|
-
class="weave-chip"
|
|
84
|
-
onClick={() => {
|
|
85
|
-
// Best-effort: `navigator.clipboard` is absent over plain HTTP in
|
|
86
|
-
// some browsers, and the snippet is on screen in the panel anyway.
|
|
87
|
-
void navigator.clipboard?.writeText(forcesSnippet(FORCES)).then(
|
|
88
|
-
() => setCopied(true),
|
|
89
|
-
() => setCopied(false),
|
|
90
|
-
);
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
93
|
-
{copied ? "copied ✓" : "copy values"}
|
|
94
|
-
</button>
|
|
95
|
-
</div>
|
|
96
|
-
<pre class="weave-tuner-snippet">{forcesSnippet(FORCES)}</pre>
|
|
97
|
-
</div>
|
|
98
|
-
);
|
|
1
|
+
/** Native graph layout sliders, available under Settings → Interface. */
|
|
2
|
+
import { FORCE_DEFAULTS, type ForceConstants } from "../../shared/layout";
|
|
3
|
+
import { FORCE_SLIDERS, formatValue, isDefault, parseSlider, sliderValue } from "./tuner.model";
|
|
4
|
+
|
|
5
|
+
export function ForceTuner(props: { forces: ForceConstants; onChange: (forces: ForceConstants) => void }) {
|
|
6
|
+
return <div class="weave-settings-forces">
|
|
7
|
+
{FORCE_SLIDERS.map((spec) => <label class="weave-setting-row" key={spec.key}>
|
|
8
|
+
<span><strong>{spec.label}</strong><small>{spec.hint}</small></span>
|
|
9
|
+
<span class="weave-setting-range"><input aria-label={spec.label} type="range" min={spec.min} max={spec.max} step={spec.step}
|
|
10
|
+
value={sliderValue(props.forces, spec)} onInput={(event) => props.onChange({ ...props.forces, [spec.key]: parseSlider(spec, event.currentTarget.value) })} />
|
|
11
|
+
<output>{formatValue(props.forces[spec.key])}</output></span>
|
|
12
|
+
</label>)}
|
|
13
|
+
<button type="button" disabled={isDefault(props.forces)} onClick={() => props.onChange({ ...FORCE_DEFAULTS })}>Reset graph layout</button>
|
|
14
|
+
</div>;
|
|
99
15
|
}
|
|
@@ -35,8 +35,6 @@ import type { GraphViewState } from "./column.model";
|
|
|
35
35
|
import {
|
|
36
36
|
FIT_HINT,
|
|
37
37
|
FIT_LABEL,
|
|
38
|
-
FORCES_HINT,
|
|
39
|
-
FORCES_LABEL,
|
|
40
38
|
LEGEND,
|
|
41
39
|
effectiveView,
|
|
42
40
|
graphClick,
|
|
@@ -49,12 +47,10 @@ import type { PositionStorage } from "./positions";
|
|
|
49
47
|
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
50
48
|
import { schemeOf } from "./scheme";
|
|
51
49
|
import type { SchemeHost } from "./scheme";
|
|
52
|
-
import type {
|
|
50
|
+
import type { GraphTheme } from "../../shared/themes";
|
|
51
|
+
import { setForces, type ForceConstants } from "../../shared/layout";
|
|
53
52
|
import { createGraphSimulation } from "./dynamics";
|
|
54
53
|
import type { GraphSimulation } from "./dynamics";
|
|
55
|
-
import { ForceTuner } from "./ForceTuner";
|
|
56
|
-
import { POSITIONS_STORAGE_KEY } from "./positions";
|
|
57
|
-
import { loadGroupColors, saveGroupColors } from "./tuner.model";
|
|
58
54
|
import { Icon } from "../tree/Tree";
|
|
59
55
|
|
|
60
56
|
export interface GraphProps {
|
|
@@ -76,7 +72,9 @@ export interface GraphProps {
|
|
|
76
72
|
* palette change `renderer.ts` calls honest — because a WebGL palette is
|
|
77
73
|
* fixed at construction.
|
|
78
74
|
*/
|
|
79
|
-
scheme:
|
|
75
|
+
scheme: GraphTheme | null;
|
|
76
|
+
forces: ForceConstants;
|
|
77
|
+
groupColors: boolean;
|
|
80
78
|
/**
|
|
81
79
|
* Whether the boot graph fetch failed (`state.ts`'s `graphFailed`). The
|
|
82
80
|
* empty column's sentence switches from "Loading…" to a named recovery:
|
|
@@ -94,14 +92,7 @@ export interface GraphProps {
|
|
|
94
92
|
* keeps the ownership where it is and costs one line at each end.
|
|
95
93
|
*/
|
|
96
94
|
fit: { current: (() => void) | null };
|
|
97
|
-
|
|
98
|
-
* Show the hidden force tuner (`?sliders=1`, docs/weave-workspace.md §15.7).
|
|
99
|
-
*
|
|
100
|
-
* A prop rather than a `location.search` read here, for the same reason
|
|
101
|
-
* `cwd` and `platform` are props: the decision is `slidersFlag`'s, and it is
|
|
102
|
-
* testable where a `location` read is not.
|
|
103
|
-
*/
|
|
104
|
-
tuner?: boolean;
|
|
95
|
+
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
export function Graph(props: GraphProps) {
|
|
@@ -134,30 +125,8 @@ export function Graph(props: GraphProps) {
|
|
|
134
125
|
// `null` means "the user has not touched the expansion" — not "nothing is
|
|
135
126
|
// expanded". `effectiveView` resolves the difference; see its doc comment.
|
|
136
127
|
const [state, setState] = useState<GraphViewState | null>(null);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
* below purely as a cache-buster: the graph's *shape* has not changed, so
|
|
140
|
-
* nothing else would recompute, and the whole point is that the same shape
|
|
141
|
-
* now lays out differently.
|
|
142
|
-
*/
|
|
143
|
-
const [forceRev, setForceRev] = useState(0);
|
|
144
|
-
/**
|
|
145
|
-
* Colour nodes by group hue (§15.8). Persisted, because it is a taste the
|
|
146
|
-
* user holds across sessions rather than a per-visit mode, and read through
|
|
147
|
-
* the same `PositionStorage` port the layout cache uses so the column still
|
|
148
|
-
* names no browser global.
|
|
149
|
-
*/
|
|
150
|
-
const [groupColors, setGroupColors] = useState(() => loadGroupColors(props.storage));
|
|
151
|
-
/**
|
|
152
|
-
* Whether the tuner panel is open.
|
|
153
|
-
*
|
|
154
|
-
* Seeded from the `?sliders=1` flag, then owned by the `[sliders]` chip — so
|
|
155
|
-
* the URL is still the way to arrive with it open, and the button is the way
|
|
156
|
-
* to get at it once you are here. The chip is always present: the panel was
|
|
157
|
-
* unreachable without knowing a query string, which is the right gate for a
|
|
158
|
-
* half-built instrument and the wrong one for a finished control.
|
|
159
|
-
*/
|
|
160
|
-
const [tunerOpen, setTunerOpen] = useState(props.tuner === true);
|
|
128
|
+
const forceKey = useMemo(() => { setForces(props.forces); return JSON.stringify(props.forces); }, [props.forces]);
|
|
129
|
+
const groupColors = props.groupColors;
|
|
161
130
|
|
|
162
131
|
// The shell's decision wins; `schemeOf` stays for a host-driven default.
|
|
163
132
|
const scheme = props.scheme ?? schemeOf(props.host);
|
|
@@ -172,7 +141,7 @@ export function Graph(props: GraphProps) {
|
|
|
172
141
|
// comparing set contents, the memo makes comparison unnecessary.
|
|
173
142
|
const model = useMemo(
|
|
174
143
|
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
|
|
175
|
-
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed,
|
|
144
|
+
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
|
|
176
145
|
);
|
|
177
146
|
const preview = graphPreview(props.graph, props.previewId);
|
|
178
147
|
|
|
@@ -238,7 +207,7 @@ export function Graph(props: GraphProps) {
|
|
|
238
207
|
|
|
239
208
|
useEffect(() => {
|
|
240
209
|
renderer.current?.setGraph(model.graph);
|
|
241
|
-
}, [model.key,
|
|
210
|
+
}, [model.key, forceKey, groupColors]);
|
|
242
211
|
|
|
243
212
|
// Live layout, in two effects so pause/resume and re-layout are independent.
|
|
244
213
|
//
|
|
@@ -254,7 +223,7 @@ export function Graph(props: GraphProps) {
|
|
|
254
223
|
return () => {
|
|
255
224
|
dynamics.current = null;
|
|
256
225
|
};
|
|
257
|
-
}, [model.key, armClock,
|
|
226
|
+
}, [model.key, armClock, forceKey]);
|
|
258
227
|
|
|
259
228
|
// Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is
|
|
260
229
|
// effect 1's, and the step self-terminates whenever the engine settles, so
|
|
@@ -294,42 +263,10 @@ export function Graph(props: GraphProps) {
|
|
|
294
263
|
<button type="button" class="weave-chip" onClick={() => props.onOpen(preview.id)}>Open in new tab</button>
|
|
295
264
|
</aside>
|
|
296
265
|
)}
|
|
297
|
-
{tunerOpen ? (
|
|
298
|
-
<ForceTuner
|
|
299
|
-
groupColors={groupColors}
|
|
300
|
-
onGroupColors={(next) => {
|
|
301
|
-
saveGroupColors(props.storage, next);
|
|
302
|
-
setGroupColors(next);
|
|
303
|
-
}}
|
|
304
|
-
onChange={() => {
|
|
305
|
-
// Poison the stored layout rather than reading it: the cache is
|
|
306
|
-
// keyed by graph *shape*, which a force change does not touch, so
|
|
307
|
-
// a hit would hand back the arrangement of the previous constants
|
|
308
|
-
// and the sliders would appear to do nothing. An unparseable entry
|
|
309
|
-
// is a miss by `deserializePositions`' contract, and the two-method
|
|
310
|
-
// `PositionStorage` port has no `removeItem` to call instead.
|
|
311
|
-
try {
|
|
312
|
-
props.storage.setItem(POSITIONS_STORAGE_KEY, "");
|
|
313
|
-
} catch {
|
|
314
|
-
// A storage that refuses writes still lays out; see `savePositions`.
|
|
315
|
-
}
|
|
316
|
-
setForceRev((n) => n + 1);
|
|
317
|
-
}}
|
|
318
|
-
/>
|
|
319
|
-
) : null}
|
|
320
266
|
<div class="weave-graph-controls">
|
|
321
267
|
<button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
|
|
322
268
|
{FIT_LABEL}
|
|
323
269
|
</button>
|
|
324
|
-
<button
|
|
325
|
-
type="button"
|
|
326
|
-
class={tunerOpen ? "weave-chip weave-chip-on" : "weave-chip"}
|
|
327
|
-
title={FORCES_HINT}
|
|
328
|
-
aria-pressed={tunerOpen}
|
|
329
|
-
onClick={() => setTunerOpen(!tunerOpen)}
|
|
330
|
-
>
|
|
331
|
-
{FORCES_LABEL}
|
|
332
|
-
</button>
|
|
333
270
|
<span class="weave-graph-legend">
|
|
334
271
|
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
335
272
|
<span class="weave-legend-near">● {LEGEND.neighborhood}</span>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GraphTheme } from "../../shared/themes";
|
|
1
2
|
/**
|
|
2
3
|
* The graph column's own state: what is expanded, what is highlighted, and
|
|
3
4
|
* what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3).
|
|
@@ -45,7 +46,7 @@ import { clusterAggregate, focusNeighborhood } from "../../shared/view";
|
|
|
45
46
|
import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
|
|
46
47
|
import { kindIcon, viewModel } from "../tree/tree.model";
|
|
47
48
|
import type { IconName } from "../shell/icons.model";
|
|
48
|
-
import type {
|
|
49
|
+
import type { RenderGraph } from "./graph.model";
|
|
49
50
|
import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
|
|
50
51
|
import { groupNodeColors } from "./groups";
|
|
51
52
|
import type { PositionStorage } from "./positions";
|
|
@@ -213,17 +214,6 @@ export function hoverHighlight(
|
|
|
213
214
|
export const FIT_LABEL = "fit";
|
|
214
215
|
export const FIT_HINT = "frame the whole graph";
|
|
215
216
|
|
|
216
|
-
/**
|
|
217
|
-
* The `[sliders]` control, which shows and hides the tuner panel (§15.7).
|
|
218
|
-
*
|
|
219
|
-
* Labelled for what the button *opens* rather than for what the panel edits:
|
|
220
|
-
* "forces" names the physics, which is the one thing a reader who has not read
|
|
221
|
-
* `layout.ts` has no word for. The constants keep the `FORCES_` prefix because
|
|
222
|
-
* the module they drive is still the force layout.
|
|
223
|
-
*/
|
|
224
|
-
export const FORCES_LABEL = "sliders";
|
|
225
|
-
export const FORCES_HINT = "tune the layout physics and colours";
|
|
226
|
-
|
|
227
217
|
/**
|
|
228
218
|
* The legend under the canvas, from the §1.2 mock:
|
|
229
219
|
* `◉ selected ● neighborhood`.
|
|
@@ -324,7 +314,7 @@ export function graphColumnModel(
|
|
|
324
314
|
selectedId: string | null,
|
|
325
315
|
state: GraphViewState,
|
|
326
316
|
storage: PositionStorage,
|
|
327
|
-
scheme:
|
|
317
|
+
scheme: GraphTheme,
|
|
328
318
|
// Optional so every existing caller and test keeps its shape; only the
|
|
329
319
|
// shell's boot-failure signal has a reason to pass it.
|
|
330
320
|
bootFailed = false,
|
|
@@ -27,21 +27,12 @@
|
|
|
27
27
|
* graphology — so it compiles under the root `tsconfig.json` (which has no
|
|
28
28
|
* `DOM` lib) whenever a test imports it.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* WebGL cannot read a CSS custom property. `sigma` needs a concrete `#rrggbb`
|
|
33
|
-
* per node, and `getComputedStyle` is both a DOM global and an untestable
|
|
34
|
-
* read. So {@link GRAPH_PALETTE} restates the six theme colours the graph
|
|
35
|
-
* uses, and {@link KIND_SLOT} maps a node kind onto the *same slot vocabulary*
|
|
36
|
-
* the TUI already uses (`src/pi/viewer/tui/theme.ts`'s `kindStyle`) rather
|
|
37
|
-
* than inventing a second one.
|
|
38
|
-
*
|
|
39
|
-
* A copy drifts, so drift is a failing test: `tests/web/client-graph.test.ts`
|
|
40
|
-
* asserts every hex in {@link GRAPH_PALETTE} literally appears in
|
|
41
|
-
* `shell/theme.ts`'s `THEME_CSS`. Change a swatch in the stylesheet and the
|
|
42
|
-
* graph's copy goes red on the same commit.
|
|
30
|
+
* CSS and WebGL use the shared theme table, so a candidate changes the
|
|
31
|
+
* canvas and workspace together without copying hex values.
|
|
43
32
|
*/
|
|
44
33
|
|
|
34
|
+
import { THEMES, accentColor, themeId } from "../../shared/themes";
|
|
35
|
+
import type { PaletteChoice, GraphTheme } from "../../shared/themes";
|
|
45
36
|
import { COLLIDE_RADIUS, MAX_NODE_SIZE, MIN_NODE_SIZE, nodeSize } from "../../shared/layout";
|
|
46
37
|
import type { Point } from "../../shared/layout";
|
|
47
38
|
/* Re-exported for the renderer and the tests, under the render model's name.
|
|
@@ -65,40 +56,17 @@ export type ColorSlot = "accent" | "success" | "warning" | "dim" | "text" | "mut
|
|
|
65
56
|
/** Dark or light. Chosen by the shell from `prefers-color-scheme`, never read here. */
|
|
66
57
|
export type ColorScheme = "dark" | "light";
|
|
67
58
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*/
|
|
80
|
-
export const GRAPH_PALETTE: Readonly<Record<ColorScheme, Readonly<Record<ColorSlot, string>>>> = {
|
|
81
|
-
dark: {
|
|
82
|
-
accent: "#c6a0f6",
|
|
83
|
-
success: "#a6da95",
|
|
84
|
-
warning: "#eed49f",
|
|
85
|
-
dim: "#a5adcb",
|
|
86
|
-
text: "#cad3f5",
|
|
87
|
-
muted: "#939ab7",
|
|
88
|
-
line: "#494d64",
|
|
89
|
-
ground: "#24273a",
|
|
90
|
-
},
|
|
91
|
-
light: {
|
|
92
|
-
accent: "#7113ec",
|
|
93
|
-
success: "#28641b",
|
|
94
|
-
warning: "#7c4f10",
|
|
95
|
-
dim: "#56586a",
|
|
96
|
-
text: "#4c4f69",
|
|
97
|
-
muted: "#606274",
|
|
98
|
-
line: "#bcc0cc",
|
|
99
|
-
ground: "#eff1f5",
|
|
100
|
-
},
|
|
101
|
-
};
|
|
59
|
+
/** Graph slots come from the same colors as the stylesheet. */
|
|
60
|
+
export const GRAPH_PALETTE = Object.fromEntries(Object.entries(THEMES).map(([id, { colors }]) => [id, {
|
|
61
|
+
accent: colors.accent, success: colors.ok, warning: colors.warn, dim: colors.dim,
|
|
62
|
+
text: colors.fg, muted: colors.faint, line: colors["line-strong"], ground: colors.bg,
|
|
63
|
+
}])) as Readonly<Record<PaletteChoice, Readonly<Record<ColorSlot, string>>>>;
|
|
64
|
+
|
|
65
|
+
/** Resolve independent accents for every WebGL drawing path. */
|
|
66
|
+
export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, string>> {
|
|
67
|
+
const palette = GRAPH_PALETTE[themeId(theme)];
|
|
68
|
+
return typeof theme === "string" ? palette : { ...palette, accent: accentColor(theme.theme, theme.accent) };
|
|
69
|
+
}
|
|
102
70
|
|
|
103
71
|
/**
|
|
104
72
|
* Node kind → colour slot.
|
|
@@ -156,13 +124,13 @@ export const EDGE_SLOT: Readonly<Record<WireEdgeKind, ColorSlot>> = {
|
|
|
156
124
|
};
|
|
157
125
|
|
|
158
126
|
/** The colour a node kind is drawn in. */
|
|
159
|
-
export function kindColor(kind: WireNodeKind, scheme:
|
|
160
|
-
return
|
|
127
|
+
export function kindColor(kind: WireNodeKind, scheme: GraphTheme): string {
|
|
128
|
+
return graphPalette(scheme)[KIND_SLOT[kind]];
|
|
161
129
|
}
|
|
162
130
|
|
|
163
131
|
/** The colour an edge kind is drawn in. */
|
|
164
|
-
export function edgeColor(kind: WireEdgeKind, scheme:
|
|
165
|
-
return
|
|
132
|
+
export function edgeColor(kind: WireEdgeKind, scheme: GraphTheme): string {
|
|
133
|
+
return graphPalette(scheme)[EDGE_SLOT[kind]];
|
|
166
134
|
}
|
|
167
135
|
|
|
168
136
|
/**
|
|
@@ -178,7 +146,7 @@ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
|
|
|
178
146
|
* exact slot colour — the accent is the only chroma voice the sheet has, and
|
|
179
147
|
* a wikilink is what the eye is here to find.
|
|
180
148
|
*/
|
|
181
|
-
export function edgeDrawColor(kind: WireEdgeKind, scheme:
|
|
149
|
+
export function edgeDrawColor(kind: WireEdgeKind, scheme: GraphTheme): string {
|
|
182
150
|
return isStructuralEdge(kind) ? recessColor(edgeColor(kind, scheme), scheme, EDGE_RECESS_STRENGTH) : edgeColor(kind, scheme);
|
|
183
151
|
}
|
|
184
152
|
|
|
@@ -253,8 +221,8 @@ export function blendHex(color: string, toward: string, t: number): string {
|
|
|
253
221
|
* *palette* fact, and the palette-mirror test already ties this module's hexes
|
|
254
222
|
* to the stylesheet's.
|
|
255
223
|
*/
|
|
256
|
-
export function recessColor(color: string, scheme:
|
|
257
|
-
return blendHex(color,
|
|
224
|
+
export function recessColor(color: string, scheme: GraphTheme, t: number = RECESS_STRENGTH): string {
|
|
225
|
+
return blendHex(color, graphPalette(scheme).ground, t);
|
|
258
226
|
}
|
|
259
227
|
|
|
260
228
|
// --- sizes ------------------------------------------------------------------------
|
|
@@ -460,13 +428,13 @@ export const HOVER_LABEL_SHADOW = 6;
|
|
|
460
428
|
* re-align them all.
|
|
461
429
|
*/
|
|
462
430
|
export function hoverLabelPainter(
|
|
463
|
-
scheme:
|
|
431
|
+
scheme: GraphTheme,
|
|
464
432
|
): (context: HoverLabelContext, data: HoverLabelNode, settings: HoverLabelSettings) => void {
|
|
465
433
|
return (context, data, settings) => {
|
|
466
434
|
// `nodeReducer` blanks the label of everything outside the highlight, and
|
|
467
435
|
// a node with no name has nothing to float.
|
|
468
436
|
if (data.label === null || data.label === "") return;
|
|
469
|
-
const palette =
|
|
437
|
+
const palette = graphPalette(scheme);
|
|
470
438
|
context.save();
|
|
471
439
|
context.font = `${settings.labelSize}px ${settings.labelFont}`;
|
|
472
440
|
context.textAlign = "center";
|
|
@@ -629,7 +597,7 @@ export function renderGraph(
|
|
|
629
597
|
nodes: readonly WireGraphNode[],
|
|
630
598
|
edges: readonly WireGraphEdge[],
|
|
631
599
|
positions: ReadonlyMap<string, Point>,
|
|
632
|
-
scheme:
|
|
600
|
+
scheme: GraphTheme,
|
|
633
601
|
/**
|
|
634
602
|
* Per-node fills that override the kind palette — `groups.ts`'s
|
|
635
603
|
* group-hue assignment, or absent for the kind-only colouring.
|
|
@@ -747,7 +715,7 @@ export function nodeReducer(
|
|
|
747
715
|
* about the clock.
|
|
748
716
|
*/
|
|
749
717
|
progress: number = 1,
|
|
750
|
-
): (id: string, data: RenderNode, scheme:
|
|
718
|
+
): (id: string, data: RenderNode, scheme: GraphTheme) => NodeDisplayOverride {
|
|
751
719
|
return (id, data, scheme) => {
|
|
752
720
|
// `progress` at zero is indistinguishable from no highlight, and saying so
|
|
753
721
|
// here is what lets the fade end by *returning* to the unhighlighted graph
|
|
@@ -888,7 +856,7 @@ export function edgeReducer(
|
|
|
888
856
|
selectedId?: string | null,
|
|
889
857
|
/** The fade's progress — see {@link nodeReducer}'s. */
|
|
890
858
|
progress: number = 1,
|
|
891
|
-
): (key: string, data: RenderEdge, scheme:
|
|
859
|
+
): (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride {
|
|
892
860
|
return (_key, data, scheme) => {
|
|
893
861
|
if (highlight === null || progress <= 0) return {};
|
|
894
862
|
if (!highlight.has(data.source) || !highlight.has(data.target)) {
|
|
@@ -905,7 +873,7 @@ export function edgeReducer(
|
|
|
905
873
|
size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
|
|
906
874
|
// Blended rather than switched, so the link arrives *as* the accent
|
|
907
875
|
// instead of flicking to it a frame before the rest of the fade.
|
|
908
|
-
color: blendHex(data.color,
|
|
876
|
+
color: blendHex(data.color, graphPalette(scheme).accent, progress),
|
|
909
877
|
};
|
|
910
878
|
}
|
|
911
879
|
// Between two neighbours, but not touching the selection: real context,
|
|
@@ -975,8 +943,8 @@ export const LABEL_GRID_CELL_SIZE = COLLIDE_RADIUS * 4;
|
|
|
975
943
|
export const LABEL_DENSITY = 1;
|
|
976
944
|
|
|
977
945
|
/** The settings for a scheme. */
|
|
978
|
-
export function graphSettings(scheme:
|
|
979
|
-
const palette =
|
|
946
|
+
export function graphSettings(scheme: GraphTheme): GraphSettings {
|
|
947
|
+
const palette = graphPalette(scheme);
|
|
980
948
|
return {
|
|
981
949
|
// §7.4. A pan over a few thousand edges is the one interaction that drops
|
|
982
950
|
// frames, and the edges are the part nobody is reading mid-gesture.
|
|
@@ -56,8 +56,10 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import type { WireEdgeKind, WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
59
|
+
import type { GraphTheme } from "../../shared/themes";
|
|
60
|
+
import { THEMES, themeId } from "../../shared/themes";
|
|
59
61
|
import type { ColorScheme } from "./graph.model";
|
|
60
|
-
import {
|
|
62
|
+
import { graphPalette, blendHex } from "./graph.model";
|
|
61
63
|
|
|
62
64
|
function isContainment(kind: WireEdgeKind): boolean {
|
|
63
65
|
return kind === "contains" || kind === "anchored-at";
|
|
@@ -219,8 +221,8 @@ export const HUE_STRIDE = 3;
|
|
|
219
221
|
* is honest: at eleven simultaneous groups the colour channel is saturated and
|
|
220
222
|
* position is doing the work anyway.
|
|
221
223
|
*/
|
|
222
|
-
export function groupColors(keys: ReadonlyMap<string, string>, scheme:
|
|
223
|
-
const ring = GROUP_HUES[scheme];
|
|
224
|
+
export function groupColors(keys: ReadonlyMap<string, string>, scheme: GraphTheme): Map<string, string> {
|
|
225
|
+
const ring = GROUP_HUES[THEMES[themeId(scheme)].scheme];
|
|
224
226
|
const sizes = groupSizes(keys);
|
|
225
227
|
const ordered = [...sizes.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
226
228
|
const out = new Map<string, string>();
|
|
@@ -249,9 +251,9 @@ export const DEPTH_SHADE = 0.11;
|
|
|
249
251
|
export const MAX_SHADE = 0.34;
|
|
250
252
|
|
|
251
253
|
/** A member's fill: its group's one hue, stepped back by its depth. */
|
|
252
|
-
export function shadeFor(hue: string, depth: number, scheme:
|
|
254
|
+
export function shadeFor(hue: string, depth: number, scheme: GraphTheme): string {
|
|
253
255
|
const steps = Number.isFinite(depth) && depth > 0 ? depth : 0;
|
|
254
|
-
return blendHex(hue,
|
|
256
|
+
return blendHex(hue, graphPalette(scheme).ground, Math.min(MAX_SHADE, steps * DEPTH_SHADE));
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
// --- bridges between groups ----------------------------------------------------------
|
|
@@ -279,7 +281,7 @@ export const MAX_BRIDGE = 0.55;
|
|
|
279
281
|
* promiscuously-linked hub does not end up painted entirely in other people's
|
|
280
282
|
* colours.
|
|
281
283
|
*/
|
|
282
|
-
export function bridgeBlend(own: string, foreign: readonly string[], scheme:
|
|
284
|
+
export function bridgeBlend(own: string, foreign: readonly string[], scheme: GraphTheme): string {
|
|
283
285
|
if (foreign.length === 0) return own;
|
|
284
286
|
let out = own;
|
|
285
287
|
let spent = 0;
|
|
@@ -307,7 +309,7 @@ export function bridgeBlend(own: string, foreign: readonly string[], scheme: Col
|
|
|
307
309
|
export function groupNodeColors(
|
|
308
310
|
nodes: readonly WireGraphNode[],
|
|
309
311
|
edges: readonly WireGraphEdge[],
|
|
310
|
-
scheme:
|
|
312
|
+
scheme: GraphTheme,
|
|
311
313
|
): Map<string, string> {
|
|
312
314
|
const places = groupPlaces(nodes, edges);
|
|
313
315
|
const keys = new Map<string, string>();
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* the root `tsconfig.json` project compiles the tests.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import { computeLayout } from "../../shared/layout";
|
|
37
|
+
import { computeLayout, FORCES } from "../../shared/layout";
|
|
38
38
|
import type { LayoutOptions, Point } from "../../shared/layout";
|
|
39
39
|
import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
40
40
|
|
|
@@ -164,7 +164,7 @@ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly W
|
|
|
164
164
|
sum = (sum + h) >>> 0;
|
|
165
165
|
}
|
|
166
166
|
const parts = [nodes.length, edges.length, xor >>> 0, sum].map((n) => n.toString(16));
|
|
167
|
-
return parts.join("-");
|
|
167
|
+
return [...parts, hashId(JSON.stringify(FORCES)).toString(16)].join("-");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Browser-only Sigma adapter; the lifecycle remains testable in renderer.ts. */
|
|
2
2
|
import Sigma from "sigma";
|
|
3
|
-
import type {
|
|
3
|
+
import type { GraphTheme } from "../../shared/themes";
|
|
4
4
|
import type { GraphRenderer, RendererFactory, SigmaLike } from "./renderer";
|
|
5
5
|
import { rafClock, sigmaRenderer } from "./renderer";
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ import { rafClock, sigmaRenderer } from "./renderer";
|
|
|
8
8
|
type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
|
|
9
9
|
|
|
10
10
|
/** The browser's renderer factory. Passed to the graph column by the shell. */
|
|
11
|
-
export const createSigmaRenderer: RendererFactory = (scheme:
|
|
11
|
+
export const createSigmaRenderer: RendererFactory = (scheme: GraphTheme): GraphRenderer =>
|
|
12
12
|
sigmaRenderer(
|
|
13
13
|
(graph, container, settings) =>
|
|
14
14
|
new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { GraphTheme } from "../../shared/themes";
|
|
1
2
|
import type { Point } from "../../shared/layout";
|
|
2
|
-
import type {
|
|
3
|
+
import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
|
|
3
4
|
import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
|
|
4
5
|
import type { ProjectedGraph } from "./project";
|
|
5
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
@@ -25,7 +26,7 @@ export interface RenderContainer {
|
|
|
25
26
|
* driven by a recording fake in a test and by `createSigmaRenderer` in the
|
|
26
27
|
* browser — the same port-shaped injection `api.ts` uses for `fetch`.
|
|
27
28
|
*/
|
|
28
|
-
export type RendererFactory = (scheme:
|
|
29
|
+
export type RendererFactory = (scheme: GraphTheme) => GraphRenderer;
|
|
29
30
|
|
|
30
31
|
// --- the sigma port -----------------------------------------------------------------
|
|
31
32
|
|
|
@@ -53,7 +54,7 @@ export interface SigmaLike {
|
|
|
53
54
|
on(event: "upNode" | "upStage", handler: () => void): unknown;
|
|
54
55
|
viewportToGraph(position: { x: number; y: number }): Point;
|
|
55
56
|
setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
|
|
56
|
-
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme:
|
|
57
|
+
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride): unknown;
|
|
57
58
|
setSetting(key: "enableCameraPanning", value: boolean): unknown;
|
|
58
59
|
setGraph(graph: ProjectedGraph): unknown;
|
|
59
60
|
/**
|
|
@@ -115,7 +116,7 @@ export const rafClock = (host: {
|
|
|
115
116
|
|
|
116
117
|
export function sigmaRenderer(
|
|
117
118
|
create: (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike,
|
|
118
|
-
scheme:
|
|
119
|
+
scheme: GraphTheme,
|
|
119
120
|
/**
|
|
120
121
|
* The fade clock, or omitted for no animation at all — in which case every
|
|
121
122
|
* highlight change lands at full strength on the next frame, which is the
|