@bongos/core 1.19.468 → 1.19.469
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/.bongos-core.json +81 -36
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +169 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +5 -10
- package/docs/copy-registry.json +15 -92
- package/docs/file-map.md +5 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/research/task-network-map/a-every-task-p2.png +0 -0
- package/docs/research/task-network-map/a-every-task-p40.png +0 -0
- package/docs/research/task-network-map/b-dependency-forest-p2.png +0 -0
- package/docs/research/task-network-map/b-dependency-forest-p40.png +0 -0
- package/docs/research/task-network-map/c-goals-p2.png +0 -0
- package/docs/research/task-network-map/c-goals-p40.png +0 -0
- package/docs/research/task-network-map/d-joined-by-goal.png +0 -0
- package/docs/research/task-network-map-readability.md +191 -0
- package/modules/hall-ui/public/people.css +3 -2
- package/modules/hall-ui/public/people.html +2 -2
- package/modules/hall-ui/public/people.js +5 -4
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/hall-ui/public/settings.js +1 -1
- package/modules/hall-ui/public/style.css +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spike-task-map.js +730 -0
- package/src/bongos/serve-internal.js +20 -8
- package/src/module-api.js +1 -1
- package/tests/hall_page_gate_map.mjs +4 -2
- package/tests/hall_reading_world.mjs +2 -2
- package/tests/hall_roster_world.mjs +40 -12
- package/tests/spike_task_map.mjs +0 -0
- package/modules/hall-ui/public/scouting.html +0 -69
- package/modules/hall-ui/public/scouting.states.json +0 -59
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/spike-task-map.js — the task-network-map spike (task 1003282, goal
|
|
5
|
+
// 1000072 "Human legibility"): can a node-link map of ~1,357 tasks be READ?
|
|
6
|
+
//
|
|
7
|
+
// WHY A HARNESS AND NOT AN OPINION. The criterion this goal already shipped under
|
|
8
|
+
// (legibility-sort-and-staleness) set the house rule for questions of this shape:
|
|
9
|
+
// "~1,357 tasks on BONGOS-V1 alone. A view that does not survive that number is not
|
|
10
|
+
// a solution" — and tests/goal_sort_staleness.mjs answered it by EXECUTING the board
|
|
11
|
+
// pipeline over a corpus that size rather than asserting about it. A network map is
|
|
12
|
+
// the same question one dimension harder, so it gets the same treatment: build the
|
|
13
|
+
// graph, lay it out for real, draw it for real, and measure what decides whether a
|
|
14
|
+
// person can read it.
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT MEASURES, AND WHY EACH ONE DECIDES SOMETHING.
|
|
17
|
+
// ink budget — screen area ÷ nodes, against the smallest node a person can
|
|
18
|
+
// pick out (a bare 5px dot) and the smallest that can carry a
|
|
19
|
+
// LABEL (~48×14px). This is the one nothing clever escapes: a
|
|
20
|
+
// map whose nodes cannot be told apart is not a slow map, it is
|
|
21
|
+
// not a map.
|
|
22
|
+
// isolated nodes — degree 0. A "network" that is mostly unconnected dots is not
|
|
23
|
+
// a hairball, it is confetti — a different failure needing a
|
|
24
|
+
// different answer, and the one this corpus is likeliest to hit.
|
|
25
|
+
// components — how many separate islands the layout must place. A force
|
|
26
|
+
// layout has nothing to say about where unrelated islands go, so
|
|
27
|
+
// each one is a free parameter the reader has to ignore.
|
|
28
|
+
// crossings — the classic node-link readability metric; counted exactly on
|
|
29
|
+
// the drawn coordinates, not estimated.
|
|
30
|
+
// layout ms — whether it can be drawn at all inside a browser interaction.
|
|
31
|
+
// payload bytes — the number the goals-page rollup was carved for (5,229,455
|
|
32
|
+
// bytes for ~1,400 tasks, measured 2026-08-25). A map that
|
|
33
|
+
// re-downloads the corpus loses before it draws.
|
|
34
|
+
//
|
|
35
|
+
// THE DENSITY IS SWEPT, NOT GUESSED. How many tasks actually declare a dependency is
|
|
36
|
+
// a fact of the live DB. Rather than assume one number and build a verdict on it,
|
|
37
|
+
// this sweeps the plausible range and reports where each conclusion flips, so the
|
|
38
|
+
// answer holds whatever the real number turns out to be. Point it at the real corpus
|
|
39
|
+
// with `--from` (a `GET /tasks?include=deps` dump) and it measures that instead.
|
|
40
|
+
//
|
|
41
|
+
// No dependencies, no network, no DB: a deterministic PRNG, a grid-accelerated force
|
|
42
|
+
// layout, an exact crossing count and a hand-rolled PNG encoder (node:zlib), so the
|
|
43
|
+
// evidence is a picture anyone can open rather than a table anyone can dispute.
|
|
44
|
+
//
|
|
45
|
+
// Usage:
|
|
46
|
+
// node scripts/gds/spike-task-map.js [--nodes 1357] [--goals 40] [--out DIR]
|
|
47
|
+
// [--from tasks.json] [--json] [--no-render]
|
|
48
|
+
|
|
49
|
+
const fs = require('node:fs');
|
|
50
|
+
const path = require('node:path');
|
|
51
|
+
const zlib = require('node:zlib');
|
|
52
|
+
|
|
53
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// argv
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function arg(name, fallback = null) {
|
|
60
|
+
const i = process.argv.indexOf(name);
|
|
61
|
+
return i > -1 && process.argv[i + 1] != null ? process.argv[i + 1] : fallback;
|
|
62
|
+
}
|
|
63
|
+
const HAS = (name) => process.argv.includes(name);
|
|
64
|
+
|
|
65
|
+
const N_TASKS = Number(arg('--nodes', 1357));
|
|
66
|
+
const N_GOALS = Number(arg('--goals', 40));
|
|
67
|
+
const OUT_DIR = path.resolve(REPO_ROOT, arg('--out', 'docs/research/task-network-map'));
|
|
68
|
+
const FROM = arg('--from', null);
|
|
69
|
+
const AS_JSON = HAS('--json');
|
|
70
|
+
const RENDER = !HAS('--no-render');
|
|
71
|
+
|
|
72
|
+
// The canvas every ink-budget number is quoted against: a laptop viewport with the
|
|
73
|
+
// hall's chrome removed. Quoting against a 4K desktop would flatter the result for a
|
|
74
|
+
// screen most readers do not have.
|
|
75
|
+
const CANVAS_W = 1280;
|
|
76
|
+
const CANVAS_H = 720;
|
|
77
|
+
|
|
78
|
+
// The smallest marks a person can actually use. A 5px dot with a 2px gap is about the
|
|
79
|
+
// floor for "I can tell these are two things"; a label needs room for ~6 characters of
|
|
80
|
+
// 11px type before it is a smudge. Both are generous to the map, deliberately.
|
|
81
|
+
const MIN_DOT_PX = 5;
|
|
82
|
+
const MIN_DOT_PITCH = MIN_DOT_PX + 2;
|
|
83
|
+
const MIN_LABEL_W = 48;
|
|
84
|
+
const MIN_LABEL_H = 14;
|
|
85
|
+
|
|
86
|
+
// The dependency densities swept: the share of tasks declaring at least one
|
|
87
|
+
// dependency. 2% is "almost nobody bothers", 40% is "the backlog is a plan".
|
|
88
|
+
const DENSITIES = [0.02, 0.05, 0.10, 0.20, 0.40];
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// deterministic PRNG — a spike whose numbers move between runs proves nothing
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function mulberry32(seed) {
|
|
95
|
+
let a = seed >>> 0;
|
|
96
|
+
return function rnd() {
|
|
97
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
98
|
+
let t = a;
|
|
99
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
100
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
101
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// the corpus
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
// buildSynthetic — a task corpus with the SHAPE the real schema produces: N tasks
|
|
110
|
+
// spread over G goals, and dependency edges that mostly stay inside a goal and always
|
|
111
|
+
// point backwards (the real table has a no-self-dep constraint and the auto-promotion
|
|
112
|
+
// trigger assumes a DAG, so cycles are not a thing this graph can contain).
|
|
113
|
+
// `density` is the share of tasks declaring at least one dependency.
|
|
114
|
+
function buildSynthetic({ nodes, goals, density, seed = 1003282 }) {
|
|
115
|
+
const rnd = mulberry32(seed);
|
|
116
|
+
const tasks = [];
|
|
117
|
+
for (let i = 0; i < nodes; i++) {
|
|
118
|
+
tasks.push({ id: i + 1, goal: i % goals, deps: [] });
|
|
119
|
+
}
|
|
120
|
+
// Within-goal index, so a dependency can prefer a sibling the way a real chain does.
|
|
121
|
+
const byGoal = new Map();
|
|
122
|
+
for (const t of tasks) {
|
|
123
|
+
if (!byGoal.has(t.goal)) byGoal.set(t.goal, []);
|
|
124
|
+
byGoal.get(t.goal).push(t);
|
|
125
|
+
}
|
|
126
|
+
for (const t of tasks) {
|
|
127
|
+
if (rnd() >= density) continue;
|
|
128
|
+
// Most dependents declare one; a few declare two or three.
|
|
129
|
+
const k = rnd() < 0.75 ? 1 : rnd() < 0.85 ? 2 : 3;
|
|
130
|
+
for (let j = 0; j < k; j++) {
|
|
131
|
+
// 85% of edges stay inside the goal — a goal IS the unit of related work.
|
|
132
|
+
const pool = rnd() < 0.85 ? byGoal.get(t.goal) : tasks;
|
|
133
|
+
const cand = pool[Math.floor(rnd() * pool.length)];
|
|
134
|
+
if (!cand || cand.id >= t.id) continue; // backwards only: no cycles
|
|
135
|
+
if (t.deps.includes(cand.id)) continue;
|
|
136
|
+
t.deps.push(cand.id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return tasks;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// loadReal — the same shape from a real `GET /tasks?include=deps` dump, so the next
|
|
143
|
+
// session with DB access can replace every synthetic number in the verdict with a
|
|
144
|
+
// measured one by re-running with --from.
|
|
145
|
+
function loadReal(file) {
|
|
146
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
147
|
+
const rows = Array.isArray(raw) ? raw : (raw.tasks || raw.rows || []);
|
|
148
|
+
const goalIndex = new Map();
|
|
149
|
+
return rows.map((r) => {
|
|
150
|
+
const g = r.goal_id == null ? '__none__' : String(r.goal_id);
|
|
151
|
+
if (!goalIndex.has(g)) goalIndex.set(g, goalIndex.size);
|
|
152
|
+
return {
|
|
153
|
+
id: Number(r.id),
|
|
154
|
+
goal: goalIndex.get(g),
|
|
155
|
+
deps: (r.dependencies || r.deps || []).map(Number).filter(Number.isFinite),
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// graph views — the literal ask, and the two shapes it could become
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
// A: every task a node, every declared dependency an edge. The literal reading of
|
|
165
|
+
// "a network map of 1,300 tasks".
|
|
166
|
+
// B: only the tasks that actually have an edge — the dependency forest. What is left
|
|
167
|
+
// when the confetti is swept off the literal view.
|
|
168
|
+
// C: one node per goal, edges aggregated between goals. The rollup the goals page
|
|
169
|
+
// already computes server-side for its counters.
|
|
170
|
+
function byGoalOf(tasks) {
|
|
171
|
+
const m = new Map();
|
|
172
|
+
for (const t of tasks) {
|
|
173
|
+
if (!m.has(t.goal)) m.set(t.goal, []);
|
|
174
|
+
m.get(t.goal).push(t);
|
|
175
|
+
}
|
|
176
|
+
return m;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function viewsOf(tasks) {
|
|
180
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
181
|
+
const edgesA = [];
|
|
182
|
+
for (const t of tasks) for (const d of t.deps) if (byId.has(d)) edgesA.push([t.id, d]);
|
|
183
|
+
|
|
184
|
+
const degree = new Map(tasks.map((t) => [t.id, 0]));
|
|
185
|
+
for (const [a, b] of edgesA) {
|
|
186
|
+
degree.set(a, degree.get(a) + 1);
|
|
187
|
+
degree.set(b, degree.get(b) + 1);
|
|
188
|
+
}
|
|
189
|
+
const connected = tasks.filter((t) => degree.get(t.id) > 0);
|
|
190
|
+
const keep = new Set(connected.map((t) => t.id));
|
|
191
|
+
const edgesB = edgesA.filter(([a, b]) => keep.has(a) && keep.has(b));
|
|
192
|
+
|
|
193
|
+
const goalNodes = [...new Set(tasks.map((t) => t.goal))].sort((a, b) => a - b);
|
|
194
|
+
const goalEdge = new Map();
|
|
195
|
+
for (const [a, b] of edgesA) {
|
|
196
|
+
const ga = byId.get(a).goal;
|
|
197
|
+
const gb = byId.get(b).goal;
|
|
198
|
+
if (ga === gb) continue; // a self-loop is not a map feature
|
|
199
|
+
const key = `${ga}>${gb}`;
|
|
200
|
+
goalEdge.set(key, (goalEdge.get(key) || 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
const edgesC = [...goalEdge.keys()].map((k) => k.split('>').map(Number));
|
|
203
|
+
|
|
204
|
+
// D: every task joined to every other task in its goal. Not a straw man — it is
|
|
205
|
+
// what "show me the network of tasks" produces once someone notices that the
|
|
206
|
+
// dependency view is mostly unconnected dots and reaches for the relation that
|
|
207
|
+
// IS dense. Included so the verdict covers the obvious second attempt.
|
|
208
|
+
const edgesD = [];
|
|
209
|
+
for (const [, members] of byGoalOf(tasks)) {
|
|
210
|
+
for (let i = 0; i < members.length; i++) {
|
|
211
|
+
for (let j = i + 1; j < members.length; j++) edgesD.push([members[i].id, members[j].id]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
A: { name: 'every task', nodes: tasks.map((t) => t.id), groups: tasks.map((t) => t.goal), edges: edgesA },
|
|
217
|
+
D: { name: 'tasks joined by goal', nodes: tasks.map((t) => t.id), groups: tasks.map((t) => t.goal), edges: edgesD },
|
|
218
|
+
B: { name: 'tasks with a dependency', nodes: connected.map((t) => t.id), groups: connected.map((t) => t.goal), edges: edgesB },
|
|
219
|
+
C: { name: 'goals', nodes: goalNodes, groups: goalNodes, edges: edgesC },
|
|
220
|
+
degree,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// weakly-connected components, ignoring edge direction — a reader does not perceive
|
|
225
|
+
// arrow direction when deciding "are these two blobs the same thing".
|
|
226
|
+
function components(nodes, edges) {
|
|
227
|
+
const idx = new Map(nodes.map((n, i) => [n, i]));
|
|
228
|
+
const parent = nodes.map((_, i) => i);
|
|
229
|
+
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
|
230
|
+
for (const [a, b] of edges) {
|
|
231
|
+
const ia = idx.get(a); const ib = idx.get(b);
|
|
232
|
+
if (ia == null || ib == null) continue;
|
|
233
|
+
const ra = find(ia); const rb = find(ib);
|
|
234
|
+
if (ra !== rb) parent[ra] = rb;
|
|
235
|
+
}
|
|
236
|
+
const sizes = new Map();
|
|
237
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
238
|
+
const r = find(i);
|
|
239
|
+
sizes.set(r, (sizes.get(r) || 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
const all = [...sizes.values()].sort((a, b) => b - a);
|
|
242
|
+
return { count: all.length, largest: all[0] || 0, singletons: all.filter((s) => s === 1).length };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// layout — a real force-directed pass, grid-accelerated so N=1,357 is honest
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// A real Fruchterman-Reingold pass with BARNES-HUT repulsion (quadtree, theta=0.8) —
|
|
250
|
+
// the same O(n log n) approximation d3-force and every production layout uses, so the
|
|
251
|
+
// timings here are the timings a real implementation would get.
|
|
252
|
+
//
|
|
253
|
+
// It has to be Barnes-Hut and not a cheaper neighbour-grid. A grid gives each node only
|
|
254
|
+
// SHORT-range repulsion, so nodes that share no edge and no cell never push apart; at
|
|
255
|
+
// 1,357 mostly-disconnected nodes that produces a cross-shaped smear which is a
|
|
256
|
+
// property of the approximation and not of the data. Measuring readability on a
|
|
257
|
+
// degenerate layout would prove nothing, and would prove it convincingly.
|
|
258
|
+
//
|
|
259
|
+
// A weak gravity toward the centre keeps disconnected components from drifting to
|
|
260
|
+
// infinity (d3-force's forceCenter, for the same reason): with pure repulsion and no
|
|
261
|
+
// frame, N islands have no equilibrium at all.
|
|
262
|
+
function layout(nodes, edges, { w = CANVAS_W, h = CANVAS_H, iters = 300, seed = 7, margin = 6, theta = 0.8, gravity = 0.015 } = {}) {
|
|
263
|
+
const n = nodes.length;
|
|
264
|
+
if (n === 0) return { pos: [], idx: new Map(), ms: 0, iters: 0 };
|
|
265
|
+
const rnd = mulberry32(seed);
|
|
266
|
+
const idx = new Map(nodes.map((v, i) => [v, i]));
|
|
267
|
+
const x = new Float64Array(n);
|
|
268
|
+
const y = new Float64Array(n);
|
|
269
|
+
for (let i = 0; i < n; i++) {
|
|
270
|
+
const a = (i / n) * Math.PI * 2;
|
|
271
|
+
x[i] = w / 2 + Math.cos(a) * (w / 3) * (0.6 + rnd() * 0.4);
|
|
272
|
+
y[i] = h / 2 + Math.sin(a) * (h / 3) * (0.6 + rnd() * 0.4);
|
|
273
|
+
}
|
|
274
|
+
const ea = new Int32Array(edges.length);
|
|
275
|
+
const eb = new Int32Array(edges.length);
|
|
276
|
+
let m = 0;
|
|
277
|
+
for (const [a, b] of edges) {
|
|
278
|
+
const ia = idx.get(a); const ib = idx.get(b);
|
|
279
|
+
if (ia == null || ib == null) continue;
|
|
280
|
+
ea[m] = ia; eb[m] = ib; m++;
|
|
281
|
+
}
|
|
282
|
+
const k = Math.sqrt((w * h) / n); // FR ideal distance
|
|
283
|
+
const dx = new Float64Array(n);
|
|
284
|
+
const dy = new Float64Array(n);
|
|
285
|
+
|
|
286
|
+
// Flat-array quadtree, rebuilt each iteration. Children are indices into the same
|
|
287
|
+
// arrays; -1 is empty. Kept flat because a per-node object tree at 300 iterations is
|
|
288
|
+
// most of the runtime, and the runtime is one of the things being measured.
|
|
289
|
+
const CAP = 4 * n + 64;
|
|
290
|
+
const qx = new Float64Array(CAP); // centre of mass
|
|
291
|
+
const qy = new Float64Array(CAP);
|
|
292
|
+
const qm = new Float64Array(CAP); // mass (node count)
|
|
293
|
+
const qs = new Float64Array(CAP); // cell side
|
|
294
|
+
const qcx = new Float64Array(CAP); // cell origin
|
|
295
|
+
const qcy = new Float64Array(CAP);
|
|
296
|
+
const qkid = new Int32Array(CAP * 4);
|
|
297
|
+
const qleaf = new Int32Array(CAP); // node index for a single-point cell, else -1
|
|
298
|
+
let qn = 0;
|
|
299
|
+
|
|
300
|
+
function newCell(cx, cy, size) {
|
|
301
|
+
const i = qn++;
|
|
302
|
+
qx[i] = 0; qy[i] = 0; qm[i] = 0; qs[i] = size; qcx[i] = cx; qcy[i] = cy; qleaf[i] = -1;
|
|
303
|
+
qkid[i * 4] = -1; qkid[i * 4 + 1] = -1; qkid[i * 4 + 2] = -1; qkid[i * 4 + 3] = -1;
|
|
304
|
+
return i;
|
|
305
|
+
}
|
|
306
|
+
function quadrantOf(cell, px, py) {
|
|
307
|
+
const half = qs[cell] / 2;
|
|
308
|
+
return (px >= qcx[cell] + half ? 1 : 0) + (py >= qcy[cell] + half ? 2 : 0);
|
|
309
|
+
}
|
|
310
|
+
function insert(cell, p, depth) {
|
|
311
|
+
// Accumulate the running centre of mass on the way down.
|
|
312
|
+
qx[cell] = (qx[cell] * qm[cell] + x[p]) / (qm[cell] + 1);
|
|
313
|
+
qy[cell] = (qy[cell] * qm[cell] + y[p]) / (qm[cell] + 1);
|
|
314
|
+
qm[cell] += 1;
|
|
315
|
+
if (qm[cell] === 1) { qleaf[cell] = p; return; }
|
|
316
|
+
// 24 levels is ~16M subdivisions of the root: past that two points are the same
|
|
317
|
+
// point for drawing purposes and splitting further would never terminate.
|
|
318
|
+
if (depth > 24 || qn + 4 >= CAP) return;
|
|
319
|
+
if (qleaf[cell] >= 0) {
|
|
320
|
+
const old = qleaf[cell];
|
|
321
|
+
qleaf[cell] = -1;
|
|
322
|
+
pushDown(cell, old, depth);
|
|
323
|
+
}
|
|
324
|
+
pushDown(cell, p, depth);
|
|
325
|
+
}
|
|
326
|
+
function pushDown(cell, p, depth) {
|
|
327
|
+
const q = quadrantOf(cell, x[p], y[p]);
|
|
328
|
+
let kid = qkid[cell * 4 + q];
|
|
329
|
+
if (kid < 0) {
|
|
330
|
+
const half = qs[cell] / 2;
|
|
331
|
+
kid = newCell(qcx[cell] + (q & 1 ? half : 0), qcy[cell] + (q & 2 ? half : 0), half);
|
|
332
|
+
qkid[cell * 4 + q] = kid;
|
|
333
|
+
}
|
|
334
|
+
insert(kid, p, depth + 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const stack = new Int32Array(4096);
|
|
338
|
+
const t0 = process.hrtime.bigint();
|
|
339
|
+
for (let it = 0; it < iters; it++) {
|
|
340
|
+
dx.fill(0); dy.fill(0);
|
|
341
|
+
|
|
342
|
+
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
|
343
|
+
for (let i = 0; i < n; i++) {
|
|
344
|
+
if (x[i] < minX) minX = x[i];
|
|
345
|
+
if (x[i] > maxX) maxX = x[i];
|
|
346
|
+
if (y[i] < minY) minY = y[i];
|
|
347
|
+
if (y[i] > maxY) maxY = y[i];
|
|
348
|
+
}
|
|
349
|
+
const side = Math.max(1, maxX - minX, maxY - minY) * 1.05;
|
|
350
|
+
qn = 0;
|
|
351
|
+
const root = newCell(minX, minY, side);
|
|
352
|
+
for (let i = 0; i < n; i++) insert(root, i, 0);
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < n; i++) {
|
|
355
|
+
let sp = 0;
|
|
356
|
+
stack[sp++] = root;
|
|
357
|
+
while (sp > 0) {
|
|
358
|
+
const c = stack[--sp];
|
|
359
|
+
if (qm[c] === 0) continue;
|
|
360
|
+
if (qleaf[c] === i && qm[c] === 1) continue; // never repel from yourself
|
|
361
|
+
let vx = x[i] - qx[c];
|
|
362
|
+
let vy = y[i] - qy[c];
|
|
363
|
+
let d = Math.sqrt(vx * vx + vy * vy);
|
|
364
|
+
if (d < 1e-6) { vx = (rnd() - 0.5) * 0.01; vy = (rnd() - 0.5) * 0.01; d = 1e-6; }
|
|
365
|
+
if (qs[c] / d < theta || qleaf[c] >= 0) {
|
|
366
|
+
const f = (qm[c] * k * k) / d;
|
|
367
|
+
dx[i] += (vx / d) * f;
|
|
368
|
+
dy[i] += (vy / d) * f;
|
|
369
|
+
} else {
|
|
370
|
+
for (let q = 0; q < 4; q++) {
|
|
371
|
+
const kid = qkid[c * 4 + q];
|
|
372
|
+
if (kid >= 0 && sp < stack.length) stack[sp++] = kid;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (let e = 0; e < m; e++) {
|
|
379
|
+
const i = ea[e]; const j = eb[e];
|
|
380
|
+
const vx = x[i] - x[j];
|
|
381
|
+
const vy = y[i] - y[j];
|
|
382
|
+
const d = Math.max(1e-3, Math.sqrt(vx * vx + vy * vy));
|
|
383
|
+
const f = (d * d) / k;
|
|
384
|
+
const fx = (vx / d) * f;
|
|
385
|
+
const fy = (vy / d) * f;
|
|
386
|
+
dx[i] -= fx; dy[i] -= fy;
|
|
387
|
+
dx[j] += fx; dy[j] += fy;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// gravity: without it, N disconnected islands have no equilibrium
|
|
391
|
+
let gx = 0; let gy = 0;
|
|
392
|
+
for (let i = 0; i < n; i++) { gx += x[i]; gy += y[i]; }
|
|
393
|
+
gx /= n; gy /= n;
|
|
394
|
+
for (let i = 0; i < n; i++) {
|
|
395
|
+
dx[i] -= (x[i] - gx) * gravity * k;
|
|
396
|
+
dy[i] -= (y[i] - gy) * gravity * k;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const temp = (1 - it / iters) * (w / 10);
|
|
400
|
+
for (let i = 0; i < n; i++) {
|
|
401
|
+
const d = Math.max(1e-6, Math.hypot(dx[i], dy[i]));
|
|
402
|
+
x[i] += (dx[i] / d) * Math.min(d, temp);
|
|
403
|
+
y[i] += (dy[i] / d) * Math.min(d, temp);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
407
|
+
|
|
408
|
+
// Fit the result to the frame. FR's ideal distance is derived from area/n, so the
|
|
409
|
+
// laid-out extent is not the canvas extent; every real implementation normalises at
|
|
410
|
+
// the end and so does this. It is the last step, never a per-iteration clamp — a
|
|
411
|
+
// clamp is a wall force the model does not have, and on a sparse graph it pins most
|
|
412
|
+
// nodes to the border and draws a ring that is an artifact and nothing else.
|
|
413
|
+
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
|
414
|
+
for (let i = 0; i < n; i++) {
|
|
415
|
+
if (x[i] < minX) minX = x[i];
|
|
416
|
+
if (x[i] > maxX) maxX = x[i];
|
|
417
|
+
if (y[i] < minY) minY = y[i];
|
|
418
|
+
if (y[i] > maxY) maxY = y[i];
|
|
419
|
+
}
|
|
420
|
+
const spanX = Math.max(1e-6, maxX - minX);
|
|
421
|
+
const spanY = Math.max(1e-6, maxY - minY);
|
|
422
|
+
const scale = Math.min((w - 2 * margin) / spanX, (h - 2 * margin) / spanY);
|
|
423
|
+
const offX = (w - spanX * scale) / 2;
|
|
424
|
+
const offY = (h - spanY * scale) / 2;
|
|
425
|
+
const pos = Array.from({ length: n }, (_, i) => [
|
|
426
|
+
offX + (x[i] - minX) * scale,
|
|
427
|
+
offY + (y[i] - minY) * scale,
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
return { pos, idx, ms, iters };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// readability metrics over the DRAWN coordinates
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
const seg = (p, q) => ({ x1: p[0], y1: p[1], x2: q[0], y2: q[1] });
|
|
438
|
+
function crosses(a, b) {
|
|
439
|
+
const d = (px, py, qx, qy, rx, ry) => (qx - px) * (ry - py) - (qy - py) * (rx - px);
|
|
440
|
+
const d1 = d(a.x1, a.y1, a.x2, a.y2, b.x1, b.y1);
|
|
441
|
+
const d2 = d(a.x1, a.y1, a.x2, a.y2, b.x2, b.y2);
|
|
442
|
+
const d3 = d(b.x1, b.y1, b.x2, b.y2, a.x1, a.y1);
|
|
443
|
+
const d4 = d(b.x1, b.y1, b.x2, b.y2, a.x2, a.y2);
|
|
444
|
+
return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Exact when the edge count is small enough to afford it, sampled (and scaled back up)
|
|
448
|
+
// when it is not — the count is reported with which of the two produced it, because a
|
|
449
|
+
// sampled number that looks exact is the kind of thing a verdict should not rest on.
|
|
450
|
+
function crossingCount(edges, pos, idx, cap = 2500) {
|
|
451
|
+
const segs = [];
|
|
452
|
+
for (const [a, b] of edges) {
|
|
453
|
+
const ia = idx.get(a); const ib = idx.get(b);
|
|
454
|
+
if (ia == null || ib == null) continue;
|
|
455
|
+
if (!pos[ia] || !pos[ib]) continue;
|
|
456
|
+
segs.push(seg(pos[ia], pos[ib]));
|
|
457
|
+
}
|
|
458
|
+
const E = segs.length;
|
|
459
|
+
if (E < 2) return { crossings: 0, exact: true, edges: E };
|
|
460
|
+
if (E <= cap) {
|
|
461
|
+
let c = 0;
|
|
462
|
+
for (let i = 0; i < E; i++) for (let j = i + 1; j < E; j++) if (crosses(segs[i], segs[j])) c++;
|
|
463
|
+
return { crossings: c, exact: true, edges: E };
|
|
464
|
+
}
|
|
465
|
+
const rnd = mulberry32(99);
|
|
466
|
+
const SAMPLES = 400000;
|
|
467
|
+
let hit = 0;
|
|
468
|
+
for (let s = 0; s < SAMPLES; s++) {
|
|
469
|
+
const i = Math.floor(rnd() * E);
|
|
470
|
+
let j = Math.floor(rnd() * E);
|
|
471
|
+
if (i === j) j = (j + 1) % E;
|
|
472
|
+
if (crosses(segs[i], segs[j])) hit++;
|
|
473
|
+
}
|
|
474
|
+
const pairs = (E * (E - 1)) / 2;
|
|
475
|
+
return { crossings: Math.round((hit / SAMPLES) * pairs), exact: false, edges: E };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// distinguishableMarks — how many separate things a reader can actually SEE, counted
|
|
479
|
+
// on the drawn coordinates: the number of distinct MIN_DOT_PITCH cells any node lands
|
|
480
|
+
// in. This is the metric that makes "is it readable" a number rather than an opinion,
|
|
481
|
+
// and it is the only one that catches OVERPLOTTING — a layout can place 1,357 nodes
|
|
482
|
+
// perfectly and still show forty marks, because a tight cluster of thirty-four dots at
|
|
483
|
+
// this scale IS one dot. A view whose marks are far below its nodes is not drawing the
|
|
484
|
+
// thing it claims to draw.
|
|
485
|
+
function distinguishableMarks(pos) {
|
|
486
|
+
const cells = new Set();
|
|
487
|
+
for (const p of pos) {
|
|
488
|
+
if (!p) continue;
|
|
489
|
+
cells.add(`${Math.round(p[0] / MIN_DOT_PITCH)},${Math.round(p[1] / MIN_DOT_PITCH)}`);
|
|
490
|
+
}
|
|
491
|
+
return cells.size;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The number nothing clever escapes. Screen area is fixed; nodes are not.
|
|
495
|
+
function inkBudget(nodeCount, w = CANVAS_W, h = CANVAS_H) {
|
|
496
|
+
const area = w * h;
|
|
497
|
+
const per = area / Math.max(1, nodeCount);
|
|
498
|
+
return {
|
|
499
|
+
nodes: nodeCount,
|
|
500
|
+
px2_per_node: Math.round(per),
|
|
501
|
+
pitch_px: Number(Math.sqrt(per).toFixed(1)), // side of the square each node owns
|
|
502
|
+
fits_as_dot: Math.sqrt(per) >= MIN_DOT_PITCH,
|
|
503
|
+
// A label is not an area, it is a WIDTH. Comparing px² against 48×14 says yes at a
|
|
504
|
+
// 26px pitch, which would be true only if labels tiled with no gaps and no
|
|
505
|
+
// clustering — neither is a thing a force layout does. The honest test is whether
|
|
506
|
+
// the cell each node owns is as wide as the label it must carry.
|
|
507
|
+
fits_with_label: Math.sqrt(per) >= MIN_LABEL_W,
|
|
508
|
+
labels_that_fit: Math.floor(w / MIN_LABEL_W) * Math.floor(h / MIN_LABEL_H),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// PNG out — no dependency, so the evidence is a picture and not a promise
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
function crc32(buf) {
|
|
517
|
+
let c;
|
|
518
|
+
const table = crc32.table || (crc32.table = (() => {
|
|
519
|
+
const t = new Int32Array(256);
|
|
520
|
+
for (let n = 0; n < 256; n++) {
|
|
521
|
+
c = n;
|
|
522
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
523
|
+
t[n] = c;
|
|
524
|
+
}
|
|
525
|
+
return t;
|
|
526
|
+
})());
|
|
527
|
+
let crc = -1;
|
|
528
|
+
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xff];
|
|
529
|
+
return (crc ^ -1) >>> 0;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function chunk(type, data) {
|
|
533
|
+
const len = Buffer.alloc(4);
|
|
534
|
+
len.writeUInt32BE(data.length, 0);
|
|
535
|
+
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
536
|
+
const crc = Buffer.alloc(4);
|
|
537
|
+
crc.writeUInt32BE(crc32(td), 0);
|
|
538
|
+
return Buffer.concat([len, td, crc]);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function writePng(file, w, h, rgba) {
|
|
542
|
+
const stride = w * 4;
|
|
543
|
+
const raw = Buffer.alloc((stride + 1) * h);
|
|
544
|
+
for (let y = 0; y < h; y++) {
|
|
545
|
+
raw[y * (stride + 1)] = 0; // filter: none
|
|
546
|
+
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
547
|
+
}
|
|
548
|
+
const ihdr = Buffer.alloc(13);
|
|
549
|
+
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
|
550
|
+
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
551
|
+
const png = Buffer.concat([
|
|
552
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
553
|
+
chunk('IHDR', ihdr),
|
|
554
|
+
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
|
555
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
556
|
+
]);
|
|
557
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
558
|
+
fs.writeFileSync(file, png);
|
|
559
|
+
return png.length;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// A tiny raster canvas: exactly the primitives a node-link diagram needs.
|
|
563
|
+
function canvas(w, h, bg = [255, 255, 255, 255]) {
|
|
564
|
+
const buf = Buffer.alloc(w * h * 4);
|
|
565
|
+
for (let i = 0; i < w * h; i++) buf.set(bg, i * 4);
|
|
566
|
+
const px = (x, y, [r, g, b, a]) => {
|
|
567
|
+
x |= 0; y |= 0;
|
|
568
|
+
if (x < 0 || y < 0 || x >= w || y >= h) return;
|
|
569
|
+
const o = (y * w + x) * 4;
|
|
570
|
+
const ia = a / 255;
|
|
571
|
+
buf[o] = buf[o] * (1 - ia) + r * ia;
|
|
572
|
+
buf[o + 1] = buf[o + 1] * (1 - ia) + g * ia;
|
|
573
|
+
buf[o + 2] = buf[o + 2] * (1 - ia) + b * ia;
|
|
574
|
+
buf[o + 3] = 255;
|
|
575
|
+
};
|
|
576
|
+
const line = (x1, y1, x2, y2, col) => {
|
|
577
|
+
const steps = Math.ceil(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)));
|
|
578
|
+
for (let s = 0; s <= steps; s++) {
|
|
579
|
+
const t = steps === 0 ? 0 : s / steps;
|
|
580
|
+
px(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, col);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const disc = (cx, cy, r, col) => {
|
|
584
|
+
for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) if (x * x + y * y <= r * r) px(cx + x, cy + y, col);
|
|
585
|
+
};
|
|
586
|
+
return { buf, px, line, disc };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// One hue per goal, so "is this cluster one goal or four" is answerable by eye —
|
|
590
|
+
// the question a map like this exists to answer.
|
|
591
|
+
function goalColor(g, alpha = 235) {
|
|
592
|
+
const hue = (g * 137.508) % 360; // golden-angle spread
|
|
593
|
+
const s = 0.62; const l = 0.48;
|
|
594
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
595
|
+
const hp = hue / 60;
|
|
596
|
+
const xx = c * (1 - Math.abs((hp % 2) - 1));
|
|
597
|
+
let r = 0; let g2 = 0; let b = 0;
|
|
598
|
+
if (hp < 1) [r, g2, b] = [c, xx, 0];
|
|
599
|
+
else if (hp < 2) [r, g2, b] = [xx, c, 0];
|
|
600
|
+
else if (hp < 3) [r, g2, b] = [0, c, xx];
|
|
601
|
+
else if (hp < 4) [r, g2, b] = [0, xx, c];
|
|
602
|
+
else if (hp < 5) [r, g2, b] = [xx, 0, c];
|
|
603
|
+
else [r, g2, b] = [c, 0, xx];
|
|
604
|
+
const mm = l - c / 2;
|
|
605
|
+
return [Math.round((r + mm) * 255), Math.round((g2 + mm) * 255), Math.round((b + mm) * 255), alpha];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function render(file, view, laid, { radius = 3 } = {}) {
|
|
609
|
+
const cv = canvas(CANVAS_W, CANVAS_H, [252, 252, 251, 255]);
|
|
610
|
+
for (const [a, b] of view.edges) {
|
|
611
|
+
const ia = laid.idx.get(a); const ib = laid.idx.get(b);
|
|
612
|
+
if (ia == null || ib == null) continue;
|
|
613
|
+
const p = laid.pos[ia]; const q = laid.pos[ib];
|
|
614
|
+
cv.line(p[0], p[1], q[0], q[1], [110, 110, 118, 90]);
|
|
615
|
+
}
|
|
616
|
+
view.nodes.forEach((id, i) => {
|
|
617
|
+
const p = laid.pos[i];
|
|
618
|
+
if (!p) return;
|
|
619
|
+
cv.disc(p[0], p[1], radius, goalColor(view.groups[i]));
|
|
620
|
+
});
|
|
621
|
+
return writePng(file, CANVAS_W, CANVAS_H, cv.buf);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// the run
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
function measure(label, view, { render: doRender, file, radius = 3 } = {}) {
|
|
629
|
+
// The frame clamp is the node's own radius plus a pixel, so a node pressed against
|
|
630
|
+
// the wall by an over-full canvas is drawn whole. It still presses — that rim IS the
|
|
631
|
+
// ink budget made visible — but a clipped disc would look like a rendering bug and
|
|
632
|
+
// invite the reader to discount the picture.
|
|
633
|
+
const laid = layout(view.nodes, view.edges, { margin: radius + 2 });
|
|
634
|
+
const comp = components(view.nodes, view.edges);
|
|
635
|
+
const cross = crossingCount(view.edges, laid.pos, laid.idx);
|
|
636
|
+
const ink = inkBudget(view.nodes.length);
|
|
637
|
+
const marks = distinguishableMarks(laid.pos);
|
|
638
|
+
const bytes = Buffer.byteLength(JSON.stringify({ nodes: view.nodes, edges: view.edges }));
|
|
639
|
+
let pngBytes = null;
|
|
640
|
+
if (doRender && file) pngBytes = render(file, view, laid, { radius });
|
|
641
|
+
return {
|
|
642
|
+
label,
|
|
643
|
+
view: view.name,
|
|
644
|
+
nodes: view.nodes.length,
|
|
645
|
+
edges: view.edges.length,
|
|
646
|
+
components: comp.count,
|
|
647
|
+
largest_component: comp.largest,
|
|
648
|
+
isolated: comp.singletons,
|
|
649
|
+
isolated_pct: view.nodes.length ? Number(((comp.singletons / view.nodes.length) * 100).toFixed(1)) : 0,
|
|
650
|
+
marks,
|
|
651
|
+
overplot_pct: view.nodes.length ? Number((100 - (marks / view.nodes.length) * 100).toFixed(1)) : 0,
|
|
652
|
+
crossings: cross.crossings,
|
|
653
|
+
crossings_exact: cross.exact,
|
|
654
|
+
layout_ms: Number(laid.ms.toFixed(0)),
|
|
655
|
+
graph_bytes: bytes,
|
|
656
|
+
png_bytes: pngBytes,
|
|
657
|
+
ink,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function main() {
|
|
662
|
+
const results = [];
|
|
663
|
+
|
|
664
|
+
if (FROM) {
|
|
665
|
+
const tasks = loadReal(path.resolve(REPO_ROOT, FROM));
|
|
666
|
+
const v = viewsOf(tasks);
|
|
667
|
+
results.push(measure('real', v.A, { render: RENDER, file: path.join(OUT_DIR, 'real-a-every-task.png') }));
|
|
668
|
+
results.push(measure('real', v.B, { render: RENDER, file: path.join(OUT_DIR, 'real-b-dependency-forest.png') }));
|
|
669
|
+
results.push(measure('real', v.C, { render: RENDER, file: path.join(OUT_DIR, 'real-c-goals.png'), radius: 8 }));
|
|
670
|
+
results.push(measure('real', v.D, { render: RENDER, file: path.join(OUT_DIR, 'real-d-joined-by-goal.png') }));
|
|
671
|
+
} else {
|
|
672
|
+
for (const density of DENSITIES) {
|
|
673
|
+
const tasks = buildSynthetic({ nodes: N_TASKS, goals: N_GOALS, density });
|
|
674
|
+
const v = viewsOf(tasks);
|
|
675
|
+
const pct = Math.round(density * 100);
|
|
676
|
+
// Only the two ends of the sweep are drawn — five pairs of near-identical
|
|
677
|
+
// hairballs is not more evidence, it is more files.
|
|
678
|
+
const draw = RENDER && (density === DENSITIES[0] || density === DENSITIES[DENSITIES.length - 1]);
|
|
679
|
+
results.push({ density, ...measure(`p=${pct}%`, v.A, { render: draw, file: path.join(OUT_DIR, `a-every-task-p${pct}.png`) }) });
|
|
680
|
+
results.push({ density, ...measure(`p=${pct}%`, v.B, { render: draw, file: path.join(OUT_DIR, `b-dependency-forest-p${pct}.png`) }) });
|
|
681
|
+
results.push({ density, ...measure(`p=${pct}%`, v.C, { render: draw, file: path.join(OUT_DIR, `c-goals-p${pct}.png`), radius: 8 }) });
|
|
682
|
+
// D does not depend on the dependency density at all — that is its whole point —
|
|
683
|
+
// so it is measured and drawn once rather than five identical times.
|
|
684
|
+
if (density === DENSITIES[0]) {
|
|
685
|
+
results.push({ density: null, ...measure('any', v.D, { render: RENDER, file: path.join(OUT_DIR, 'd-joined-by-goal.png') }) });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (AS_JSON) {
|
|
691
|
+
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, results }, null, 2)}\n`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
696
|
+
const rpad = (s, n) => String(s).padStart(n);
|
|
697
|
+
console.log(`task-network-map spike — ${N_TASKS} tasks over ${N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
|
|
698
|
+
console.log(`ink floor: a legible dot needs ${MIN_DOT_PITCH}px of pitch; a label needs ${MIN_LABEL_W}×${MIN_LABEL_H}px `
|
|
699
|
+
+ `(${inkBudget(1).labels_that_fit} labels fit on this canvas, total)`);
|
|
700
|
+
console.log('');
|
|
701
|
+
console.log([pad('density', 9), pad('view', 26), rpad('nodes', 6), rpad('edges', 6), rpad('isol%', 6),
|
|
702
|
+
rpad('comps', 6), rpad('marks', 6), rpad('lost%', 6), rpad('cross', 9), rpad('pitch', 7),
|
|
703
|
+
rpad('label?', 7), rpad('ms', 6), rpad('KB', 7)].join(' '));
|
|
704
|
+
console.log('-'.repeat(120));
|
|
705
|
+
for (const r of results) {
|
|
706
|
+
console.log([
|
|
707
|
+
pad(r.label, 9), pad(r.view, 26), rpad(r.nodes, 6), rpad(r.edges, 6), rpad(r.isolated_pct, 6),
|
|
708
|
+
rpad(r.components, 6), rpad(r.marks, 6), rpad(r.overplot_pct, 6),
|
|
709
|
+
rpad(r.crossings + (r.crossings_exact ? '' : '~'), 9),
|
|
710
|
+
rpad(`${r.ink.pitch_px}px`, 7), rpad(r.ink.fits_with_label ? 'yes' : 'NO', 7),
|
|
711
|
+
rpad(r.layout_ms, 6), rpad((r.graph_bytes / 1024).toFixed(0), 7),
|
|
712
|
+
].join(' '));
|
|
713
|
+
}
|
|
714
|
+
console.log('');
|
|
715
|
+
console.log('marks = distinct 7px cells any node lands in — what a reader can actually tell apart.');
|
|
716
|
+
console.log('lost% = share of nodes that overplot onto a mark another node already owns.');
|
|
717
|
+
console.log('');
|
|
718
|
+
console.log(`PNGs: ${path.relative(REPO_ROOT, OUT_DIR)}/`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (require.main === module) main();
|
|
722
|
+
|
|
723
|
+
// The rasteriser is exported for the same reason the graph maths is: the figures
|
|
724
|
+
// this file writes are cited as EVIDENCE in ADR 0227, so a stride, CRC or
|
|
725
|
+
// alpha-blend bug would quietly corrupt the evidence rather than fail loudly.
|
|
726
|
+
// Exported = coverable; see tests/spike_task_map.mjs.
|
|
727
|
+
module.exports = {
|
|
728
|
+
buildSynthetic, viewsOf, components, layout, crossingCount, inkBudget, mulberry32,
|
|
729
|
+
crc32, chunk, writePng, canvas, goalColor, render,
|
|
730
|
+
};
|