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.
@@ -1,99 +1,15 @@
1
- /**
2
- * The hidden force tuner panel (docs/weave-workspace.md §15.7).
3
- *
4
- * Props in, JSX out. Every range, label, parse and clamp comes from
5
- * `tuner.model.ts`; what is left here is seven `<input type="range">` elements
6
- * and two buttons. Native range inputs rather than a slider component, for the
7
- * obvious reason: the platform has had this element since 2011.
8
- *
9
- * Rendered only when `Graph.tsx` was handed `tuner` — that is, only when
10
- * `?sliders=1` was on the URL. See `slidersFlag`.
11
- */
12
-
13
- import { useState } from "preact/hooks";
14
- import { FORCES, FORCE_DEFAULTS, setForces } from "../../shared/layout";
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 { ColorScheme } from "./graph.model";
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: ColorScheme | null;
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
- * Bumped by the tuner after each write to `FORCES`. It enters the model memo
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, forceRev, groupColors],
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, forceRev, groupColors]);
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, forceRev]);
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 { ColorScheme, RenderGraph } from "./graph.model";
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: ColorScheme,
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
- * ## Why the palette is duplicated from the stylesheet
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
- * Slot → hex, per scheme.
70
- *
71
- * Every value is copied from `shell/theme.ts`'s `THEME_CSS`:
72
- * `accent`→`--weave-accent`, `success`→`--weave-ok`, `warning`→`--weave-warn`,
73
- * `dim`→`--weave-dim`, `text`→`--weave-fg`, `muted`→`--weave-faint`,
74
- * `line`→`--weave-line-strong`, `ground`→`--weave-bg` (the canvas the WebGL
75
- * floats on, which no slot previously named because nothing painted it — the
76
- * recession blend below needs it as a *value*, not as a contrast judgement).
77
- * The dark block is `:root`; the light block is the
78
- * `prefers-color-scheme: light` override.
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: ColorScheme): string {
160
- return GRAPH_PALETTE[scheme][KIND_SLOT[kind]];
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: ColorScheme): string {
165
- return GRAPH_PALETTE[scheme][EDGE_SLOT[kind]];
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: ColorScheme): string {
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: ColorScheme, t: number = RECESS_STRENGTH): string {
257
- return blendHex(color, GRAPH_PALETTE[scheme].ground, t);
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: ColorScheme,
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 = GRAPH_PALETTE[scheme];
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: ColorScheme,
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: ColorScheme) => NodeDisplayOverride {
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: ColorScheme) => EdgeDisplayOverride {
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, GRAPH_PALETTE[scheme].accent, progress),
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: ColorScheme): GraphSettings {
979
- const palette = GRAPH_PALETTE[scheme];
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 { GRAPH_PALETTE, blendHex } from "./graph.model";
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: ColorScheme): Map<string, string> {
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: ColorScheme): string {
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, GRAPH_PALETTE[scheme].ground, Math.min(MAX_SHADE, steps * DEPTH_SHADE));
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: ColorScheme): string {
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: ColorScheme,
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 { ColorScheme } from "./graph.model";
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: ColorScheme): GraphRenderer =>
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 { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
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: ColorScheme) => GraphRenderer;
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: ColorScheme) => EdgeDisplayOverride): unknown;
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: ColorScheme,
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