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.
- package/README.md +22 -21
- package/package.json +1 -1
- package/skills/weave-explore/SKILL.md +2 -0
- package/skills/weave-notepad/SKILL.md +10 -0
- package/src/core/concurrency.ts +1 -1
- package/src/core/frontmatter.ts +30 -0
- package/src/core/index.ts +25 -1
- package/src/core/paths.ts +1 -0
- package/src/core/sessions.ts +963 -0
- package/src/core/vault.ts +93 -1
- package/src/pi/index.ts +108 -9
- package/src/pi/sessionScan.ts +105 -0
- package/src/pi/summarize.ts +48 -5
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/web/client/dist/app.js +71 -41
- package/src/web/client/graph/ForceTuner.tsx +99 -0
- package/src/web/client/graph/Graph.tsx +77 -16
- package/src/web/client/graph/column.model.ts +35 -49
- package/src/web/client/graph/dynamics.ts +22 -1
- package/src/web/client/graph/graph.model.ts +63 -5
- package/src/web/client/graph/groups.ts +353 -0
- package/src/web/client/graph/positions.ts +14 -13
- package/src/web/client/graph/renderer.ts +6 -3
- package/src/web/client/graph/tuner.model.ts +210 -0
- package/src/web/client/main.tsx +12 -1
- package/src/web/client/shell/Columns.tsx +3 -0
- package/src/web/client/shell/Shell.tsx +7 -0
- package/src/web/client/shell/theme.ts +30 -1
- package/src/web/client/tree/Tree.tsx +126 -13
- package/src/web/client/tree/tree.model.ts +102 -1
- package/src/web/shared/layout.ts +76 -11
|
@@ -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
|
-
* `
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* does what it did for
|
|
211
|
-
*
|
|
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.
|
|
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:
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
return JSON.stringify({ v:
|
|
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"] !==
|
|
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
|
+
}
|
package/src/web/client/main.tsx
CHANGED
|
@@ -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(
|
|
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. */
|