pi-weave 0.1.19 → 0.1.21

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Group identity as colour (weave-workspace §7.4, §15.8).
3
+ *
4
+ * The forces separate the graph into blobs; this decides what each blob is
5
+ * *called* in colour. The two must agree on what a group is, or the picture
6
+ * says one thing and the physics another — so a group here is exactly what
7
+ * `FORCES` pulls apart: a **depth-1 containment branch**.
8
+ *
9
+ * ```text
10
+ * vault ← a root: its own group
11
+ * ├── vfolder:sessions ← group A, with all 323 descendants
12
+ * ├── vfolder:manager-digest ← group B, with all 159
13
+ * └── note:loose ← no branch of its own: joins the root's group
14
+ * ```
15
+ *
16
+ * Depth 1 exactly, for the reason the retired `bigBranches` used it: deeper
17
+ * groupings shred a nested module tree into a rainbow, and the tangle a reader
18
+ * actually needs help with is always *sibling* blobs sharing a centre.
19
+ *
20
+ * ## One colour per group, a ramp inside it
21
+ *
22
+ * Each group takes **one** Catppuccin accent — its primary — and every member
23
+ * draws a shade of that single hue. Depth in the containment tree drives the
24
+ * ramp: the branch anchor at full strength, its children a step toward the
25
+ * ground, their children a step further. A group therefore reads as one colour
26
+ * family with its own internal structure, rather than as a set of unrelated
27
+ * swatches that merely happen to sit together.
28
+ *
29
+ * Depth rather than kind drives the ramp because kind does not vary inside a
30
+ * real group. Measured on this vault: colouring by kind gave every folder
31
+ * exactly **two** distinct fills (the folder, and 300-odd identical notes),
32
+ * which is why the result read as flat-but-arbitrary.
33
+ *
34
+ * ## A node that bridges groups shows both
35
+ *
36
+ * A note linking into another group is the interesting node on the canvas, and
37
+ * painting it purely in its own colour hides that. {@link bridgeBlend} mixes a
38
+ * measured share of each foreign group's primary into such a node, so a bridge
39
+ * is visibly part-way between the two families it joins. Containment is
40
+ * excluded — that is what put it in its group in the first place — so only
41
+ * genuine `links-to` / `mentions` associations tint.
42
+ *
43
+ * ## Why these hexes are not in the stylesheet
44
+ *
45
+ * Every other colour in `graph.model.ts` is mirrored from `THEME_CSS` and
46
+ * guarded by a drift test, because the sheet paints the same thing in CSS.
47
+ * Nothing in the sheet paints a graph group, so there is nothing to mirror and
48
+ * a mirror test would be theatre. The guarantee that matters here is
49
+ * *contrast*, and that is asserted directly: every hue, at every depth of the
50
+ * ramp, clears 3:1 against its scheme's ground (the WCAG non-text minimum).
51
+ *
52
+ * ## Tier rules (§2)
53
+ *
54
+ * `src/web/client/**`, no DOM, no npm — compiles under the root
55
+ * `tsconfig.json` so its tests are ordinary ones.
56
+ */
57
+
58
+ import type { WireEdgeKind, WireGraphEdge, WireGraphNode } from "../../shared/wire";
59
+ import type { ColorScheme } from "./graph.model";
60
+ import { GRAPH_PALETTE, blendHex } from "./graph.model";
61
+
62
+ function isContainment(kind: WireEdgeKind): boolean {
63
+ return kind === "contains" || kind === "anchored-at";
64
+ }
65
+
66
+ /** First-containment-parent map, the spine every walk below shares. */
67
+ function parents(nodes: readonly WireGraphNode[], edges: readonly WireGraphEdge[]): Map<string, string> {
68
+ const known = new Set(nodes.map((node) => node.id));
69
+ const parent = new Map<string, string>();
70
+ for (const edge of edges) {
71
+ if (!isContainment(edge.kind)) continue;
72
+ if (edge.source === edge.target) continue;
73
+ if (!known.has(edge.source) || !known.has(edge.target)) continue;
74
+ // A second containment parent (a cycle, a hand-edited index) must not turn
75
+ // the walk into a diamond: first edge wins, like `layout.ts`'s `analyse`.
76
+ if (parent.has(edge.target)) continue;
77
+ parent.set(edge.target, edge.source);
78
+ }
79
+ return parent;
80
+ }
81
+
82
+ // --- which group a node belongs to, and how deep inside it ------------------------
83
+
84
+ /** Where a node sits: which group, and how far below that group's anchor. */
85
+ export interface GroupPlace {
86
+ /** The depth-1 branch id this node hangs from — its group. */
87
+ readonly key: string;
88
+ /** 0 for the group's anchor, 1 for its children, and so on. */
89
+ readonly depth: number;
90
+ }
91
+
92
+ /**
93
+ * Node id → its group and depth, for every node.
94
+ *
95
+ * The key is the id of the depth-1 branch the node sits under, or the node's
96
+ * own id when there is no branch between it and the top (a root itself, a
97
+ * loose note, an isolated island). Every node gets a place, so a caller never
98
+ * has to decide what an absent one means.
99
+ *
100
+ * The walk is bounded by a `seen` set, so a containment cycle terminates
101
+ * rather than hanging the column.
102
+ */
103
+ export function groupPlaces(
104
+ nodes: readonly WireGraphNode[],
105
+ edges: readonly WireGraphEdge[],
106
+ ): Map<string, GroupPlace> {
107
+ const parent = parents(nodes, edges);
108
+ const out = new Map<string, GroupPlace>();
109
+ for (const node of nodes) {
110
+ // Walk to the root, remembering the last step before it: that step is the
111
+ // depth-1 branch, and it is the group. Distance travelled past it is the
112
+ // node's depth inside the group.
113
+ let current = node.id;
114
+ let previous = node.id;
115
+ let steps = 0;
116
+ const seen = new Set<string>([current]);
117
+ for (;;) {
118
+ const next = parent.get(current);
119
+ if (next === undefined || seen.has(next)) break;
120
+ seen.add(next);
121
+ previous = current;
122
+ current = next;
123
+ steps++;
124
+ }
125
+ // `steps` counts hops to the root; the group anchor is one hop below it,
126
+ // so depth inside the group is one less. A root itself is depth 0.
127
+ out.set(node.id, { key: previous, depth: Math.max(0, steps - 1) });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Node id → group key. The projection most callers want. */
133
+ export function groupKeys(
134
+ nodes: readonly WireGraphNode[],
135
+ edges: readonly WireGraphEdge[],
136
+ ): Map<string, string> {
137
+ const out = new Map<string, string>();
138
+ for (const [id, place] of groupPlaces(nodes, edges)) out.set(id, place.key);
139
+ return out;
140
+ }
141
+
142
+ /** Group key → how many nodes it holds. Biggest groups get the first hues. */
143
+ export function groupSizes(keys: ReadonlyMap<string, string>): Map<string, number> {
144
+ const out = new Map<string, number>();
145
+ for (const key of keys.values()) out.set(key, (out.get(key) ?? 0) + 1);
146
+ return out;
147
+ }
148
+
149
+ // --- the hues ---------------------------------------------------------------------
150
+
151
+ /**
152
+ * The hue ring: Catppuccin's named accents, **in hue-wheel order**.
153
+ *
154
+ * Dark is **Macchiato** and light is **Latte**, the two flavours `THEME_CSS`
155
+ * is already built from, so the graph reads as the same product even though
156
+ * these particular swatches appear nowhere else in the sheet.
157
+ *
158
+ * Wheel order (pink → mauve → red → peach → yellow → green → teal → sapphire
159
+ * → blue → lavender) is what makes the *walk* across it meaningful: the ring
160
+ * is stepped by {@link HUE_STRIDE} rather than one at a time, so consecutive
161
+ * groups land on opposite sides of the wheel and two adjacent blobs are never
162
+ * two neighbouring blues. An arbitrary hand-ordered list gave no such
163
+ * guarantee, which is what made the previous assignment look random.
164
+ *
165
+ * The light row is *deepened* from stock Latte, exactly as the stylesheet
166
+ * deepens its own status colours (`--weave-ok:#28641b` is not Latte green).
167
+ * Stock Latte accents sit at 2.3–3.0:1 on `#eff1f5`, under the 3:1 non-text
168
+ * floor — pretty on a marketing page, invisible as a 6-pixel disc. Each was
169
+ * darkened along its own hue until the **deepest step of the ramp** still
170
+ * cleared 3:1, which is a stronger condition than the hue alone clearing it.
171
+ */
172
+ export const GROUP_HUES: Readonly<Record<ColorScheme, readonly string[]>> = {
173
+ dark: [
174
+ "#f5bde6", // pink
175
+ "#c6a0f6", // mauve — the shell's own accent
176
+ "#ed8796", // red
177
+ "#f5a97f", // peach
178
+ "#eed49f", // yellow
179
+ "#a6da95", // green
180
+ "#8bd5ca", // teal
181
+ "#7dc4e4", // sapphire
182
+ "#8aadf4", // blue
183
+ "#b7bdf8", // lavender
184
+ ],
185
+ light: [
186
+ "#824171", // pink
187
+ "#7832d4", // mauve
188
+ "#d20f39", // red
189
+ "#9f3e07", // peach
190
+ "#7b4e10", // yellow
191
+ "#28641b", // green
192
+ "#106368", // teal
193
+ "#14616e", // sapphire
194
+ "#1853c8", // blue
195
+ "#445198", // lavender
196
+ ],
197
+ };
198
+
199
+ /**
200
+ * How far to step along the hue wheel between consecutive groups.
201
+ *
202
+ * 3 against a 10-entry ring visits every slot before repeating (3 and 10 are
203
+ * coprime) while putting roughly a third of the wheel between one group and
204
+ * the next — so the two largest blobs, which is what the eye lands on first,
205
+ * are always strongly separated in hue. A stride of 1 would hand the two
206
+ * biggest groups adjacent, easily-confused colours.
207
+ */
208
+ export const HUE_STRIDE = 3;
209
+
210
+ /**
211
+ * Group key → its one primary hue, assigned **biggest group first**.
212
+ *
213
+ * Size order rather than id order, because the biggest blob is the one a
214
+ * reader orients by and it should get the most distinct colours first. Ties
215
+ * break on the key, so the assignment is a pure function of the graph and
216
+ * never of insertion order.
217
+ *
218
+ * More groups than hues wraps the ring. Two groups then share a colour, which
219
+ * is honest: at eleven simultaneous groups the colour channel is saturated and
220
+ * position is doing the work anyway.
221
+ */
222
+ export function groupColors(keys: ReadonlyMap<string, string>, scheme: ColorScheme): Map<string, string> {
223
+ const ring = GROUP_HUES[scheme];
224
+ const sizes = groupSizes(keys);
225
+ const ordered = [...sizes.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
226
+ const out = new Map<string, string>();
227
+ ordered.forEach(([key], index) => out.set(key, ring[(index * HUE_STRIDE) % ring.length]!));
228
+ return out;
229
+ }
230
+
231
+ // --- the ramp inside one group ------------------------------------------------------
232
+
233
+ /**
234
+ * How far one level of depth steps the hue toward the ground.
235
+ *
236
+ * Small, and capped by {@link MAX_SHADE}: the ramp exists to show structure
237
+ * *within* one colour family, and a step big enough to read as a different
238
+ * colour would undo the grouping it is meant to express.
239
+ */
240
+ export const DEPTH_SHADE = 0.11;
241
+
242
+ /**
243
+ * The deepest any member may be shaded.
244
+ *
245
+ * This is the number the light ring is deepened against — every hue must still
246
+ * clear 3:1 on its ground *at this shade*, which the contrast test asserts for
247
+ * every hue at every depth.
248
+ */
249
+ export const MAX_SHADE = 0.34;
250
+
251
+ /** 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 {
253
+ const steps = Number.isFinite(depth) && depth > 0 ? depth : 0;
254
+ return blendHex(hue, GRAPH_PALETTE[scheme].ground, Math.min(MAX_SHADE, steps * DEPTH_SHADE));
255
+ }
256
+
257
+ // --- bridges between groups ----------------------------------------------------------
258
+
259
+ /**
260
+ * How much of a foreign group's colour a bridging node takes on, per group it
261
+ * reaches.
262
+ *
263
+ * Enough to be visible against its siblings, small enough that the node still
264
+ * reads as a member of its own group rather than as a defector. Two foreign
265
+ * groups tint twice, which is the intent — a node joining three families
266
+ * should look like it.
267
+ */
268
+ export const BRIDGE_SHARE = 0.26;
269
+
270
+ /** Total foreign tint, however many groups a node bridges. */
271
+ export const MAX_BRIDGE = 0.55;
272
+
273
+ /**
274
+ * Mix the foreign groups' primaries into a node's own colour.
275
+ *
276
+ * Each foreign hue is blended in at {@link BRIDGE_SHARE}, in a fixed order
277
+ * (the caller sorts), so the result is deterministic rather than dependent on
278
+ * edge iteration order. The total is capped at {@link MAX_BRIDGE} so a
279
+ * promiscuously-linked hub does not end up painted entirely in other people's
280
+ * colours.
281
+ */
282
+ export function bridgeBlend(own: string, foreign: readonly string[], scheme: ColorScheme): string {
283
+ if (foreign.length === 0) return own;
284
+ let out = own;
285
+ let spent = 0;
286
+ for (const hue of foreign) {
287
+ const share = Math.min(BRIDGE_SHARE, MAX_BRIDGE - spent);
288
+ if (share <= 0) break;
289
+ spent += share;
290
+ // Blend into the running colour, not into `own`: successive mixes
291
+ // compound, which is what makes a three-way bridge read differently from
292
+ // a two-way one. `scheme` is unused by the arithmetic but kept in the
293
+ // signature so a future scheme-aware mix does not change every caller.
294
+ out = blendHex(out, hue, share);
295
+ }
296
+ return out;
297
+ }
298
+
299
+ // --- the whole assignment --------------------------------------------------------------
300
+
301
+ /**
302
+ * Node id → fill: group hue, depth shade, and any bridge tint.
303
+ *
304
+ * One call for the caller, and the only place the three steps are composed —
305
+ * so a renderer cannot apply the ramp twice or skip the tint for one kind.
306
+ */
307
+ export function groupNodeColors(
308
+ nodes: readonly WireGraphNode[],
309
+ edges: readonly WireGraphEdge[],
310
+ scheme: ColorScheme,
311
+ ): Map<string, string> {
312
+ const places = groupPlaces(nodes, edges);
313
+ const keys = new Map<string, string>();
314
+ for (const [id, place] of places) keys.set(id, place.key);
315
+ const hues = groupColors(keys, scheme);
316
+
317
+ // Which foreign groups each node associates with. Containment is excluded:
318
+ // it is what put the node in its group, so it can never be a bridge.
319
+ const known = new Set(nodes.map((node) => node.id));
320
+ const reaches = new Map<string, Set<string>>();
321
+ const note = (from: string, to: string): void => {
322
+ const here = places.get(from)?.key;
323
+ const there = places.get(to)?.key;
324
+ if (here === undefined || there === undefined || here === there) return;
325
+ const set = reaches.get(from);
326
+ if (set === undefined) reaches.set(from, new Set([there]));
327
+ else set.add(there);
328
+ };
329
+ for (const edge of edges) {
330
+ if (isContainment(edge.kind)) continue;
331
+ if (edge.source === edge.target) continue;
332
+ if (!known.has(edge.source) || !known.has(edge.target)) continue;
333
+ note(edge.source, edge.target);
334
+ note(edge.target, edge.source);
335
+ }
336
+
337
+ const out = new Map<string, string>();
338
+ for (const node of nodes) {
339
+ const place = places.get(node.id);
340
+ if (place === undefined) continue;
341
+ const hue = hues.get(place.key);
342
+ if (hue === undefined) continue;
343
+ const mine = shadeFor(hue, place.depth, scheme);
344
+ // Sorted, so the blend order — and therefore the exact hex — is a function
345
+ // of the graph rather than of the order edges happened to arrive in.
346
+ const foreign = [...(reaches.get(node.id) ?? [])]
347
+ .sort()
348
+ .map((key) => hues.get(key))
349
+ .filter((value): value is string => value !== undefined);
350
+ out.set(node.id, bridgeBlend(mine, foreign, scheme));
351
+ }
352
+ return out;
353
+ }
@@ -203,14 +203,15 @@ export interface PositionStorage {
203
203
  /**
204
204
  * The `localStorage` key. Namespaced and versioned, like the layout's.
205
205
  *
206
- * `v3` is the Tier 6 gravity/size change: stronger centre gravity, shorter
207
- * relation springs and degree-sized collision discs moved every resting
208
- * position the old recipe settled to. A v2 entry is a valid layout of the
209
- * *old* physics, and the shape key has no way to know that — so the version
210
- * does what it did for the branch-anchor change: one whole generation of
211
- * stored arrangements is a miss rather than a half-forgotten map.
206
+ * `v4` is the frozen force constants (§15.7): the tuner's chosen values move
207
+ * every resting position the previous recipe settled to. A v3 entry is a valid
208
+ * layout of the *old* physics, and the shape key has no way to know that — it
209
+ * digests which nodes and edges exist, and a force change touches neither. So
210
+ * the version does what it did for `v3`'s gravity change and the branch-anchor
211
+ * change before it: one whole generation of stored arrangements is a clean
212
+ * miss rather than a half-forgotten map.
212
213
  */
213
- export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v3";
214
+ export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v4";
214
215
 
215
216
  /**
216
217
  * Coordinates are rounded to **one** decimal before storage.
@@ -237,11 +238,11 @@ function round1(value: number): number {
237
238
  export function serializePositions(key: string, positions: ReadonlyMap<string, Point>): string {
238
239
  const at: Record<string, [number, number]> = {};
239
240
  for (const [id, point] of positions) at[id] = [round1(point.x), round1(point.y)];
240
- // `v: 3` — the Tier 6 gravity/size recipe. A v2 entry is an arrangement from
241
- // the previous force constants, and the shape key cannot tell the two
242
- // recipes apart because they changed under the same node and edge set. The
243
- // version can, exactly as it did for the branch-anchor change before it.
244
- return JSON.stringify({ v: 3, key, at });
241
+ // `v: 4` — the frozen force constants. A v3 entry is an arrangement from the
242
+ // previous constants, and the shape key cannot tell the two recipes apart
243
+ // because they changed under the same node and edge set. The version can,
244
+ // exactly as it did for the gravity change before it.
245
+ return JSON.stringify({ v: 4, key, at });
245
246
  }
246
247
 
247
248
  /**
@@ -273,7 +274,7 @@ export function deserializePositions(raw: string | null, key: string): Map<strin
273
274
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
274
275
 
275
276
  const record = parsed as Record<string, unknown>;
276
- if (record["v"] !== 3) return null;
277
+ if (record["v"] !== 4) return null;
277
278
  if (record["key"] !== key) return null;
278
279
 
279
280
  const at = record["at"];
@@ -90,6 +90,8 @@ export function sigmaRenderer(
90
90
  let sigma: SigmaLike | null = null;
91
91
  let graph: ProjectedGraph = project({ nodes: [], edges: [] });
92
92
  let highlight: ReadonlySet<string> | null = null;
93
+ /** The selection inside that neighbourhood — see `nodeReducer`. */
94
+ let selected: string | null = null;
93
95
  let select: (id: string | null) => void = () => {};
94
96
  let dragStart: (id: string) => void = () => {};
95
97
  let dragMove: (id: string, at: Point) => void = () => {};
@@ -119,8 +121,8 @@ export function sigmaRenderer(
119
121
  * until the next unrelated frame.
120
122
  */
121
123
  const applyReducers = (instance: SigmaLike): void => {
122
- const nodes = nodeReducer(highlight);
123
- const edges = edgeReducer(highlight);
124
+ const nodes = nodeReducer(highlight, selected);
125
+ const edges = edgeReducer(highlight, selected);
124
126
  instance.setSetting("nodeReducer", (id, data) => ({ ...data, ...nodes(id, data, scheme) }));
125
127
  instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data, scheme) }));
126
128
  };
@@ -177,8 +179,9 @@ export function sigmaRenderer(
177
179
  sigma?.refresh();
178
180
  },
179
181
 
180
- setHighlight(next: ReadonlySet<string> | null) {
182
+ setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null) {
181
183
  highlight = next;
184
+ selected = selectedId;
182
185
  if (sigma !== null) applyReducers(sigma);
183
186
  },
184
187
 
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The hidden force tuner's logic (docs/weave-workspace.md §15.7).
3
+ *
4
+ * The graph's arrangement is decided by six numbers in `shared/layout.ts`, and
5
+ * the difference between "one hairball" and "legible groups" is a *ratio*
6
+ * between them that is far easier to see than to derive. So there is a panel
7
+ * of sliders behind a URL flag, and everything about it that is not a DOM node
8
+ * lives here — §10's rule, the same split `Graph.tsx` / `column.model.ts`
9
+ * already uses.
10
+ *
11
+ * ## Why a query flag and not a setting
12
+ *
13
+ * The tuner is a *developer* affordance with a human in the loop; it is not a
14
+ * feature and must not read as one. A URL string is the cheapest gate that is
15
+ * genuinely hidden (no menu entry, no chord to collide with §11 P4's
16
+ * keymap, no persisted state to leak into a normal session) while staying
17
+ * reachable on a running workspace with no rebuild. A build-time `define`
18
+ * would have been more hidden still and was rejected: it breaks the
19
+ * byte-reproducible `build:web:check` contract for a panel that is already
20
+ * unreachable without the flag.
21
+ *
22
+ * ## Tier rules (§2)
23
+ *
24
+ * `src/web/client/**`, but nothing here touches the DOM or npm — the flag
25
+ * arrives as a `location.search` *string*, so this module compiles under the
26
+ * root `tsconfig.json` and its tests are ordinary ones.
27
+ */
28
+
29
+ import type { ForceConstants } from "../../shared/layout";
30
+ import { FORCE_DEFAULTS } from "../../shared/layout";
31
+
32
+ // --- the gate ----------------------------------------------------------------------
33
+
34
+ /** The query parameter that opens the panel. Documented in §15.7. */
35
+ export const SLIDERS_FLAG = "sliders";
36
+
37
+ /**
38
+ * Whether `location.search` asks for the tuner.
39
+ *
40
+ * `?sliders`, `?sliders=1` and `?sliders=true` all open it; `?sliders=0` and
41
+ * `?sliders=false` do not, so a bookmarked URL can carry the parameter in the
42
+ * off position. Anything unparseable is off — a typo must not silently put a
43
+ * debug panel over a user's graph.
44
+ *
45
+ * Hand-parsed rather than via `URLSearchParams`, which is a DOM/Node global
46
+ * this tier's `tsconfig` does not provide.
47
+ */
48
+ export function slidersFlag(search: string): boolean {
49
+ for (const pair of search.replace(/^\?/, "").split("&")) {
50
+ const eq = pair.indexOf("=");
51
+ const name = eq === -1 ? pair : pair.slice(0, eq);
52
+ if (name !== SLIDERS_FLAG) continue;
53
+ const value = eq === -1 ? "" : pair.slice(eq + 1).toLowerCase();
54
+ return value !== "0" && value !== "false";
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // --- the sliders --------------------------------------------------------------------
60
+
61
+ /** One slider: which constant it drives, and the range it may drive it over. */
62
+ export interface SliderSpec {
63
+ readonly key: keyof ForceConstants;
64
+ readonly label: string;
65
+ /** One line under the label saying what moving it does. */
66
+ readonly hint: string;
67
+ readonly min: number;
68
+ readonly max: number;
69
+ readonly step: number;
70
+ }
71
+
72
+ /**
73
+ * The panel, in the order the forces actually fight each other: cohesion
74
+ * first (what holds a group together), then repulsion (what pushes groups
75
+ * apart), then gravity (what pulls everything back in). Reading top to bottom
76
+ * is reading the tug-of-war.
77
+ *
78
+ * Ranges are generous on purpose — the point of the exercise is that the
79
+ * shipped values are in the wrong region, so a range hugging them would hide
80
+ * the answer. `chargeMax` tops out at 4000, which is past any useful value on
81
+ * a graph that spans a few thousand units; that top of the range is
82
+ * effectively "uncapped".
83
+ */
84
+ export const FORCE_SLIDERS: readonly SliderSpec[] = [
85
+ { key: "containsStrength", label: "contains strength", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
86
+ { key: "containsRest", label: "contains rest", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
87
+ { key: "relationStrength", label: "relation strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
88
+ { key: "relationDistance", label: "relation distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
89
+ { key: "charge", label: "charge", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
90
+ { key: "chargeMax", label: "charge range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
91
+ { key: "center", label: "centre gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
92
+ ];
93
+
94
+ /**
95
+ * The arrangement this whole exercise replaced, kept as a **comparison**.
96
+ *
97
+ * Not a suggestion any more — `FORCE_DEFAULTS` is now the chosen answer, found
98
+ * through these sliders and frozen in `shared/layout.ts`. This is the *before*
99
+ * picture, one button press away, so the next person to open the panel can see
100
+ * in one click what the constants are for rather than reading a changelog.
101
+ *
102
+ * Measured at 300 ticks, worst-case gap between the big branches' bounding
103
+ * boxes (negative = the branches interleave, which is the hairball):
104
+ * `siblingBlobsGraph` **−312** here against **+39** shipped, `repoLikeGraph`
105
+ * **−274** against **+133**.
106
+ */
107
+ export const HAIRBALL: Readonly<ForceConstants> = {
108
+ containsRest: 90,
109
+ containsStrength: 0.02,
110
+ relationDistance: 170,
111
+ relationStrength: 0.05,
112
+ charge: -50,
113
+ chargeMax: Infinity,
114
+ center: 0.09,
115
+ };
116
+
117
+ /**
118
+ * A slider's value for `<input type="range">`.
119
+ *
120
+ * `chargeMax` defaults to `Infinity`, which is not a number a range input can
121
+ * hold, so it reads as the top of its range — the position that means "no
122
+ * meaningful cap", which is what `Infinity` is.
123
+ */
124
+ export function sliderValue(forces: ForceConstants, spec: SliderSpec): number {
125
+ const raw = forces[spec.key];
126
+ if (!Number.isFinite(raw)) return spec.max;
127
+ return Math.min(spec.max, Math.max(spec.min, raw));
128
+ }
129
+
130
+ /**
131
+ * Parse and clamp what a slider reported.
132
+ *
133
+ * An `<input>`'s `value` is a string, and a non-numeric one (which the
134
+ * platform should never produce, but a synthetic event can) falls back to the
135
+ * shipped default rather than poisoning the simulation with a `NaN` that would
136
+ * propagate to every node position in one tick.
137
+ */
138
+ export function parseSlider(spec: SliderSpec, raw: string): number {
139
+ const parsed = Number.parseFloat(raw);
140
+ if (!Number.isFinite(parsed)) return FORCE_DEFAULTS[spec.key];
141
+ return Math.min(spec.max, Math.max(spec.min, parsed));
142
+ }
143
+
144
+ /** A slider's readout. Short, and never `0.8500000000000001`. */
145
+ export function formatValue(value: number): string {
146
+ if (!Number.isFinite(value)) return "∞";
147
+ return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
148
+ }
149
+
150
+ // --- handing the numbers back ----------------------------------------------------------
151
+
152
+ /**
153
+ * The current constants as the literal that belongs in `shared/layout.ts`.
154
+ *
155
+ * The whole point of the tuner is that a human finds the values and they then
156
+ * get *frozen into the source*, so the last step of the loop is transcription
157
+ * — and transcribing seven floats by eye off a panel is exactly where a digit
158
+ * gets dropped. The Copy button emits the block verbatim.
159
+ */
160
+ export function forcesSnippet(forces: ForceConstants): string {
161
+ const line = (key: keyof ForceConstants): string => ` ${key}: ${Number.isFinite(forces[key]) ? forces[key] : "Infinity"},`;
162
+ return ["export const FORCES: ForceConstants = {", ...FORCE_SLIDERS.map((spec) => line(spec.key)), "};"].join("\n");
163
+ }
164
+
165
+ /** True when nothing has been moved off the shipped values. Drives "Reset". */
166
+ export function isDefault(forces: ForceConstants): boolean {
167
+ return FORCE_SLIDERS.every((spec) => Object.is(forces[spec.key], FORCE_DEFAULTS[spec.key]));
168
+ }
169
+
170
+ // --- the group-colour setting (§15.8) ------------------------------------------------
171
+
172
+ /**
173
+ * Where the group-colour choice is remembered.
174
+ *
175
+ * Versioned like the position cache, and stored separately from it: a palette
176
+ * preference must survive the layout invalidation that a force change causes,
177
+ * or tuning the physics would silently reset the user's colours.
178
+ */
179
+ export const GROUP_COLORS_STORAGE_KEY = "pi-weave.graph.groupColors.v1";
180
+
181
+ /** The two-method storage port, same shape as `PositionStorage`. */
182
+ export interface SettingStorage {
183
+ getItem(key: string): string | null;
184
+ setItem(key: string, value: string): void;
185
+ }
186
+
187
+ /**
188
+ * Whether to colour by group. Defaults to **on**.
189
+ *
190
+ * On by default because the forces go to real trouble to separate the groups
191
+ * and leaving them one colour wastes that. A throwing `getItem` (Safari
192
+ * private browsing, partitioned storage) is a default, not an error — the
193
+ * same answer `loadPositions` gives.
194
+ */
195
+ export function loadGroupColors(storage: SettingStorage): boolean {
196
+ try {
197
+ return storage.getItem(GROUP_COLORS_STORAGE_KEY) !== "off";
198
+ } catch {
199
+ return true;
200
+ }
201
+ }
202
+
203
+ /** Persist the choice, best-effort. A full or refusing quota is not an error. */
204
+ export function saveGroupColors(storage: SettingStorage, on: boolean): void {
205
+ try {
206
+ storage.setItem(GROUP_COLORS_STORAGE_KEY, on ? "on" : "off");
207
+ } catch {
208
+ // A preference that does not survive a reload still works this session.
209
+ }
210
+ }
@@ -13,6 +13,7 @@
13
13
  import { render } from "preact";
14
14
  import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
15
15
  import { readBootstrap } from "./bootstrap";
16
+ import { slidersFlag } from "./graph/tuner.model";
16
17
  import { Shell } from "./shell/Shell";
17
18
  import { installTheme } from "./shell/theme";
18
19
  import { loadTheme, themeAttr } from "./shell/theme.model";
@@ -28,7 +29,17 @@ if (host !== null) {
28
29
  // writes nothing — the media query already answered.
29
30
  applyPrePaintTheme();
30
31
  const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
31
- render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
32
+ render(
33
+ <Shell
34
+ cwd={boot.cwd}
35
+ initialWidth={window.innerWidth}
36
+ platform={navigator.platform}
37
+ // The fourth DOM read that cannot be injected further up. `slidersFlag`
38
+ // owns the parsing, so this stays a read and the decision stays tested.
39
+ tuner={slidersFlag(window.location.search)}
40
+ />,
41
+ host,
42
+ );
32
43
  }
33
44
 
34
45
  /** Apply the stored theme choice to `<html>` synchronously, pre-render. */