wowbagger 0.5.0-beta.0 → 0.5.0
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/CHANGELOG.md +59 -0
- package/README.md +145 -81
- package/package.json +1 -1
- package/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +7 -5
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +98 -21
package/src/report-evidence.js
CHANGED
|
@@ -71,59 +71,120 @@ function shiftDays(date, days) {
|
|
|
71
71
|
.slice(0, 10);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// Arrivals against
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
// Arrivals against closures, week by week. An arrival is `created`; a closure
|
|
75
|
+
// is the item reaching any terminal status, because every one of the four
|
|
76
|
+
// terminal statuses removes work from the backlog. `done` counts the closures
|
|
77
|
+
// that delivered: only a `done` departure is finished work, so only `done`
|
|
78
|
+
// may be read as completed work.
|
|
79
|
+
function isValidDateString(value) {
|
|
80
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const parsed = Date.parse(`${value}T00:00:00Z`);
|
|
84
|
+
return !Number.isNaN(parsed)
|
|
85
|
+
&& new Date(parsed).toISOString().slice(0, 10) === value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The weeks an evidence window covers. The default is the trailing twelve
|
|
89
|
+
// calendar weeks ending in the week of `asOf`. An explicit range uses the
|
|
90
|
+
// inclusive UTC calendar dates `from` through `to` (`to <= asOf`); a week
|
|
91
|
+
// bucket the range cuts short is marked partial, and the rolling mean only
|
|
92
|
+
// covers complete weeks. A range never filters items out: anything created
|
|
93
|
+
// before `from` still contributes to cumulative inventory.
|
|
94
|
+
function resolveWindow(asOf, range) {
|
|
95
|
+
if (range === null || range === undefined) {
|
|
96
|
+
const lastWeek = weekStart(asOf);
|
|
97
|
+
const weekStarts = [];
|
|
98
|
+
for (let index = WINDOW_WEEKS - 1; index >= 0; index -= 1) {
|
|
99
|
+
weekStarts.push(shiftDays(lastWeek, -index * 7));
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
weekStarts,
|
|
103
|
+
start: weekStarts[0],
|
|
104
|
+
end: asOf,
|
|
105
|
+
partial: new Set(),
|
|
106
|
+
windowWeeks: WINDOW_WEEKS,
|
|
107
|
+
range: null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const from = range?.from ?? null;
|
|
111
|
+
const to = range?.to ?? null;
|
|
112
|
+
if (!isValidDateString(from) || !isValidDateString(to) || from > to || to > asOf) {
|
|
113
|
+
throw new Error(`invalid report range ${JSON.stringify(range)}`);
|
|
114
|
+
}
|
|
80
115
|
const weekStarts = [];
|
|
81
|
-
for (let
|
|
82
|
-
const start = shiftDays(lastWeek, -index * 7);
|
|
116
|
+
for (let start = weekStart(from); start <= weekStart(to); start = shiftDays(start, 7)) {
|
|
83
117
|
weekStarts.push(start);
|
|
84
|
-
|
|
118
|
+
}
|
|
119
|
+
const partial = new Set(
|
|
120
|
+
weekStarts.filter((start) => start < from || shiftDays(start, 6) > to),
|
|
121
|
+
);
|
|
122
|
+
const windowDays = (daysBetween(from, to) ?? 0) + 1;
|
|
123
|
+
const windowWeeks = windowDays / 7;
|
|
124
|
+
return {
|
|
125
|
+
weekStarts, start: from, end: to, partial, windowWeeks, range: { from, to },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildWeeklyFlow(allItems, asOf, range = null) {
|
|
130
|
+
const window = resolveWindow(asOf, range);
|
|
131
|
+
const counts = new Map();
|
|
132
|
+
for (const start of window.weekStarts) {
|
|
133
|
+
counts.set(start, {
|
|
134
|
+
weekStart: start, arrivals: 0, closures: 0, done: 0, partial: window.partial.has(start),
|
|
135
|
+
});
|
|
85
136
|
}
|
|
86
137
|
|
|
87
138
|
for (const item of allItems) {
|
|
88
139
|
const arrivalWeek = counts.get(weekStart(item.created));
|
|
89
|
-
if (arrivalWeek !== undefined) {
|
|
140
|
+
if (arrivalWeek !== undefined && item.created >= window.start && item.created <= window.end) {
|
|
90
141
|
arrivalWeek.arrivals += 1;
|
|
91
142
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
if (item.terminalDate === null) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const departureWeek = counts.get(weekStart(item.terminalDate));
|
|
147
|
+
if (departureWeek !== undefined
|
|
148
|
+
&& item.terminalDate >= window.start && item.terminalDate <= window.end) {
|
|
149
|
+
departureWeek.closures += 1;
|
|
150
|
+
if (item.status === 'done') {
|
|
151
|
+
departureWeek.done += 1;
|
|
152
|
+
}
|
|
97
153
|
}
|
|
98
154
|
}
|
|
99
|
-
|
|
100
|
-
return attachRollingMean(series);
|
|
155
|
+
return attachRollingMean(counts, window);
|
|
101
156
|
}
|
|
102
157
|
|
|
103
|
-
// A four-week trailing mean over
|
|
158
|
+
// A four-week trailing mean over closures. Weekly throughput is noisy
|
|
104
159
|
// enough that the bar heights alone mislead; the mean is the trend line.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
160
|
+
// A trailing window shorter than four complete weeks stays null: averaging
|
|
161
|
+
// over fewer weeks, or over a week the selected range cut short, would
|
|
162
|
+
// flatter or understate the start of the window.
|
|
107
163
|
const ROLLING_WEEKS = 4;
|
|
108
164
|
|
|
109
|
-
function attachRollingMean(
|
|
110
|
-
return
|
|
111
|
-
|
|
165
|
+
function attachRollingMean(counts, window) {
|
|
166
|
+
return window.weekStarts.map((start, index) => {
|
|
167
|
+
const week = counts.get(start);
|
|
168
|
+
const trailing = window.weekStarts.slice(Math.max(0, index - ROLLING_WEEKS + 1), index + 1);
|
|
169
|
+
if (trailing.length < ROLLING_WEEKS || trailing.some((member) => window.partial.has(member))) {
|
|
112
170
|
return { ...week, rolling: null };
|
|
113
171
|
}
|
|
114
|
-
const
|
|
115
|
-
const total = window.reduce((sum, member) => sum + member.completions, 0);
|
|
172
|
+
const total = trailing.reduce((sum, member) => sum + counts.get(member).closures, 0);
|
|
116
173
|
return { ...week, rolling: Math.round((total / ROLLING_WEEKS) * 100) / 100 };
|
|
117
174
|
});
|
|
118
175
|
}
|
|
119
|
-
|
|
120
176
|
// Cumulative flow over the same window the weekly series covers. Each item
|
|
121
177
|
// carries three timestamps - `created`, the accept decision, and its terminal
|
|
122
178
|
// date - so every day in the window can be replayed from the current bytes.
|
|
123
179
|
// Only three bands are honest here: `backlog -> in-progress` records no
|
|
124
|
-
// decision, so the ledger cannot say when work actually started.
|
|
125
|
-
|
|
126
|
-
|
|
180
|
+
// decision, so the ledger cannot say when work actually started. An explicit
|
|
181
|
+
// range moves the window edges but never drops early items: anything created
|
|
182
|
+
// before the range still stands in inventory on every day it spans.
|
|
183
|
+
// A band named Untriaged holds items with no recorded accept decision, which
|
|
184
|
+
// is reconstruction uncertainty - not proof the item sat untriaged. Deleted
|
|
185
|
+
// items and unrecorded transitions cannot be recovered from the snapshot.
|
|
186
|
+
export function buildCumulativeFlow(allItems, asOf, range = null) {
|
|
187
|
+
const window = resolveWindow(asOf, range);
|
|
127
188
|
const states = allItems.map((item) => ({
|
|
128
189
|
created: item.created,
|
|
129
190
|
accepted: item.decisions.find((decision) => decision.action === 'accept')?.date ?? null,
|
|
@@ -131,7 +192,7 @@ export function buildCumulativeFlow(allItems, asOf) {
|
|
|
131
192
|
}));
|
|
132
193
|
|
|
133
194
|
const points = [];
|
|
134
|
-
for (let date = start; date <=
|
|
195
|
+
for (let date = window.start; date <= window.end; date = shiftDays(date, 1)) {
|
|
135
196
|
const point = {
|
|
136
197
|
date, triage: 0, accepted: 0, terminal: 0,
|
|
137
198
|
};
|
|
@@ -205,11 +266,13 @@ const FORECAST_WEEK_CEILING = 520;
|
|
|
205
266
|
// the same --as-of, so the sampler may not read the clock or Math.random.
|
|
206
267
|
const FORECAST_SEED = 0x9e3779b9;
|
|
207
268
|
|
|
208
|
-
// Monte Carlo over observed weekly
|
|
209
|
-
// the ledger actually recorded rather than extrapolating an average,
|
|
210
|
-
// a band instead of one false-precision date.
|
|
269
|
+
// Monte Carlo over the observed weekly closure rate, per Vacanti: resample the
|
|
270
|
+
// weeks the ledger actually recorded rather than extrapolating an average,
|
|
271
|
+
// and report a band instead of one false-precision date. This is a
|
|
272
|
+
// closure-based estimate of when open work clears, not a feature-delivery
|
|
273
|
+
// commitment: closures include kills, deferrals, and archives alongside done.
|
|
211
274
|
export function buildForecast(weeks, remaining, asOf) {
|
|
212
|
-
const samples = weeks.map((week) => week.
|
|
275
|
+
const samples = weeks.map((week) => week.closures);
|
|
213
276
|
if (samples.reduce((total, value) => total + value, 0) === 0) {
|
|
214
277
|
return null;
|
|
215
278
|
}
|
|
@@ -283,21 +346,75 @@ function seededRandom(seed) {
|
|
|
283
346
|
};
|
|
284
347
|
}
|
|
285
348
|
|
|
286
|
-
export function buildEvidence(openItems, terminalItems, asOf) {
|
|
287
|
-
const
|
|
288
|
-
const
|
|
349
|
+
export function buildEvidence(openItems, terminalItems, asOf, range = null) {
|
|
350
|
+
const window = resolveWindow(asOf, range);
|
|
351
|
+
const retained = [...openItems, ...terminalItems];
|
|
352
|
+
const weeks = buildWeeklyFlow(retained, asOf, range);
|
|
353
|
+
const closureTotal = weeks.reduce((total, week) => total + week.closures, 0);
|
|
354
|
+
const doneTotal = weeks.reduce((total, week) => total + week.done, 0);
|
|
289
355
|
|
|
290
356
|
return {
|
|
357
|
+
range: window.range,
|
|
291
358
|
agingBuckets: buildAgingBuckets(openItems, asOf),
|
|
292
359
|
agingMatrix: buildAgingMatrix(openItems, asOf),
|
|
293
360
|
weeks,
|
|
294
361
|
throughput: {
|
|
295
|
-
total:
|
|
296
|
-
|
|
297
|
-
|
|
362
|
+
total: closureTotal,
|
|
363
|
+
done: doneTotal,
|
|
364
|
+
windowWeeks: window.windowWeeks,
|
|
365
|
+
perWeek: window.windowWeeks === 0
|
|
366
|
+
? 0
|
|
367
|
+
: Math.round((closureTotal / window.windowWeeks) * 100) / 100,
|
|
368
|
+
},
|
|
369
|
+
cumulativeFlow: buildCumulativeFlow(retained, asOf, range),
|
|
370
|
+
coverageGaps: {
|
|
371
|
+
// Retained items past triage with no recorded accept decision. Their
|
|
372
|
+
// band history reads as untriaged until departure, which is
|
|
373
|
+
// reconstruction uncertainty, not proof they sat untriaged. An item
|
|
374
|
+
// killed straight from triage records a kill instead of an accept;
|
|
375
|
+
// its history is complete, so it is not a gap.
|
|
376
|
+
missingAcceptance: retained.filter((item) => item.status !== 'triage'
|
|
377
|
+
&& !item.decisions.some((decision) => decision.action === 'accept')
|
|
378
|
+
&& !(item.status === 'killed'
|
|
379
|
+
&& item.decisions.some((decision) => decision.action === 'kill'))).length,
|
|
298
380
|
},
|
|
299
|
-
cumulativeFlow: buildCumulativeFlow([...openItems, ...terminalItems], asOf),
|
|
300
381
|
cycleTime: buildCycleTime(terminalItems),
|
|
301
382
|
forecast: buildForecast(weeks, openItems.length, asOf),
|
|
302
383
|
};
|
|
303
384
|
}
|
|
385
|
+
|
|
386
|
+
// The calculators the browser re-runs, serialized from the same functions
|
|
387
|
+
// Node executes above. Tested for runtime parity against the direct functions
|
|
388
|
+
// in a VM: the browser never carries a second formula.
|
|
389
|
+
function browserFunctionSource(fn) {
|
|
390
|
+
return fn.toString().replace(/^export\s+/, '');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function reportEvidenceBrowserSource() {
|
|
394
|
+
return [
|
|
395
|
+
`const AGE_BUCKETS = ${JSON.stringify(AGE_BUCKETS)};`,
|
|
396
|
+
`const OPEN_STATUSES = ${JSON.stringify(OPEN_STATUSES)};`,
|
|
397
|
+
`const WINDOW_WEEKS = ${WINDOW_WEEKS};`,
|
|
398
|
+
`const MILLISECONDS_PER_DAY = ${MILLISECONDS_PER_DAY};`,
|
|
399
|
+
`const ROLLING_WEEKS = ${ROLLING_WEEKS};`,
|
|
400
|
+
`const FORECAST_TRIALS = ${FORECAST_TRIALS};`,
|
|
401
|
+
`const FORECAST_WEEK_CEILING = ${FORECAST_WEEK_CEILING};`,
|
|
402
|
+
`const FORECAST_SEED = ${FORECAST_SEED};`,
|
|
403
|
+
browserFunctionSource(daysBetween),
|
|
404
|
+
browserFunctionSource(buildAgingBuckets),
|
|
405
|
+
browserFunctionSource(buildAgingMatrix),
|
|
406
|
+
browserFunctionSource(weekStart),
|
|
407
|
+
browserFunctionSource(shiftDays),
|
|
408
|
+
browserFunctionSource(isValidDateString),
|
|
409
|
+
browserFunctionSource(resolveWindow),
|
|
410
|
+
browserFunctionSource(buildWeeklyFlow),
|
|
411
|
+
browserFunctionSource(attachRollingMean),
|
|
412
|
+
browserFunctionSource(buildCumulativeFlow),
|
|
413
|
+
browserFunctionSource(buildCycleTime),
|
|
414
|
+
browserFunctionSource(percentile),
|
|
415
|
+
browserFunctionSource(buildForecast),
|
|
416
|
+
browserFunctionSource(cumulativeShare),
|
|
417
|
+
browserFunctionSource(seededRandom),
|
|
418
|
+
browserFunctionSource(buildEvidence),
|
|
419
|
+
].join('\n');
|
|
420
|
+
}
|
package/src/report-graph.js
CHANGED
|
@@ -66,6 +66,7 @@ export function buildGraphModel(model) {
|
|
|
66
66
|
asOf: model.asOf,
|
|
67
67
|
nodes,
|
|
68
68
|
links: buildLinks([...model.items, ...model.terminalItems], new Set(nodes.map((node) => node.id))),
|
|
69
|
+
impactById: model.impactById ?? {},
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -174,12 +175,10 @@ export function graphStyleSource() {
|
|
|
174
175
|
.join('');
|
|
175
176
|
return `${bandRules}
|
|
176
177
|
#graph-stage{position:relative;height:min(66vh,620px);border-radius:11px;background:#080b0f;overflow:hidden}
|
|
177
|
-
|
|
178
|
-
.graph-
|
|
179
|
-
.graph-
|
|
180
|
-
.graph-
|
|
181
|
-
.graph-filter-actions button{padding:7px 10px}
|
|
182
|
-
.graph-filter-actions .result-count{margin-right:auto}
|
|
178
|
+
#graph-node-count{margin:12px 0 0}
|
|
179
|
+
.graph-actions{display:flex;flex-wrap:wrap;gap:8px;margin:9px 0 0}
|
|
180
|
+
.graph-actions button{padding:6px 10px}
|
|
181
|
+
#graph-card .graph-actions button{border-color:#3b4c5e;background:#22303d;color:#e6edf3}
|
|
183
182
|
#graph-empty{margin:12px 0 0;border-left:3px solid var(--navy);background:#eef1f4;padding:10px 13px}
|
|
184
183
|
#graph-canvas{position:absolute;inset:0}
|
|
185
184
|
#graph-labels{position:absolute;inset:0;pointer-events:none}
|
|
@@ -214,9 +213,25 @@ export function graphStyleSource() {
|
|
|
214
213
|
|
|
215
214
|
// The roster is not a fallback bolted on for the WebGL-less reader; it is where
|
|
216
215
|
// the graph's decision-relevant content lives. The 3D view adds shape and
|
|
217
|
-
// adjacency to it and holds nothing the roster does not already say.
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
// adjacency to it and holds nothing the roster does not already say. Each row
|
|
217
|
+
// carries the same actions the graph card offers: canonical details through
|
|
218
|
+
// inspectItem, and labelled drilldowns from impactById. Only nonempty impact
|
|
219
|
+
// sets become buttons, so a terminal node offers details alone.
|
|
220
|
+
function renderGraphActions(node, impactById) {
|
|
221
|
+
const impact = impactById[node.id] ?? {};
|
|
222
|
+
const downstreamIds = Array.isArray(impact.downstreamIds) ? impact.downstreamIds : [];
|
|
223
|
+
const readyIds = Array.isArray(impact.readyIfDoneIds) ? impact.readyIfDoneIds : [];
|
|
224
|
+
let buttons = `<button type="button" data-inspect="${escapeHtml(node.id)}">Details</button>`;
|
|
225
|
+
if (downstreamIds.length > 0) {
|
|
226
|
+
buttons += `<button type="button" data-downstream="${escapeHtml(node.id)}">Downstream (${downstreamIds.length})</button>`;
|
|
227
|
+
}
|
|
228
|
+
if (readyIds.length > 0) {
|
|
229
|
+
buttons += `<button type="button" data-ready="${escapeHtml(node.id)}">Ready if done (${readyIds.length})</button>`;
|
|
230
|
+
}
|
|
231
|
+
return `<p class="graph-actions">${buttons}</p>`;
|
|
232
|
+
}
|
|
233
|
+
function renderRoster(nodes, impactById = {}) {
|
|
234
|
+
return `<details class="graph-fallback"><summary>Every node, without the graph</summary><ol class="graph-roster">${nodes.map((node) => `<li class="graph-band-${escapeHtml(node.band)}" data-node-id="${escapeHtml(node.id)}" data-node-status="${escapeHtml(node.status)}"><p class="graph-head"><span class="graph-handle">${escapeHtml(node.handle)}</span><span class="graph-title">${escapeHtml(node.title)}</span><span class="graph-meta">${escapeHtml(BAND_LABELS[node.band] ?? node.band)} · ${escapeHtml(node.status)} · age ${escapeHtml(node.ageDays)}d · unblocks ${escapeHtml(node.leverage)}</span></p><p class="graph-why">${node.reasons.map((reason) => `<span class="graph-reason">${escapeHtml(reason.label)}</span>`).join('')}</p>${renderGraphActions(node, impactById)}</li>`).join('')}</ol></details>`;
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
function renderLegend() {
|
|
@@ -225,20 +240,6 @@ function renderLegend() {
|
|
|
225
240
|
.join('');
|
|
226
241
|
return `<div id="graph-legend">${bands}<span class="graph-key graph-key-edge graph-band-depends">Unblocks (depends_on)</span><span class="graph-key graph-key-edge graph-key-edge-parent graph-band-parent">Parent to child</span></div>`;
|
|
227
242
|
}
|
|
228
|
-
|
|
229
|
-
// The filter offers the lifecycle statuses the ledger actually holds, each
|
|
230
|
-
// carrying how many nodes answer to it, and starts with all of them selected:
|
|
231
|
-
// the graph's own default is the whole ledger. Select all and Clear are the two
|
|
232
|
-
// moves a chip strip cannot make on its own.
|
|
233
|
-
function renderStatusFilter(nodes) {
|
|
234
|
-
const statuses = [...new Set(nodes.map((node) => node.status))].sort();
|
|
235
|
-
const chips = statuses.map((status) => {
|
|
236
|
-
const count = nodes.filter((node) => node.status === status).length;
|
|
237
|
-
return `<label class="chip"><input type="checkbox" class="graph-status" value="${escapeHtml(status)}" checked><span class="chip-text">${escapeHtml(status)}</span> <span class="chip-count">${count}</span></label>`;
|
|
238
|
-
}).join('');
|
|
239
|
-
return `<div id="graph-filter" class="graph-filter"><fieldset class="facet-group" data-group="graph-status"><legend>Status</legend><div class="chips">${chips}</div></fieldset><div class="graph-filter-actions"><p id="graph-node-count" class="result-count" role="status" aria-live="polite">Showing ${nodes.length} of ${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'}</p><span class="graph-filter-buttons"><button type="button" id="graph-status-all">Select all</button><button type="button" id="graph-status-clear">Clear</button></span></div></div>`;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
243
|
// The empty state has to name what emptied the graph. A reader who cleared
|
|
243
244
|
// every status did that, and selecting one undoes it. A named view that matched
|
|
244
245
|
// nothing was never narrowed by anybody, and there is no status for that reader
|
|
@@ -250,36 +251,58 @@ function renderGraphEmptyState(nodes, view) {
|
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
export function graphSection(model, manifest, view = null) {
|
|
254
|
+
const nodes = model.nodes;
|
|
253
255
|
return `<section id="graph" class="panel"><div class="section-heading"><div><p class="eyebrow">Dependencies</p><h2>Ledger graph</h2></div><p class="muted">Every item as a node, sized by how much it unblocks. Edges run from a prerequisite or parent to the item it releases.</p></div>
|
|
254
|
-
${
|
|
256
|
+
<p id="graph-node-count" class="result-count" role="status" aria-live="polite">Showing ${nodes.length} of ${nodes.length} ${nodes.length === 1 ? 'node' : 'nodes'}</p>
|
|
255
257
|
<div id="graph-stage"><div id="graph-canvas"></div><div id="graph-labels" aria-hidden="true"></div><aside id="graph-card" hidden></aside><p id="graph-hint">Drag to orbit · scroll to zoom · hover or click a node</p></div>
|
|
256
258
|
<p id="graph-nowebgl" hidden>This browser has no WebGL, so the graph cannot draw. Nothing is lost: every node, its status, its age, and the reasons that place it are listed below, and the graph only adds the shape of the dependencies between them.</p>
|
|
257
|
-
${renderGraphEmptyState(
|
|
259
|
+
${renderGraphEmptyState(nodes, view)}
|
|
258
260
|
${renderLegend()}
|
|
259
|
-
${renderRoster(model.
|
|
261
|
+
${renderRoster(nodes, model.impactById ?? {})}
|
|
260
262
|
<p class="muted">Rendered by ${escapeHtml(manifest.package)} ${escapeHtml(manifest.version)}, vendored and checksummed in this repository and inlined here. This report fetches nothing.</p></section>`;
|
|
261
263
|
}
|
|
262
264
|
|
|
263
265
|
export function graphClientSource(model) {
|
|
264
266
|
return `'use strict';
|
|
265
|
-
var GRAPH_MODEL=${scriptJson(model)};
|
|
267
|
+
var GRAPH_MODEL=${scriptJson({ asOf: model.asOf, nodes: model.nodes, links: model.links })};
|
|
266
268
|
var GRAPH_COLORS=${scriptJson(BAND_COLORS)};
|
|
267
269
|
var graphStage=document.getElementById('graph-stage');
|
|
268
270
|
var graphNotice=document.getElementById('graph-nowebgl');
|
|
269
271
|
var graphEmpty=document.getElementById('graph-empty');
|
|
270
272
|
var graphCount=document.getElementById('graph-node-count');
|
|
273
|
+
var graphEmptyCopy=graphEmpty?graphEmpty.textContent:'';
|
|
271
274
|
var graphFallback=document.querySelector('.graph-fallback');
|
|
272
|
-
var
|
|
273
|
-
var
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
var graphSection=document.getElementById('section-dependencies');
|
|
276
|
+
var graphNav=document.getElementById('nav-dependencies');
|
|
277
|
+
var graphReducedMotion=false;
|
|
278
|
+
try{graphReducedMotion=!!(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches);}catch(error){}
|
|
279
|
+
var graphStarted=false;
|
|
280
|
+
var graphDisposed=false;
|
|
281
|
+
var graphInstance=null;
|
|
282
|
+
var graphUnsubscribe=null;
|
|
283
|
+
var graphLabelRaf=0;
|
|
284
|
+
var graphRosterRows=Array.prototype.slice.call(document.querySelectorAll('[data-node-id]'));
|
|
285
|
+
var graphNodesById=Object.create(null);
|
|
286
|
+
GRAPH_MODEL.nodes.forEach(function(node){graphNodesById[node.id]=node;});
|
|
287
|
+
// The shared report scope owns selection: the controller installed by
|
|
288
|
+
// reportClientSource publishes matching items, and the graph draws the induced
|
|
289
|
+
// subgraph without recomputing readiness from an incomplete picture.
|
|
290
|
+
var graphReport=window.wowbaggerReport||null;
|
|
291
|
+
// The stage hands back the one function that redraws it. A scope that arrives
|
|
292
|
+
// before the stage exists waits here until the first sizing.
|
|
276
293
|
var graphDraw=null;
|
|
294
|
+
var graphPendingVisible=null;
|
|
277
295
|
var graphShownId=null;
|
|
296
|
+
// Probed once: every probe opens a WebGL context, and a retry loop that probes
|
|
297
|
+
// each frame exhausts the browser's context budget and evicts the graph's own.
|
|
298
|
+
var graphWebglProbe=null;
|
|
278
299
|
function graphWebglAvailable(){
|
|
300
|
+
if(graphWebglProbe!==null)return graphWebglProbe;
|
|
279
301
|
try{
|
|
280
302
|
var probe=document.createElement('canvas');
|
|
281
|
-
|
|
282
|
-
}catch(error){
|
|
303
|
+
graphWebglProbe=!!(window.WebGLRenderingContext&&(probe.getContext('webgl2')||probe.getContext('webgl')));
|
|
304
|
+
}catch(error){graphWebglProbe=false;}
|
|
305
|
+
return graphWebglProbe;
|
|
283
306
|
}
|
|
284
307
|
function graphBandColor(node){return GRAPH_COLORS[node.band]||'#94a3b8';}
|
|
285
308
|
function graphRenderCard(card,node){
|
|
@@ -308,52 +331,158 @@ function graphRenderCard(card,node){
|
|
|
308
331
|
why.append(chip);
|
|
309
332
|
});
|
|
310
333
|
card.append(handle,title,facts,why);
|
|
334
|
+
var actions=document.createElement('p');
|
|
335
|
+
actions.className='graph-actions';
|
|
336
|
+
var details=document.createElement('button');
|
|
337
|
+
details.type='button';
|
|
338
|
+
details.textContent='Details';
|
|
339
|
+
details.addEventListener('click',function(){graphInspect(node.id);});
|
|
340
|
+
actions.append(details);
|
|
341
|
+
var impact=graphImpactLists(node.id);
|
|
342
|
+
if(impact.downstreamIds.length>0){
|
|
343
|
+
var downstream=document.createElement('button');
|
|
344
|
+
downstream.type='button';
|
|
345
|
+
downstream.textContent='Downstream ('+impact.downstreamIds.length+')';
|
|
346
|
+
downstream.addEventListener('click',function(){graphShowImpact(node.id,'downstream');});
|
|
347
|
+
actions.append(downstream);
|
|
348
|
+
}
|
|
349
|
+
if(impact.readyIfDoneIds.length>0){
|
|
350
|
+
var unlocks=document.createElement('button');
|
|
351
|
+
unlocks.type='button';
|
|
352
|
+
unlocks.textContent='Ready if done ('+impact.readyIfDoneIds.length+')';
|
|
353
|
+
unlocks.addEventListener('click',function(){graphShowImpact(node.id,'ready');});
|
|
354
|
+
actions.append(unlocks);
|
|
355
|
+
}
|
|
356
|
+
card.append(actions);
|
|
311
357
|
card.hidden=false;
|
|
312
358
|
graphShownId=node.id;
|
|
313
359
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return chosen;
|
|
320
|
-
}
|
|
321
|
-
// One selection drives the stage, the roster, and the count together, so the
|
|
322
|
-
// three can never disagree about which part of the ledger is on screen.
|
|
323
|
-
function graphApplyFilter(){
|
|
324
|
-
var chosen=graphSelectedStatuses();
|
|
360
|
+
// One scope drives the stage, the roster, and the count together, so the three
|
|
361
|
+
// can never disagree about which part of the ledger is on screen. Bands and
|
|
362
|
+
// reasons come from the complete-ledger model, so hiding a prerequisite never
|
|
363
|
+
// recomputes the items it holds back.
|
|
364
|
+
function graphApplyScope(items){
|
|
325
365
|
var visible=Object.create(null);
|
|
326
366
|
var shown=0;
|
|
327
|
-
|
|
328
|
-
if(
|
|
329
|
-
|
|
330
|
-
|
|
367
|
+
(items||[]).forEach(function(item){
|
|
368
|
+
if(item&&graphNodesById[item.id]!==undefined&&visible[item.id]!==true){
|
|
369
|
+
visible[item.id]=true;
|
|
370
|
+
shown+=1;
|
|
371
|
+
}
|
|
331
372
|
});
|
|
332
373
|
graphRosterRows.forEach(function(row){
|
|
333
|
-
|
|
374
|
+
var id=row.getAttribute('data-node-id');
|
|
375
|
+
row.hidden=!(id!==null&&visible[id]===true);
|
|
334
376
|
});
|
|
335
377
|
var total=GRAPH_MODEL.nodes.length;
|
|
336
|
-
graphCount.textContent='Showing '+shown+' of '+total+(total===1?' node':' nodes');
|
|
337
|
-
graphEmpty
|
|
378
|
+
if(graphCount)graphCount.textContent='Showing '+shown+' of '+total+(total===1?' node':' nodes');
|
|
379
|
+
if(graphEmpty){
|
|
380
|
+
if(total===0){graphEmpty.hidden=false;}
|
|
381
|
+
else if(shown===0){graphEmpty.textContent='No items match the current scope, so the graph has nothing to draw.';graphEmpty.hidden=false;}
|
|
382
|
+
else{graphEmpty.textContent=graphEmptyCopy;graphEmpty.hidden=true;}
|
|
383
|
+
}
|
|
338
384
|
if(graphDraw)graphDraw(visible);
|
|
385
|
+
else graphPendingVisible=visible;
|
|
339
386
|
}
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
387
|
+
function graphMount(){return document.getElementById('graph-canvas');}
|
|
388
|
+
// Impact actions read the frozen controller map, never a recomputed graph: the
|
|
389
|
+
// labels carry the same downstream and ready-if-done sets the Items detail
|
|
390
|
+
// shows, with their distinct counts.
|
|
391
|
+
function graphImpactLists(id){
|
|
392
|
+
var entry=null;
|
|
393
|
+
try{entry=graphReport&&graphReport.impactById?graphReport.impactById[id]:null;}catch(error){entry=null;}
|
|
394
|
+
var downstream=entry&&Array.isArray(entry.downstreamIds)?entry.downstreamIds.slice():[];
|
|
395
|
+
var ready=entry&&Array.isArray(entry.readyIfDoneIds)?entry.readyIfDoneIds.slice():[];
|
|
396
|
+
return {downstreamIds:downstream,readyIfDoneIds:ready};
|
|
345
397
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
398
|
+
function graphNodeHandle(id){var node=graphNodesById[id];return node?node.handle:id;}
|
|
399
|
+
function graphInspect(id){
|
|
400
|
+
try{if(graphReport&&typeof graphReport.inspectItem==='function')return graphReport.inspectItem(id);}catch(error){}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
function graphShowImpact(id,kind){
|
|
404
|
+
if(!graphReport||typeof graphReport.showItems!=='function')return;
|
|
405
|
+
var lists=graphImpactLists(id);
|
|
406
|
+
var handle=graphNodeHandle(id);
|
|
407
|
+
if(kind==='downstream'&&lists.downstreamIds.length>0)graphReport.showItems({label:'Downstream of '+handle+' ('+lists.downstreamIds.length+')',itemIds:lists.downstreamIds});
|
|
408
|
+
if(kind==='ready'&&lists.readyIfDoneIds.length>0)graphReport.showItems({label:'Ready if '+handle+' is done ('+lists.readyIfDoneIds.length+')',itemIds:lists.readyIfDoneIds});
|
|
409
|
+
}
|
|
410
|
+
function graphWireActions(root){
|
|
411
|
+
Array.prototype.slice.call(root.querySelectorAll('[data-inspect]')).forEach(function(button){
|
|
412
|
+
button.addEventListener('click',function(){graphInspect(button.getAttribute('data-inspect'));});
|
|
413
|
+
});
|
|
414
|
+
Array.prototype.slice.call(root.querySelectorAll('[data-downstream]')).forEach(function(button){
|
|
415
|
+
button.addEventListener('click',function(){graphShowImpact(button.getAttribute('data-downstream'),'downstream');});
|
|
416
|
+
});
|
|
417
|
+
Array.prototype.slice.call(root.querySelectorAll('[data-ready]')).forEach(function(button){
|
|
418
|
+
button.addEventListener('click',function(){graphShowImpact(button.getAttribute('data-ready'),'ready');});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
graphWireActions(document);
|
|
422
|
+
function graphSectionHidden(){return !!(graphSection&&graphSection.hidden===true);}
|
|
423
|
+
function graphMeasurable(){var mount=graphMount();return !!(mount&&mount.clientWidth>0&&mount.clientHeight>0);}
|
|
424
|
+
function graphCanDraw(){return !!(window.ForceGraph3D&&graphWebglAvailable());}
|
|
425
|
+
// The 1,744-item reference built one canvas while Dependencies was still
|
|
426
|
+
// hidden: a hidden container measures zero, so the first sizing waits for a
|
|
427
|
+
// visible section with measurable dimensions. Repeated visits reuse the one
|
|
428
|
+
// canvas instead of starting the layout again.
|
|
429
|
+
function graphEnsureStarted(){
|
|
430
|
+
if(graphStarted||graphDisposed)return;
|
|
431
|
+
if(!graphCanDraw()||GRAPH_MODEL.nodes.length===0)return;
|
|
432
|
+
if(graphSectionHidden())return;
|
|
433
|
+
if(!graphMeasurable()){
|
|
434
|
+
if(window.requestAnimationFrame)window.requestAnimationFrame(function(){graphEnsureStarted();});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
351
437
|
graphStart();
|
|
438
|
+
graphStarted=true;
|
|
439
|
+
if(graphPendingVisible&&graphDraw)graphDraw(graphPendingVisible);
|
|
440
|
+
}
|
|
441
|
+
function graphNoteVisibility(){
|
|
442
|
+
if(!graphStarted||!graphInstance)return;
|
|
443
|
+
if(graphSectionHidden()){
|
|
444
|
+
if(graphInstance&&typeof graphInstance.pauseAnimation==='function')graphInstance.pauseAnimation();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
var mount=graphMount();
|
|
448
|
+
if(mount&&graphInstance&&typeof graphInstance.width==='function'){
|
|
449
|
+
try{graphInstance.width(mount.clientWidth).height(mount.clientHeight);}catch(error){}
|
|
450
|
+
}
|
|
451
|
+
if(graphInstance&&typeof graphInstance.resumeAnimation==='function')graphInstance.resumeAnimation();
|
|
452
|
+
}
|
|
453
|
+
// Disposal releases the layout, its label loop, and the scope subscription, so
|
|
454
|
+
// leaving the document does not leave the force layout ticking.
|
|
455
|
+
function graphDispose(){
|
|
456
|
+
if(graphDisposed)return;
|
|
457
|
+
graphDisposed=true;
|
|
458
|
+
if(typeof graphUnsubscribe==='function'){try{graphUnsubscribe();}catch(error){}graphUnsubscribe=null;}
|
|
459
|
+
if(window.cancelAnimationFrame&&graphLabelRaf){try{window.cancelAnimationFrame(graphLabelRaf);}catch(error){}graphLabelRaf=0;}
|
|
460
|
+
if(graphInstance&&typeof graphInstance._destructor==='function'){try{graphInstance._destructor();}catch(error){}}
|
|
461
|
+
graphInstance=null;
|
|
462
|
+
graphDraw=null;
|
|
463
|
+
}
|
|
464
|
+
if(!graphCanDraw()){
|
|
465
|
+
if(graphStage)graphStage.hidden=true;
|
|
466
|
+
if(graphNotice)graphNotice.hidden=false;
|
|
467
|
+
if(graphFallback)graphFallback.open=true;
|
|
468
|
+
}
|
|
469
|
+
if(graphReport&&typeof graphReport.subscribeScope==='function'){
|
|
470
|
+
graphUnsubscribe=graphReport.subscribeScope(function(items){graphApplyScope(items||[]);graphEnsureStarted();graphNoteVisibility();});
|
|
471
|
+
}else{
|
|
472
|
+
graphApplyScope(GRAPH_MODEL.nodes.map(function(node){return {id:node.id};}));
|
|
473
|
+
graphEnsureStarted();
|
|
474
|
+
}
|
|
475
|
+
if(graphNav)graphNav.addEventListener('click',function(){graphEnsureStarted();graphNoteVisibility();});
|
|
476
|
+
if(graphSection&&window.MutationObserver){
|
|
477
|
+
try{
|
|
478
|
+
new window.MutationObserver(function(){graphEnsureStarted();graphNoteVisibility();}).observe(graphSection,{attributes:true,attributeFilter:['hidden']});
|
|
479
|
+
}catch(error){}
|
|
480
|
+
}
|
|
481
|
+
if(window.addEventListener){
|
|
482
|
+
window.addEventListener('resize',function(){graphEnsureStarted();graphNoteVisibility();});
|
|
483
|
+
window.addEventListener('pagehide',function(){graphDispose();});
|
|
484
|
+
window.addEventListener('unload',function(){graphDispose();});
|
|
352
485
|
}
|
|
353
|
-
graphStatusInputs.forEach(function(input){input.addEventListener('change',graphApplyFilter);});
|
|
354
|
-
document.getElementById('graph-status-all').addEventListener('click',function(){graphSetEveryStatus(true);});
|
|
355
|
-
document.getElementById('graph-status-clear').addEventListener('click',function(){graphSetEveryStatus(false);});
|
|
356
|
-
graphApplyFilter();
|
|
357
486
|
function graphStart(){
|
|
358
487
|
var mount=document.getElementById('graph-canvas');
|
|
359
488
|
var labelLayer=document.getElementById('graph-labels');
|
|
@@ -381,9 +510,10 @@ function graphStart(){
|
|
|
381
510
|
.linkDirectionalArrowLength(function(link){return link.kind==='parent'?0:8;})
|
|
382
511
|
.linkDirectionalArrowRelPos(1)
|
|
383
512
|
.onNodeHover(function(node){if(node)graphRenderCard(card,node);})
|
|
384
|
-
.onNodeClick(function(node){if(node)graphRenderCard(card,node);})
|
|
513
|
+
.onNodeClick(function(node){if(node){graphRenderCard(card,node);graphInspect(node.id);}})
|
|
385
514
|
.onBackgroundClick(function(){card.hidden=true;graphShownId=null;})
|
|
386
|
-
.cooldownTicks(220);
|
|
515
|
+
.cooldownTicks(graphReducedMotion?0:220);
|
|
516
|
+
graphInstance=graph;
|
|
387
517
|
graph.d3Force('charge').strength(-40);
|
|
388
518
|
var labels=nodes.map(function(node){
|
|
389
519
|
var element=document.createElement('span');
|
|
@@ -397,11 +527,9 @@ function graphStart(){
|
|
|
397
527
|
graph.onEngineStop(function(){
|
|
398
528
|
if(framed||drawn===0)return;
|
|
399
529
|
framed=true;
|
|
400
|
-
graph.zoomToFit(800,20);
|
|
401
|
-
});
|
|
402
|
-
window.addEventListener('resize',function(){
|
|
403
|
-
graph.width(mount.clientWidth).height(mount.clientHeight);
|
|
530
|
+
graph.zoomToFit(graphReducedMotion?0:800,20);
|
|
404
531
|
});
|
|
532
|
+
window.addEventListener('resize',function(){graphNoteVisibility();});
|
|
405
533
|
// A hidden node's edges have nowhere to land, and its label would hang over
|
|
406
534
|
// empty space, so the node, its links, and its label leave together. Handing
|
|
407
535
|
// the graph new data reheats the layout, which is what re-forms the remaining
|
|
@@ -458,8 +586,8 @@ function graphStart(){
|
|
|
458
586
|
placement.element.style.transform='translate('+placement.screen.x.toFixed(1)+'px,'+placement.screen.y.toFixed(1)+'px)';
|
|
459
587
|
placement.element.style.opacity=(1-0.55*((placement.depth-nearest)/span)).toFixed(2);
|
|
460
588
|
}
|
|
461
|
-
window.requestAnimationFrame(graphPlaceLabels);
|
|
589
|
+
if(!graphDisposed)graphLabelRaf=window.requestAnimationFrame(graphPlaceLabels);
|
|
462
590
|
}
|
|
463
|
-
window.requestAnimationFrame(graphPlaceLabels);
|
|
591
|
+
graphLabelRaf=window.requestAnimationFrame(graphPlaceLabels);
|
|
464
592
|
}`;
|
|
465
593
|
}
|