@bongos/core 1.19.946 → 1.19.948
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 +72 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/clients/bongos-client/package.json +1 -0
- package/docs/api/openapi.json +32 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +37 -19
- package/docs/copy-registry.json +209 -26
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-map-lib.js +356 -0
- package/modules/hall-ui/public/goal-map.css +117 -0
- package/modules/hall-ui/public/goal-map.html +68 -0
- package/modules/hall-ui/public/goal-map.js +209 -0
- package/modules/hall-ui/public/goal-map.states.json +14 -0
- package/modules/hall-ui/public/goals.html +3 -0
- package/modules/lifecycle/goal-graph.js +176 -0
- package/modules/lifecycle/routes/goals.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-api-client.js +18 -3
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/api_client.mjs +37 -0
- package/tests/goal_graph.mjs +192 -0
- package/tests/goal_map_lib.mjs +214 -0
- package/tests/goal_map_page.mjs +236 -0
- package/tests/hall_page_gate_map.mjs +3 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// goal-map-lib.js — the goal map's model and layout, pure (task 1003479, goal
|
|
2
|
+
// 1000089, ADR 0227 §6 item 3 and its §10.4 amendment).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THE MAP IS. Goals are the nodes; an edge says "some tasks in goal A wait on
|
|
5
|
+
// tasks in goal B". ADR 0227 measured the real corpus (70 goals, 66 task edges
|
|
6
|
+
// across 33 goal pairs) and changed the shape to build: HALF the goals have no
|
|
7
|
+
// edge at all, and even the connected half is not one network but a large cluster
|
|
8
|
+
// and a scatter of pairs. So this file does three things the picture needs and a
|
|
9
|
+
// force layout would get wrong:
|
|
10
|
+
// 1. it SPLITS the goals into connected and stand-alone (§10.4 item 1) — the
|
|
11
|
+
// stand-alone ones are a list, never dots on a canvas that has nothing to say
|
|
12
|
+
// about them;
|
|
13
|
+
// 2. it finds the CLUSTERS (§10.4 item 2), largest first, so the view reads as
|
|
14
|
+
// "one cluster and a scatter of pairs" and not as a network;
|
|
15
|
+
// 3. it lays each cluster out in LAYERS — a goal sits one column right of the
|
|
16
|
+
// furthest goal it waits on — which is deterministic (same data, same
|
|
17
|
+
// picture, no random seed) and reads as a sequence rather than as a hairball.
|
|
18
|
+
//
|
|
19
|
+
// IT DRAWS NOTHING THE TABLE DOES NOT ALSO SAY. Every fact in the picture —
|
|
20
|
+
// which goals, how many tasks, which category, which goal waits on which — is a
|
|
21
|
+
// row or a cell from tableRows(), and the page ships the table by default, not
|
|
22
|
+
// behind a toggle (ADR 0227 §5). Category is a WORD on every node as well as a
|
|
23
|
+
// colour: the ADR 0192 set is not colour-distinguishable at this many nodes.
|
|
24
|
+
//
|
|
25
|
+
// NEVER VIEW A OR D. There is no function here that takes a task, and no mode
|
|
26
|
+
// that would densify: the only input is the aggregate GET /goals/graph returns,
|
|
27
|
+
// which carries no task identity at all (ADR 0227 §7).
|
|
28
|
+
//
|
|
29
|
+
// Pure — no window, no DOM, no fetch — and Node-require-safe (the blockers-lib.js
|
|
30
|
+
// / goals-lib.js precedent) so tests/goal_map_lib.mjs runs the real layout over
|
|
31
|
+
// the real fixture instead of agreeing with a copy of it.
|
|
32
|
+
(function (root, factory) {
|
|
33
|
+
var mod = factory();
|
|
34
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = mod;
|
|
35
|
+
else root.OTBGoalMapLib = mod;
|
|
36
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
// Geometry. A node is wide enough for ~24 characters of title at the page's
|
|
40
|
+
// label size (ADR 0227 measured a 48px pitch as the floor for a label to fit;
|
|
41
|
+
// these are well past it), and the gaps leave room for an edge to be seen.
|
|
42
|
+
var NODE_W = 168;
|
|
43
|
+
// 68, not 60: the extra 8px hold the weight bar (task 1003479 grader follow-up
|
|
44
|
+
// — "node weight = task count" was text-only; the bar makes it a real visual
|
|
45
|
+
// size, not just a number to read).
|
|
46
|
+
var NODE_H = 68;
|
|
47
|
+
var GAP_X = 52;
|
|
48
|
+
var GAP_Y = 14;
|
|
49
|
+
var PAD = 16;
|
|
50
|
+
var BLOCK_GAP = 28;
|
|
51
|
+
var MIN_ROW_W = 760;
|
|
52
|
+
// The categorical colour channel repeats after this many; the WORD on every
|
|
53
|
+
// node is what distinguishes them, so a repeat costs nothing.
|
|
54
|
+
var CATEGORY_COLOURS = 8;
|
|
55
|
+
var TITLE_CHARS = 24;
|
|
56
|
+
var SUB_CHARS = 30;
|
|
57
|
+
|
|
58
|
+
function num(id) { return Number(id); }
|
|
59
|
+
function byNumericId(a, b) { return num(a) - num(b); }
|
|
60
|
+
|
|
61
|
+
function clip(text, max) {
|
|
62
|
+
var t = String(text == null ? '' : text);
|
|
63
|
+
return t.length > max ? t.slice(0, max - 1).replace(/\s+$/, '') + '…' : t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A private goal the viewer cannot read arrives with title null. It stays a node
|
|
67
|
+
// (dropping it would falsely isolate its neighbours) and is named as what it is.
|
|
68
|
+
function displayTitle(node) { return node.title ? String(node.title) : 'A private goal'; }
|
|
69
|
+
|
|
70
|
+
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
|
|
71
|
+
|
|
72
|
+
// analyse — the graph's structure, with no geometry: nodes by id, who each goal
|
|
73
|
+
// waits on and who needs it, the connected/stand-alone split, and the clusters.
|
|
74
|
+
function analyse(payload) {
|
|
75
|
+
var rawNodes = (payload && Array.isArray(payload.nodes)) ? payload.nodes : [];
|
|
76
|
+
var rawEdges = (payload && Array.isArray(payload.edges)) ? payload.edges : [];
|
|
77
|
+
var byId = {};
|
|
78
|
+
var order = [];
|
|
79
|
+
rawNodes.forEach(function (n) {
|
|
80
|
+
var id = String(n.goal_id);
|
|
81
|
+
if (byId[id]) return;
|
|
82
|
+
byId[id] = Object.assign({}, n, { id: id });
|
|
83
|
+
order.push(id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
var waits = {};
|
|
87
|
+
var needed = {};
|
|
88
|
+
var edges = [];
|
|
89
|
+
var seen = {};
|
|
90
|
+
rawEdges.forEach(function (e) {
|
|
91
|
+
var from = String(e.from);
|
|
92
|
+
var to = String(e.to);
|
|
93
|
+
if (from === to || !byId[from] || !byId[to] || seen[from + '>' + to]) return;
|
|
94
|
+
seen[from + '>' + to] = true;
|
|
95
|
+
var count = Number(e.count) || 0;
|
|
96
|
+
edges.push({ from: from, to: to, count: count });
|
|
97
|
+
(waits[from] = waits[from] || []).push({ id: to, count: count });
|
|
98
|
+
(needed[to] = needed[to] || []).push({ id: from, count: count });
|
|
99
|
+
});
|
|
100
|
+
function bySize(a, b) { return (b.count - a.count) || byNumericId(a.id, b.id); }
|
|
101
|
+
Object.keys(waits).forEach(function (k) { waits[k].sort(bySize); });
|
|
102
|
+
Object.keys(needed).forEach(function (k) { needed[k].sort(bySize); });
|
|
103
|
+
|
|
104
|
+
// Union-find over the connected goals only.
|
|
105
|
+
var parent = {};
|
|
106
|
+
function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
|
|
107
|
+
function union(a, b) { var ra = find(a); var rb = find(b); if (ra !== rb) parent[rb] = ra; }
|
|
108
|
+
order.forEach(function (id) { if (waits[id] || needed[id]) parent[id] = id; });
|
|
109
|
+
edges.forEach(function (e) { union(e.from, e.to); });
|
|
110
|
+
|
|
111
|
+
var groups = {};
|
|
112
|
+
Object.keys(parent).forEach(function (id) { (groups[find(id)] = groups[find(id)] || []).push(id); });
|
|
113
|
+
var clusters = Object.keys(groups).map(function (k) { return groups[k].sort(byNumericId); });
|
|
114
|
+
clusters.sort(function (a, b) { return (b.length - a.length) || byNumericId(a[0], b[0]); });
|
|
115
|
+
var clusterOf = {};
|
|
116
|
+
clusters.forEach(function (ids, i) {
|
|
117
|
+
ids.forEach(function (id) { clusterOf[id] = { index: i + 1, size: ids.length }; });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
var isolated = order.filter(function (id) { return !parent.hasOwnProperty(id); }).sort(byNumericId);
|
|
121
|
+
return {
|
|
122
|
+
order: order, byId: byId, edges: edges, waits: waits, needed: needed,
|
|
123
|
+
clusters: clusters, clusterOf: clusterOf, isolated: isolated,
|
|
124
|
+
connected: order.length - isolated.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A goal sits one column right of the furthest goal it waits on. Goal-level
|
|
129
|
+
// dependencies CAN loop (task edges never do, but two goals can each hold a task
|
|
130
|
+
// the other's waits on), so an edge back onto the current path is ignored for
|
|
131
|
+
// layering — it is still drawn, it just does not push anything to the right.
|
|
132
|
+
function layerOf(ids, waits) {
|
|
133
|
+
var depth = {};
|
|
134
|
+
var state = {};
|
|
135
|
+
function visit(id) {
|
|
136
|
+
if (state[id] === 2) return depth[id];
|
|
137
|
+
if (state[id] === 1) return -1;
|
|
138
|
+
state[id] = 1;
|
|
139
|
+
var d = 0;
|
|
140
|
+
(waits[id] || []).forEach(function (dep) {
|
|
141
|
+
var dd = visit(dep.id);
|
|
142
|
+
if (dd >= 0) d = Math.max(d, dd + 1);
|
|
143
|
+
});
|
|
144
|
+
state[id] = 2;
|
|
145
|
+
depth[id] = d;
|
|
146
|
+
return d;
|
|
147
|
+
}
|
|
148
|
+
ids.forEach(visit);
|
|
149
|
+
return depth;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Order the goals inside each column by the average row of their neighbours in
|
|
153
|
+
// the columns already placed, a few alternating sweeps — the standard cheap way
|
|
154
|
+
// to cut crossings, and deterministic (ties fall back to goal id).
|
|
155
|
+
function orderColumns(columns, neighbours) {
|
|
156
|
+
var pos = {};
|
|
157
|
+
function reindex() { columns.forEach(function (col) { col.forEach(function (id, i) { pos[id] = i; }); }); }
|
|
158
|
+
reindex();
|
|
159
|
+
var layerOfId = {};
|
|
160
|
+
columns.forEach(function (col, li) { col.forEach(function (id) { layerOfId[id] = li; }); });
|
|
161
|
+
for (var sweep = 0; sweep < 4; sweep++) {
|
|
162
|
+
var down = sweep % 2 === 0;
|
|
163
|
+
var from = down ? 1 : columns.length - 2;
|
|
164
|
+
var to = down ? columns.length : -1;
|
|
165
|
+
for (var li = from; li !== to; li += down ? 1 : -1) {
|
|
166
|
+
var col = columns[li];
|
|
167
|
+
var key = {};
|
|
168
|
+
col.forEach(function (id) {
|
|
169
|
+
var ns = (neighbours[id] || []).filter(function (n) { return down ? layerOfId[n] < li : layerOfId[n] > li; });
|
|
170
|
+
key[id] = ns.length ? ns.reduce(function (s, n) { return s + pos[n]; }, 0) / ns.length : pos[id];
|
|
171
|
+
});
|
|
172
|
+
col.sort(function (a, b) { return (key[a] - key[b]) || byNumericId(a, b); });
|
|
173
|
+
col.forEach(function (id, i) { pos[id] = i; });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// layoutCluster — one cluster's goals in a block of its own: { w, h, at: { id: {x, y} } }.
|
|
179
|
+
function layoutCluster(ids, a) {
|
|
180
|
+
var depth = layerOf(ids, a.waits);
|
|
181
|
+
var neighbours = {};
|
|
182
|
+
ids.forEach(function (id) {
|
|
183
|
+
neighbours[id] = (a.waits[id] || []).concat(a.needed[id] || []).map(function (n) { return n.id; });
|
|
184
|
+
});
|
|
185
|
+
var columns = [];
|
|
186
|
+
ids.forEach(function (id) { (columns[depth[id]] = columns[depth[id]] || []).push(id); });
|
|
187
|
+
columns = columns.map(function (c) { return (c || []).sort(byNumericId); });
|
|
188
|
+
orderColumns(columns, neighbours);
|
|
189
|
+
|
|
190
|
+
var rows = columns.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
|
|
191
|
+
var w = columns.length * NODE_W + (columns.length - 1) * GAP_X;
|
|
192
|
+
var h = rows * NODE_H + (rows - 1) * GAP_Y;
|
|
193
|
+
var at = {};
|
|
194
|
+
columns.forEach(function (col, li) {
|
|
195
|
+
var colH = col.length * NODE_H + (col.length - 1) * GAP_Y;
|
|
196
|
+
var top = Math.round((h - colH) / 2);
|
|
197
|
+
col.forEach(function (id, i) { at[id] = { x: li * (NODE_W + GAP_X), y: top + i * (NODE_H + GAP_Y) }; });
|
|
198
|
+
});
|
|
199
|
+
return { w: w, h: h, at: at };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function edgePath(dep, dependent) {
|
|
203
|
+
// Drawn dependency → dependent, left to right: "this had to ship first".
|
|
204
|
+
var x1 = dep.x + NODE_W;
|
|
205
|
+
var y1 = dep.y + NODE_H / 2;
|
|
206
|
+
var x2 = dependent.x;
|
|
207
|
+
var y2 = dependent.y + NODE_H / 2;
|
|
208
|
+
var dx = x2 > x1 ? (x2 - x1) / 2 : 44;
|
|
209
|
+
return 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// layout — every cluster laid out, then packed into rows ("shelves") so the
|
|
213
|
+
// big cluster sits on top and the pairs tile beneath it. Returns the SVG's own
|
|
214
|
+
// size: the page puts the svg in an overflow-x container and NEVER lets the page
|
|
215
|
+
// body scroll sideways (ADR 0227 §5.2).
|
|
216
|
+
function layout(a) {
|
|
217
|
+
var blocks = a.clusters.map(function (ids) { return layoutCluster(ids, a); });
|
|
218
|
+
var rowW = Math.max(MIN_ROW_W, blocks.length ? blocks[0].w : 0);
|
|
219
|
+
var rows = [];
|
|
220
|
+
var cur = { items: [], w: 0, h: 0 };
|
|
221
|
+
blocks.forEach(function (b, i) {
|
|
222
|
+
if (cur.items.length && cur.w + BLOCK_GAP + b.w > rowW) { rows.push(cur); cur = { items: [], w: 0, h: 0 }; }
|
|
223
|
+
cur.items.push({ i: i, x: cur.items.length ? cur.w + BLOCK_GAP : 0 });
|
|
224
|
+
cur.w += (cur.items.length > 1 ? BLOCK_GAP : 0) + b.w;
|
|
225
|
+
cur.h = Math.max(cur.h, b.h);
|
|
226
|
+
});
|
|
227
|
+
if (cur.items.length) rows.push(cur);
|
|
228
|
+
|
|
229
|
+
var nodes = [];
|
|
230
|
+
var abs = {};
|
|
231
|
+
var y = PAD;
|
|
232
|
+
var width = 0;
|
|
233
|
+
rows.forEach(function (row) {
|
|
234
|
+
row.items.forEach(function (it) {
|
|
235
|
+
var b = blocks[it.i];
|
|
236
|
+
Object.keys(b.at).forEach(function (id) {
|
|
237
|
+
var p = { x: PAD + it.x + b.at[id].x, y: y + b.at[id].y };
|
|
238
|
+
abs[id] = p;
|
|
239
|
+
nodes.push({ id: id, x: p.x, y: p.y, w: NODE_W, h: NODE_H });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
width = Math.max(width, PAD + row.w + PAD);
|
|
243
|
+
y += row.h + BLOCK_GAP;
|
|
244
|
+
});
|
|
245
|
+
var height = rows.length ? y - BLOCK_GAP + PAD : 0;
|
|
246
|
+
|
|
247
|
+
var edges = a.edges.map(function (e) {
|
|
248
|
+
return {
|
|
249
|
+
from: e.from, to: e.to, count: e.count,
|
|
250
|
+
d: edgePath(abs[e.to], abs[e.from]),
|
|
251
|
+
// 1px for a single task edge, up to 3px — weight is a hint, the table has the count.
|
|
252
|
+
width: 1 + Math.min(2, Math.floor(Math.log(Math.max(1, e.count)) / Math.LN2 / 1.5)),
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
return { width: width, height: height, nodes: nodes, edges: edges, nodeW: NODE_W, nodeH: NODE_H };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// categories — the legend, and the colour each category wears. Index follows
|
|
259
|
+
// the alphabetical order of the labels PRESENT, so a category keeps its colour
|
|
260
|
+
// from one visit to the next while the set does not change.
|
|
261
|
+
function categories(a) {
|
|
262
|
+
var seen = {};
|
|
263
|
+
a.order.forEach(function (id) {
|
|
264
|
+
var c = a.byId[id].category;
|
|
265
|
+
if (c && c.slug && !seen[c.slug]) seen[c.slug] = { slug: c.slug, label: c.label, count: 0 };
|
|
266
|
+
if (c && c.slug) seen[c.slug].count++;
|
|
267
|
+
});
|
|
268
|
+
var list = Object.keys(seen).map(function (k) { return seen[k]; })
|
|
269
|
+
.sort(function (x, y) { return String(x.label).localeCompare(String(y.label)) || (x.slug < y.slug ? -1 : 1); });
|
|
270
|
+
list.forEach(function (c, i) { c.colour = i % CATEGORY_COLOURS; });
|
|
271
|
+
var uncategorised = a.order.filter(function (id) { return !a.byId[id].category; }).length;
|
|
272
|
+
return { list: list, uncategorised: uncategorised };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function colourOf(node, cats) {
|
|
276
|
+
if (!node.category) return 'none';
|
|
277
|
+
for (var i = 0; i < cats.list.length; i++) if (cats.list[i].slug === node.category.slug) return String(cats.list[i].colour);
|
|
278
|
+
return 'none';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// weightOf — the node's WEIGHT (ADR 0227 §6 item 3: "node weight = task
|
|
282
|
+
// count"), as a 0–1 fraction of the busiest DRAWN goal's task count. Log-scaled
|
|
283
|
+
// so one 127-task goal does not flatten every other bar to nothing — the same
|
|
284
|
+
// reasoning `edgePath`'s width already uses for edge count. `maxTotal` is the
|
|
285
|
+
// caller's (the drawn set, not the whole corpus) so the scale means something
|
|
286
|
+
// on THIS map; a maxTotal of 0 (every drawn goal has zero tasks) draws no bar
|
|
287
|
+
// rather than dividing by zero into a false full one.
|
|
288
|
+
function weightOf(total, maxTotal) {
|
|
289
|
+
var t = Math.max(0, Number(total) || 0);
|
|
290
|
+
var m = Math.max(0, Number(maxTotal) || 0);
|
|
291
|
+
if (!m || !t) return 0;
|
|
292
|
+
return Math.min(1, Math.log(1 + t) / Math.log(1 + m));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// tableRows — the primary surface. One row per goal, carrying every fact the
|
|
296
|
+
// picture carries plus the two directions of each dependency BY NAME, because
|
|
297
|
+
// with this many separate clusters the table holds more of the structure than
|
|
298
|
+
// the picture does (§10.4 item 3).
|
|
299
|
+
function tableRows(a) {
|
|
300
|
+
function named(list) {
|
|
301
|
+
return (list || []).map(function (x) { return { id: x.id, title: displayTitle(a.byId[x.id]), count: x.count }; });
|
|
302
|
+
}
|
|
303
|
+
var rows = a.order.map(function (id) {
|
|
304
|
+
var n = a.byId[id];
|
|
305
|
+
var cl = a.clusterOf[id] || null;
|
|
306
|
+
return {
|
|
307
|
+
id: id,
|
|
308
|
+
title: displayTitle(n),
|
|
309
|
+
isPrivate: !n.title,
|
|
310
|
+
version: n.version_id || '',
|
|
311
|
+
status: n.status || '',
|
|
312
|
+
category: n.category ? n.category.label : '',
|
|
313
|
+
done: n.task_done || 0,
|
|
314
|
+
total: n.task_total || 0,
|
|
315
|
+
waits: named(a.waits[id]),
|
|
316
|
+
needed: named(a.needed[id]),
|
|
317
|
+
waitsGoals: n.depends_on != null ? n.depends_on : (a.waits[id] || []).length,
|
|
318
|
+
neededGoals: n.depended_on_by != null ? n.depended_on_by : (a.needed[id] || []).length,
|
|
319
|
+
cluster: cl ? cl.index : 0,
|
|
320
|
+
clusterSize: cl ? cl.size : 1,
|
|
321
|
+
clusterLabel: cl ? 'Cluster ' + cl.index + ' · ' + plural(cl.size, 'goal') : 'Stands alone',
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
// Big cluster first, stand-alone last; within a group the busiest goal first.
|
|
325
|
+
rows.sort(function (x, y) {
|
|
326
|
+
var cx = x.cluster || 1e9;
|
|
327
|
+
var cy = y.cluster || 1e9;
|
|
328
|
+
return (cx - cy) || (y.total - x.total) || byNumericId(x.id, y.id);
|
|
329
|
+
});
|
|
330
|
+
return rows;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// describe — the sentence that states the split out loud (ADR 0227 §10.4 item 1:
|
|
334
|
+
// "with the split stated on the page"). Numbers only; no claim the data cannot back.
|
|
335
|
+
function describe(a) {
|
|
336
|
+
var goals = a.order.length;
|
|
337
|
+
if (!goals) return { summary: 'No goal carries any work yet.', graph: '' };
|
|
338
|
+
if (!a.connected) {
|
|
339
|
+
return {
|
|
340
|
+
summary: plural(goals, 'goal') + ' carry work, and no goal waits on another goal’s tasks.',
|
|
341
|
+
graph: 'No goal depends on another goal’s work, so there is no graph to draw — the table below is the whole map.',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
var largest = a.clusters[0].length;
|
|
345
|
+
var summary = plural(goals, 'goal') + ' carry work. ' + a.connected + ' of them ' + (a.connected === 1 ? 'is' : 'are')
|
|
346
|
+
+ ' tied to another goal by a dependency; the other ' + a.isolated.length + ' stand alone. '
|
|
347
|
+
+ 'The ' + a.connected + ' form ' + plural(a.clusters.length, 'cluster') + ', the largest with ' + plural(largest, 'goal') + '.';
|
|
348
|
+
return { summary: summary, graph: '' };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
analyse: analyse, layout: layout, categories: categories, colourOf: colourOf, weightOf: weightOf,
|
|
353
|
+
tableRows: tableRows, describe: describe, displayTitle: displayTitle, clip: clip,
|
|
354
|
+
plural: plural, TITLE_CHARS: TITLE_CHARS, SUB_CHARS: SUB_CHARS, CATEGORY_COLOURS: CATEGORY_COLOURS,
|
|
355
|
+
};
|
|
356
|
+
}));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* goal-map.css — the goal map (task 1003479, goal 1000089; ADR 0227 §6 item 3).
|
|
2
|
+
*
|
|
3
|
+
* The page is the board archetype: the shell's .scroll panels, the kit's ledger
|
|
4
|
+
* for the table, and only the shapes no other page has — the node-link figure, its
|
|
5
|
+
* category colour channel and legend, and the stand-alone chips.
|
|
6
|
+
*
|
|
7
|
+
* COLOUR. Every value below is a var() or a color-mix() of the thirteen contract
|
|
8
|
+
* colours (DESIGN.md's Fifteen Rule), so the page inherits dark and light and an
|
|
9
|
+
* instance's palette. Category is a colour AND a word on every node — the ADR 0192
|
|
10
|
+
* set is not colour-distinguishable at this many nodes — so the eight hues below
|
|
11
|
+
* are a redundant channel, and a ninth category repeating the first costs nothing.
|
|
12
|
+
* These are page-scoped classes on the figure, never a :root (the pack's :root is
|
|
13
|
+
* spliced at (0,1,0); a page's own would neither win reliably nor lose cleanly).
|
|
14
|
+
*
|
|
15
|
+
* NO SIDEWAYS SCROLL ON THE PAGE. The figure scrolls inside .gm-scroll, which names
|
|
16
|
+
* BOTH axes — a lone overflow-x makes the other axis `auto` too and any 1px
|
|
17
|
+
* overhang grows a vertical scrollbar nothing can scroll (task 1004130).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
.gm-figure { margin: 0; }
|
|
21
|
+
.gm-figure .scroll__lede { margin-bottom: var(--sp-md); }
|
|
22
|
+
|
|
23
|
+
.gm-scroll {
|
|
24
|
+
overflow-x: auto;
|
|
25
|
+
overflow-y: hidden;
|
|
26
|
+
max-width: 100%;
|
|
27
|
+
border: var(--line) solid var(--rule-soft);
|
|
28
|
+
border-radius: var(--radius);
|
|
29
|
+
background: color-mix(in srgb, var(--ink) 3%, var(--bg-card));
|
|
30
|
+
}
|
|
31
|
+
.gm-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
32
|
+
.gm-svg { display: block; }
|
|
33
|
+
|
|
34
|
+
/* Eight categorical hues, derived. */
|
|
35
|
+
.gm-c0 { --gm-c: var(--sea); }
|
|
36
|
+
.gm-c1 { --gm-c: var(--accent-deep); }
|
|
37
|
+
.gm-c2 { --gm-c: var(--terracotta); }
|
|
38
|
+
.gm-c3 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--terracotta)); }
|
|
39
|
+
.gm-c4 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--ink)); }
|
|
40
|
+
.gm-c5 { --gm-c: color-mix(in srgb, var(--accent-deep) 60%, var(--ink)); }
|
|
41
|
+
.gm-c6 { --gm-c: color-mix(in srgb, var(--accent) 55%, var(--sea)); }
|
|
42
|
+
.gm-c7 { --gm-c: var(--ink-soft); }
|
|
43
|
+
|
|
44
|
+
.gm-edge {
|
|
45
|
+
fill: none;
|
|
46
|
+
stroke: var(--ink-faint);
|
|
47
|
+
opacity: 0.85;
|
|
48
|
+
}
|
|
49
|
+
.gm-arrowhead { fill: var(--ink-faint); }
|
|
50
|
+
|
|
51
|
+
.gm-card {
|
|
52
|
+
fill: var(--bg-card);
|
|
53
|
+
stroke: var(--rule);
|
|
54
|
+
stroke-width: 1;
|
|
55
|
+
}
|
|
56
|
+
.gm-stripe { fill: var(--gm-c, transparent); }
|
|
57
|
+
/* Uncategorised is legal (ADR 0192): a hollow, dashed stripe — present, and plainly not a colour. */
|
|
58
|
+
.gm-cnone .gm-stripe { fill: none; stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 2; }
|
|
59
|
+
|
|
60
|
+
.gm-name { font: 600 13px var(--font-body); fill: var(--ink); }
|
|
61
|
+
.gm-cat { font: 500 11px var(--font-body); fill: var(--ink-soft); }
|
|
62
|
+
.gm-tasks { font: 500 11px var(--font-body); fill: var(--ink-faint); }
|
|
63
|
+
|
|
64
|
+
/* The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count") — a bar
|
|
65
|
+
* scaled to the busiest goal drawn on THIS map, so its length is a real second
|
|
66
|
+
* channel beside the task-count text, not a decoration repeating it. */
|
|
67
|
+
.gm-weight-track { fill: var(--rule-soft); }
|
|
68
|
+
.gm-weight-fill { fill: var(--gm-c, var(--ink-soft)); }
|
|
69
|
+
|
|
70
|
+
/* The legend is a run of labelled swatches under the figure. */
|
|
71
|
+
.gm-legend {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-wrap: wrap;
|
|
74
|
+
gap: var(--sp-hair) var(--sp-md);
|
|
75
|
+
margin: var(--sp-md) 0 0;
|
|
76
|
+
padding: 0;
|
|
77
|
+
list-style: none;
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
color: var(--ink-soft);
|
|
80
|
+
}
|
|
81
|
+
.gm-legend li { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; }
|
|
82
|
+
.gm-swatch {
|
|
83
|
+
width: 10px;
|
|
84
|
+
height: 10px;
|
|
85
|
+
border-radius: 2px;
|
|
86
|
+
background: var(--gm-c, transparent);
|
|
87
|
+
}
|
|
88
|
+
.gm-swatch.gm-cnone { background: none; box-shadow: inset 0 0 0 1px var(--ink-faint); }
|
|
89
|
+
.gm-n { color: var(--ink-faint); }
|
|
90
|
+
|
|
91
|
+
/* The stand-alone goals: chips that wrap, not a grid of cards. */
|
|
92
|
+
.gm-alone {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
gap: var(--sp-hair);
|
|
96
|
+
margin: 0;
|
|
97
|
+
padding: 0;
|
|
98
|
+
list-style: none;
|
|
99
|
+
}
|
|
100
|
+
/* A long goal title wraps inside its chip rather than pushing past the panel. */
|
|
101
|
+
.gm-chip {
|
|
102
|
+
min-height: 24px;
|
|
103
|
+
max-width: 100%;
|
|
104
|
+
flex-wrap: wrap;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 0 var(--sp-hair);
|
|
107
|
+
white-space: normal;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Table cells that list goals. */
|
|
111
|
+
/* A floor on the dependency columns: without it a phone squeezes them to a few
|
|
112
|
+
characters and every row becomes a tall, empty tower. With it the table is
|
|
113
|
+
wider than the card and scrolls inside it (the hall's own ledger rule). */
|
|
114
|
+
.gm-deps { margin: 0; padding: 0; list-style: none; min-width: 14rem; }
|
|
115
|
+
.gm-deps li { font-size: 13px; line-height: 1.45; }
|
|
116
|
+
.gm-none { color: var(--ink-faint); }
|
|
117
|
+
.gm-private { color: var(--ink-soft); font-style: italic; }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Goal map — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="Which goals wait on which other goals: the goals that are tied together drawn as a map, the goals that stand alone listed beside it, and every goal in a table.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/goal-map.css">
|
|
13
|
+
</head>
|
|
14
|
+
<!-- THE GOAL MAP (task 1003479, goal 1000089; ADR 0227 §6 item 3 + §10.4). The
|
|
15
|
+
picture is the ENHANCEMENT and the table is the page: every fact the map
|
|
16
|
+
draws is a cell in the table under it, on screen by default and not behind a
|
|
17
|
+
toggle (ADR 0227 §5). It takes no nav slot — like the sky, it is reached from
|
|
18
|
+
the page it maps (Goals) and is never the only path to anything it shows. -->
|
|
19
|
+
<body data-page="goal-map" data-page-title="Goal map">
|
|
20
|
+
|
|
21
|
+
<div class="app">
|
|
22
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
23
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
24
|
+
<div class="app-body">
|
|
25
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
26
|
+
<div class="app-content">
|
|
27
|
+
<div class="page-head page-head--board">
|
|
28
|
+
<h1 class="page-head__h">Goal map</h1>
|
|
29
|
+
<p class="page-head__sub" id="gm-sub">Reading the goals…</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<main id="hall-main">
|
|
33
|
+
<section class="scroll" id="gm-graph-scroll" aria-labelledby="gm-graph-h">
|
|
34
|
+
<h2 id="gm-graph-h" class="scroll__h">Goals that wait on each other</h2>
|
|
35
|
+
<div id="gm-graph"><p class="profile__placeholder">Loading…</p></div>
|
|
36
|
+
<ul id="gm-legend" class="gm-legend" aria-label="Categories" hidden></ul>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<section class="scroll" id="gm-alone-scroll" aria-labelledby="gm-alone-h" hidden>
|
|
40
|
+
<h2 id="gm-alone-h" class="scroll__h">Goals that stand alone</h2>
|
|
41
|
+
<p class="scroll__lede" id="gm-alone-lede"></p>
|
|
42
|
+
<ul id="gm-alone" class="gm-alone"></ul>
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<section class="scroll" id="gm-table-scroll" aria-labelledby="gm-table-h" hidden>
|
|
46
|
+
<h2 id="gm-table-h" class="scroll__h">Every goal, as a table</h2>
|
|
47
|
+
<p class="scroll__lede">The same facts as the map, and more of them: with this many separate clusters the table holds more of the structure than the picture does. Each goal lists, by name, the goals it waits on and the goals that wait on it, with how many tasks the dependency runs through.</p>
|
|
48
|
+
<div id="gm-table"></div>
|
|
49
|
+
</section>
|
|
50
|
+
</main>
|
|
51
|
+
|
|
52
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
58
|
+
|
|
59
|
+
<script src="/builders/shell.js?v=2026-09-20-room1"></script>
|
|
60
|
+
<script src="/builders/palette.js" defer></script>
|
|
61
|
+
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
62
|
+
<script src="/builders/hall-kit.js"></script>
|
|
63
|
+
<!-- The model and layout are a pure lib so a test runs the real arithmetic; the
|
|
64
|
+
page script keeps only the drawing and the table. Lib BEFORE page. -->
|
|
65
|
+
<script src="/builders/goal-map-lib.js"></script>
|
|
66
|
+
<script src="/builders/goal-map.js"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|