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.
@@ -71,59 +71,120 @@ function shiftDays(date, days) {
71
71
  .slice(0, 10);
72
72
  }
73
73
 
74
- // Arrivals against completions, week by week. An arrival is `created`; a
75
- // completion is the item reaching any terminal status, because every one of the
76
- // four terminal statuses removes work from the backlog.
77
- export function buildWeeklyFlow(allItems, asOf) {
78
- const lastWeek = weekStart(asOf);
79
- const counts = new Map();
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 index = WINDOW_WEEKS - 1; index >= 0; index -= 1) {
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
- counts.set(start, { weekStart: start, arrivals: 0, completions: 0 });
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
- const departureWeek = item.terminalDate === null
93
- ? undefined
94
- : counts.get(weekStart(item.terminalDate));
95
- if (departureWeek !== undefined) {
96
- departureWeek.completions += 1;
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
- const series = weekStarts.map((start) => counts.get(start));
100
- return attachRollingMean(series);
155
+ return attachRollingMean(counts, window);
101
156
  }
102
157
 
103
- // A four-week trailing mean over completions. Weekly throughput is noisy
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
- // The first three weeks stay null because a four-week mean needs four weeks,
106
- // and averaging over fewer would flatter the start of the window.
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(series) {
110
- return series.map((week, index) => {
111
- if (index < ROLLING_WEEKS - 1) {
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 window = series.slice(index - ROLLING_WEEKS + 1, index + 1);
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
- export function buildCumulativeFlow(allItems, asOf) {
126
- const start = shiftDays(weekStart(asOf), -(WINDOW_WEEKS - 1) * 7);
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 <= asOf; date = shiftDays(date, 1)) {
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 throughput, per Vacanti: resample the weeks
209
- // the ledger actually recorded rather than extrapolating an average, and report
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.completions);
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 weeks = buildWeeklyFlow([...openItems, ...terminalItems], asOf);
288
- const completionTotal = weeks.reduce((total, week) => total + week.completions, 0);
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: completionTotal,
296
- windowWeeks: WINDOW_WEEKS,
297
- perWeek: Math.round((completionTotal / WINDOW_WEEKS) * 100) / 100,
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
+ }
@@ -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
- .graph-filter{margin-bottom:13px}
178
- .graph-filter .facet-group{padding:0}
179
- .graph-filter-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:10px}
180
- .graph-filter-buttons{display:flex;gap:8px}
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
- function renderRoster(nodes) {
219
- 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-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></li>`).join('')}</ol></details>`;
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
- ${renderStatusFilter(model.nodes)}
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(model.nodes, view)}
259
+ ${renderGraphEmptyState(nodes, view)}
258
260
  ${renderLegend()}
259
- ${renderRoster(model.nodes)}
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 graphStatusInputs=Array.prototype.slice.call(document.querySelectorAll('.graph-status'));
273
- var graphRosterRows=Array.prototype.slice.call(document.querySelectorAll('[data-node-status]'));
274
- // The stage hands back the one function that redraws it. Without WebGL there is
275
- // no stage, and the filter still runs: it is the roster's filter too.
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
- return !!(window.WebGLRenderingContext&&(probe.getContext('webgl2')||probe.getContext('webgl')));
282
- }catch(error){return false;}
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
- function graphSelectedStatuses(){
315
- var chosen=Object.create(null);
316
- graphStatusInputs.forEach(function(input){
317
- if(input.checked)chosen[input.value]=true;
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
- GRAPH_MODEL.nodes.forEach(function(node){
328
- if(chosen[node.status]!==true)return;
329
- visible[node.id]=true;
330
- shown+=1;
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
- row.hidden=chosen[row.getAttribute('data-node-status')]!==true;
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.hidden=shown!==0;
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
- // The two moves a chip strip cannot make on its own. Clearing every status is a
341
- // legitimate request, and its honest answer is an empty graph that says so.
342
- function graphSetEveryStatus(checked){
343
- graphStatusInputs.forEach(function(input){input.checked=checked;});
344
- graphApplyFilter();
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
- if(!window.ForceGraph3D||!graphWebglAvailable()){
347
- graphStage.hidden=true;
348
- graphNotice.hidden=false;
349
- graphFallback.open=true;
350
- }else{
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
  }