storymapper 0.1.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.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,974 @@
1
+ /**
2
+ * Dependencies-View (SM-50).
3
+ *
4
+ * Third sight (after Story-Map and Kanban): a layered DAG of all tickets and
5
+ * the typed links between them (see SM-44/45/46 for the underlying link
6
+ * model + types). Read-only — links are still authored from the ticket
7
+ * detail modal. Designed so a later highlight overlay (SM-64: critical-path,
8
+ * impact-set, traceability) can wrap the same layout output.
9
+ *
10
+ * Layout algorithm: longest-path layering (Sugiyama-style).
11
+ * layer(node) = max(layer(predecessor) + 1, 0)
12
+ * x(node) = CANVAS_PADDING_PX + layer * HORIZONTAL_SPACING_PX
13
+ * y(node) = CANVAS_PADDING_PX + indexInLayer * VERTICAL_SPACING_PX
14
+ *
15
+ * Within each layer, nodes are sorted alphabetically by `ticketKey` for
16
+ * stable, predictable output. Cycles (which `wouldCreateCycle` mostly
17
+ * prevents) are handled defensively: the late-comer node gets placed one
18
+ * layer past the current max so we never throw.
19
+ *
20
+ * Filtering happens BEFORE layering: filtered-out nodes drop, and edges
21
+ * that reference a dropped node also drop.
22
+ *
23
+ * Mount API:
24
+ * mount(host, store, opts) → { unmount }
25
+ * opts.onTicketClick(ticketId) — click on a node opens detail modal.
26
+ *
27
+ * Hover-highlighting (AC #3): hovering a node tags itself + direct
28
+ * predecessors + successors + the connecting edges with semantic classes
29
+ * (.deps-node-hovered/-pred/-succ, .deps-edge-active). Pure CSS handles
30
+ * the visual treatment.
31
+ */
32
+ (function (root, factory) {
33
+ if (typeof module === "object" && module.exports) {
34
+ module.exports = factory(require("./core.js"), require("./core/graph.js"), require("./renderer-card.js"), require("./filter.js"));
35
+ } else {
36
+ (root.STORYMAP = root.STORYMAP || {}).rendererDependencies = factory(
37
+ root.STORYMAP && root.STORYMAP.core,
38
+ root.STORYMAP && root.STORYMAP.coreGraph,
39
+ root.STORYMAP && root.STORYMAP.rendererCard,
40
+ root.STORYMAP && root.STORYMAP.filter
41
+ );
42
+ }
43
+ }(typeof self !== "undefined" ? self : this, function (core, coreGraph, rendererCard, filterMod) {
44
+ "use strict";
45
+
46
+ if (!core) throw new Error("renderer-dependencies: core module missing");
47
+ if (!coreGraph) throw new Error("renderer-dependencies: core/graph module missing");
48
+ if (!rendererCard) throw new Error("renderer-dependencies: renderer-card module missing");
49
+ // filterMod is optional — older callers / tests may mount without the board filter.
50
+
51
+ // ---- Constants -----------------------------------------------------
52
+ //
53
+ // Single source of truth for all geometry. Tune here, not at call-sites.
54
+ // HORIZONTAL_SPACING_PX is the inter-layer pitch (column-to-column).
55
+ // VERTICAL_SPACING_PX is the in-layer pitch (top-to-bottom of two nodes
56
+ // in the same layer). Both include the node itself + gap.
57
+ const DEPENDENCY_LAYOUT = Object.freeze({
58
+ // SM-161: nodes are the SHARED story-cards — the width comes from the SINGLE
59
+ // source rendererCard.CARD_WIDTH_PX (no more 240/280 drift between views).
60
+ // SM-162: tightened the pitch. Node-height is nominal (cards are variable
61
+ // height — edges connect at the nominal center).
62
+ HORIZONTAL_SPACING_PX: rendererCard.CARD_WIDTH_PX + 28, // card + 28px gap
63
+ VERTICAL_SPACING_PX: 104, // nominal card height + a small gap
64
+ NODE_WIDTH_PX: rendererCard.CARD_WIDTH_PX, // single source (no drift)
65
+ NODE_HEIGHT_PX: 88, // nominal card height (layout + edge anchors)
66
+ CANVAS_PADDING_PX: 24,
67
+ EDGE_STROKE_WIDTH_PX: 1.5,
68
+ NODE_BORDER_RADIUS_PX: 6,
69
+ // SM-50 followup — barycenter + center axis + isolated band:
70
+ BARYCENTER_PASSES: 4, // up/down sweeps for crossing reduction
71
+ ISOLATED_GAP_PX: 100, // vertical gap between DAG and isolated band
72
+ ISOLATED_PER_ROW: 6, // grid columns in isolated band
73
+ ISOLATED_COL_GAP_PX: 12,
74
+ ISOLATED_ROW_GAP_PX: 12,
75
+ SEPARATOR_LABEL_OFFSET: 18 // y-offset of "Unrelated" label above isolated band
76
+ });
77
+
78
+ // SVG namespace used for canvas + nodes + edges.
79
+ const SVG_NS = "http://www.w3.org/2000/svg";
80
+
81
+ // ---- DOM helpers ---------------------------------------------------
82
+
83
+ function el(tag, attrs, ...children) {
84
+ const node = document.createElement(tag);
85
+ applyAttrs(node, attrs);
86
+ appendChildren(node, children);
87
+ return node;
88
+ }
89
+ function svg(tag, attrs, ...children) {
90
+ const node = document.createElementNS(SVG_NS, tag);
91
+ applyAttrs(node, attrs);
92
+ appendChildren(node, children);
93
+ return node;
94
+ }
95
+ function applyAttrs(node, attrs) {
96
+ if (!attrs) return;
97
+ for (const k of Object.keys(attrs)) {
98
+ if (k === "class") node.setAttribute("class", attrs[k]);
99
+ else if (k === "dataset" && attrs.dataset && typeof attrs.dataset === "object") {
100
+ for (const dk of Object.keys(attrs.dataset)) node.dataset[dk] = attrs.dataset[dk];
101
+ }
102
+ else if (k === "style" && typeof attrs.style === "object") Object.assign(node.style, attrs.style);
103
+ else if (k === "text") node.textContent = attrs[k];
104
+ else if (k.startsWith("on") && typeof attrs[k] === "function") {
105
+ node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
106
+ }
107
+ else node.setAttribute(k, attrs[k]);
108
+ }
109
+ }
110
+ function appendChildren(node, children) {
111
+ for (const c of children) {
112
+ if (c == null) continue;
113
+ if (typeof c === "string") node.appendChild(document.createTextNode(c));
114
+ else node.appendChild(c);
115
+ }
116
+ }
117
+
118
+ // ---- Pure layout ---------------------------------------------------
119
+
120
+ /**
121
+ * Resolve the status-category for a ticket. Mirrors getWorkflowForType to
122
+ * find the canonical status entry; falls back to "doing" if unknown.
123
+ * The category is what colours the node (todo / doing / blocked / done).
124
+ */
125
+ function statusCategoryFor(project, ticket) {
126
+ const wf = core.getWorkflowForType
127
+ ? core.getWorkflowForType(project || {}, ticket && ticket.type)
128
+ : null;
129
+ const statuses = (wf && wf.statuses) || [];
130
+ const entry = statuses.find(s => s.id === (ticket && ticket.status));
131
+ return entry ? entry.category : "doing";
132
+ }
133
+
134
+ /**
135
+ * Resolve link semantic via project.linkTypes (SM-45). Falls back to
136
+ * "freeform" — the most-conservative semantic — when the lookup fails.
137
+ */
138
+ function semanticFor(project, linkTypeId) {
139
+ if (project && Array.isArray(project.linkTypes)) {
140
+ const lt = project.linkTypes.find(t => t.id === linkTypeId);
141
+ if (lt) return lt.semantic || "freeform";
142
+ }
143
+ return "freeform";
144
+ }
145
+
146
+ function colorFor(project, linkTypeId) {
147
+ if (project && Array.isArray(project.linkTypes)) {
148
+ const lt = project.linkTypes.find(t => t.id === linkTypeId);
149
+ if (lt && typeof lt.color === "string" && lt.color.length > 0) return lt.color;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Apply filter to the ticket set. Returns a Set<ticketId> of survivors.
156
+ * Filter fields:
157
+ * type: keep only this ticket type
158
+ * releaseId: keep only tickets whose position.releaseId matches
159
+ * search: case-insensitive substring match against ticketKey OR title
160
+ */
161
+ function applyFilter(tickets, filter) {
162
+ if (!filter) return new Set(tickets.map(t => t.id));
163
+ const out = new Set();
164
+ const search = (filter.search || "").toLowerCase().trim();
165
+ for (const t of tickets) {
166
+ if (filter.type && t.type !== filter.type) continue;
167
+ if (filter.releaseId) {
168
+ const rid = t.position && t.position.releaseId;
169
+ if (rid !== filter.releaseId) continue;
170
+ }
171
+ if (search.length > 0) {
172
+ const key = (t.ticketKey || "").toLowerCase();
173
+ const title = (t.title || "").toLowerCase();
174
+ if (key.indexOf(search) < 0 && title.indexOf(search) < 0) continue;
175
+ }
176
+ out.add(t.id);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Pure layout. Returns {nodes, edges} ready for rendering.
183
+ *
184
+ * nodes: [{id, ticketKey, title, status, statusCategory, x, y, w, h, type}]
185
+ * edges: [{sourceId, targetId, linkTypeId, semantic, color?, label?}]
186
+ *
187
+ * Layering: longest-path. Iterative single-pass over a topological-ish
188
+ * traversal — for every node we set layer = max(layer(predecessor)+1, 0).
189
+ * We loop until no node's layer changes (bounded by nodes.length to
190
+ * defend against accidental cycles).
191
+ */
192
+ /**
193
+ * Compare two ticketKeys with natural-number-aware ordering: "SM-2" comes
194
+ * before "SM-10", and pure-string keys fall back to lexicographic. Used as
195
+ * the deterministic baseline before the barycenter passes reshuffle.
196
+ */
197
+ function compareKey(a, b) {
198
+ const ka = a.ticketKey || a.id;
199
+ const kb = b.ticketKey || b.id;
200
+ const ma = /^([A-Za-z\-_]+)?-?(\d+)$/.exec(ka);
201
+ const mb = /^([A-Za-z\-_]+)?-?(\d+)$/.exec(kb);
202
+ if (ma && mb && (ma[1] || "") === (mb[1] || "")) {
203
+ return Number(ma[2]) - Number(mb[2]);
204
+ }
205
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
206
+ }
207
+
208
+ function computeDependencyLayout(snapshot, opts) {
209
+ opts = opts || {};
210
+ const filter = opts.filter || null;
211
+ const includeIds = opts.includeIds || null; // Set<string> | null
212
+ const project = (snapshot && snapshot.project) || {};
213
+
214
+ // (1) collect live, filter-passing tickets.
215
+ // If `includeIds` is set, intersect with that allow-list — used by the
216
+ // "Show only critical" toggle to restrict the visible graph to the
217
+ // critical-path nodes BEFORE layering (the layout sees only the path
218
+ // so layers/coordinates collapse correctly).
219
+ const liveTickets = (snapshot && snapshot.tickets || []).filter(t => !t.isDeleted);
220
+ const survivors = applyFilter(liveTickets, filter);
221
+ // SM-163: also honour the BOARD filter (statuses/types/hide-completed-
222
+ // releases — the toolbar "Filter N" popover) so e.g. "hide done" applies in
223
+ // the dependency view too, not just Map/Kanban. Same module + semantics.
224
+ if (filterMod && opts.boardFilter) {
225
+ const ctx = (typeof filterMod.buildReleaseCtx === "function")
226
+ ? filterMod.buildReleaseCtx(snapshot) : null;
227
+ for (const t of liveTickets) {
228
+ if (survivors.has(t.id) && !filterMod.matchesFilter(t, opts.boardFilter, ctx)) {
229
+ survivors.delete(t.id);
230
+ }
231
+ }
232
+ }
233
+ if (includeIds && typeof includeIds.has === "function") {
234
+ for (const id of Array.from(survivors)) {
235
+ if (!includeIds.has(id)) survivors.delete(id);
236
+ }
237
+ }
238
+ const tickets = liveTickets.filter(t => survivors.has(t.id));
239
+
240
+ // (2) collect edges — only where BOTH endpoints survived. Legacy compat:
241
+ // accept either link.linkTypeId (canonical, post-SM-44) or link.type
242
+ // (pre-SM-44 shape that some snapshots still carry).
243
+ const edges = [];
244
+ for (const t of tickets) {
245
+ const links = Array.isArray(t.links) ? t.links : [];
246
+ for (const ln of links) {
247
+ if (!ln.targetTicketId) continue;
248
+ if (!survivors.has(ln.targetTicketId)) continue;
249
+ const linkTypeId = ln.linkTypeId || ln.type || "relates-to";
250
+ edges.push({
251
+ sourceId: t.id,
252
+ targetId: ln.targetTicketId,
253
+ linkTypeId: linkTypeId,
254
+ semantic: semanticFor(project, linkTypeId),
255
+ color: colorFor(project, linkTypeId) || undefined,
256
+ label: ln.label || undefined
257
+ });
258
+ }
259
+ }
260
+
261
+ // (3) Partition into connected vs isolated. A ticket is "connected" if
262
+ // it touches at least one surviving edge as source OR target. The
263
+ // layered DAG only contains connected nodes; isolated ones go into
264
+ // a separate band below the main graph (SM-50 follow-up).
265
+ const connectedIds = new Set();
266
+ for (const e of edges) { connectedIds.add(e.sourceId); connectedIds.add(e.targetId); }
267
+ const connectedTickets = tickets.filter(t => connectedIds.has(t.id));
268
+ const isolatedTickets = tickets.filter(t => !connectedIds.has(t.id));
269
+
270
+ // (4) longest-path layering on connected tickets only.
271
+ const predecessors = new Map();
272
+ const successors = new Map();
273
+ for (const t of connectedTickets) { predecessors.set(t.id, []); successors.set(t.id, []); }
274
+ for (const e of edges) {
275
+ if (predecessors.has(e.targetId)) predecessors.get(e.targetId).push(e.sourceId);
276
+ if (successors.has(e.sourceId)) successors.get(e.sourceId).push(e.targetId);
277
+ }
278
+ const layer = new Map();
279
+ for (const t of connectedTickets) layer.set(t.id, 0);
280
+ // Iterate until stable. Bounded by node count as cycle-safety net even
281
+ // though wouldCreateCycle prevents cycles at write time.
282
+ const MAX_ITER = connectedTickets.length + 1;
283
+ let changed = true;
284
+ let iter = 0;
285
+ while (changed && iter < MAX_ITER) {
286
+ changed = false;
287
+ for (const t of connectedTickets) {
288
+ const preds = predecessors.get(t.id) || [];
289
+ let want = 0;
290
+ for (const pid of preds) {
291
+ const pl = layer.get(pid);
292
+ if (typeof pl === "number" && pl + 1 > want) want = pl + 1;
293
+ }
294
+ if (want !== layer.get(t.id)) {
295
+ layer.set(t.id, want);
296
+ changed = true;
297
+ }
298
+ }
299
+ iter++;
300
+ }
301
+
302
+ // (5) Bucket per layer + initial deterministic order (by ticketKey).
303
+ const byLayer = new Map();
304
+ for (const t of connectedTickets) {
305
+ const l = layer.get(t.id) || 0;
306
+ if (!byLayer.has(l)) byLayer.set(l, []);
307
+ byLayer.get(l).push(t);
308
+ }
309
+ for (const arr of byLayer.values()) arr.sort(compareKey);
310
+ const sortedLayers = Array.from(byLayer.keys()).sort((a, b) => a - b);
311
+
312
+ // (6) Barycenter crossing reduction. Alternating L→R and R→L sweeps:
313
+ // within each layer, sort nodes by the mean index of their neighbours
314
+ // in the reference layer. Pulls connected pairs into vertical
315
+ // proximity and slashes edge crossings dramatically vs. fixed-alpha
316
+ // ordering.
317
+ function barycenterSort(layerArr, refLayerArr, getNeighbours) {
318
+ if (!refLayerArr || refLayerArr.length === 0) return layerArr;
319
+ const refIdx = new Map();
320
+ for (let i = 0; i < refLayerArr.length; i++) refIdx.set(refLayerArr[i].id, i);
321
+ const annotated = layerArr.map((t, currIdx) => {
322
+ const ns = getNeighbours(t.id) || [];
323
+ const positions = [];
324
+ for (const nid of ns) if (refIdx.has(nid)) positions.push(refIdx.get(nid));
325
+ const bc = positions.length === 0
326
+ ? currIdx
327
+ : positions.reduce((a, b) => a + b, 0) / positions.length;
328
+ return { t: t, bc: bc, currIdx: currIdx };
329
+ });
330
+ annotated.sort((a, b) => a.bc !== b.bc ? a.bc - b.bc : a.currIdx - b.currIdx);
331
+ return annotated.map(x => x.t);
332
+ }
333
+ for (let pass = 0; pass < DEPENDENCY_LAYOUT.BARYCENTER_PASSES; pass++) {
334
+ // Forward — re-order each layer L>0 by predecessors in layer L-1.
335
+ for (let i = 1; i < sortedLayers.length; i++) {
336
+ const L = sortedLayers[i], P = sortedLayers[i - 1];
337
+ const newOrder = barycenterSort(
338
+ byLayer.get(L),
339
+ byLayer.get(P),
340
+ (tid) => predecessors.get(tid) || []
341
+ );
342
+ byLayer.set(L, newOrder);
343
+ }
344
+ // Backward — re-order each layer L<last by successors in layer L+1.
345
+ for (let i = sortedLayers.length - 2; i >= 0; i--) {
346
+ const L = sortedLayers[i], N = sortedLayers[i + 1];
347
+ const newOrder = barycenterSort(
348
+ byLayer.get(L),
349
+ byLayer.get(N),
350
+ (tid) => successors.get(tid) || []
351
+ );
352
+ byLayer.set(L, newOrder);
353
+ }
354
+ }
355
+
356
+ // (7) Coordinate assignment. SM-162: the CRITICAL PATH forms the central
357
+ // horizontal axis. In every layer the (first) critical-path node is
358
+ // pinned to a common axis-Y; the layer's other nodes stack above/below
359
+ // it in barycenter order. Layers without a critical node are centered on
360
+ // the same axis. Without a critical path we fall back to plain per-layer
361
+ // vertical centering around the tallest-layer mid-line.
362
+ const pad = DEPENDENCY_LAYOUT.CANVAS_PADDING_PX;
363
+ const hSp = DEPENDENCY_LAYOUT.HORIZONTAL_SPACING_PX;
364
+ const vSp = DEPENDENCY_LAYOUT.VERTICAL_SPACING_PX;
365
+ const w = DEPENDENCY_LAYOUT.NODE_WIDTH_PX;
366
+ const h = DEPENDENCY_LAYOUT.NODE_HEIGHT_PX;
367
+ const maxLayerCount = sortedLayers.length === 0
368
+ ? 0
369
+ : sortedLayers.reduce((m, l) => Math.max(m, byLayer.get(l).length), 0);
370
+ const layerHeightMax = maxLayerCount === 0 ? 0 : (maxLayerCount - 1) * vSp + h;
371
+
372
+ // Critical-path node set (union of every longest path), computed on the full
373
+ // graph; only the visible ones get pinned to the axis.
374
+ const criticalAxis = new Set();
375
+ if (coreGraph && typeof coreGraph.criticalPath === "function") {
376
+ const cp = coreGraph.criticalPath(snapshot) || {};
377
+ const paths = (cp.paths && cp.paths.length) ? cp.paths
378
+ : (cp.path && cp.path.length ? [cp.path] : []);
379
+ for (const p of paths) for (const id of p) criticalAxis.add(id);
380
+ }
381
+ const hasAxis = criticalAxis.size > 0;
382
+ const axisTopY = layerHeightMax > 0 ? (layerHeightMax - h) / 2 : 0;
383
+
384
+ const nodes = [];
385
+ for (const l of sortedLayers) {
386
+ const bucket = byLayer.get(l);
387
+ if (bucket.length === 0) continue;
388
+ let yTop; // y of bucket[0]
389
+ if (hasAxis) {
390
+ let cIdx = -1;
391
+ for (let i = 0; i < bucket.length; i++) {
392
+ if (criticalAxis.has(bucket[i].id)) { cIdx = i; break; }
393
+ }
394
+ if (cIdx < 0) cIdx = (bucket.length - 1) / 2; // center this layer on the axis
395
+ yTop = axisTopY - cIdx * vSp;
396
+ } else {
397
+ const layerH = (bucket.length - 1) * vSp + h;
398
+ yTop = pad + Math.max(0, (layerHeightMax - layerH) / 2);
399
+ }
400
+ for (let i = 0; i < bucket.length; i++) {
401
+ const t = bucket[i];
402
+ nodes.push({
403
+ id: t.id,
404
+ ticketKey: t.ticketKey,
405
+ title: t.title,
406
+ status: t.status,
407
+ statusCategory: statusCategoryFor(project, t),
408
+ type: t.type,
409
+ x: pad + l * hSp,
410
+ y: yTop + i * vSp,
411
+ w: w,
412
+ h: h,
413
+ band: "connected"
414
+ });
415
+ }
416
+ }
417
+ // Axis-pinning can push nodes above the top — shift the whole connected band
418
+ // down so the top-most node sits at `pad`.
419
+ let minY = Infinity;
420
+ for (const n of nodes) if (n.y < minY) minY = n.y;
421
+ if (minY !== Infinity && minY !== pad) {
422
+ const shift = pad - minY;
423
+ for (const n of nodes) n.y += shift;
424
+ }
425
+ let connectedBottomY = pad; // tracks where the connected band ends
426
+ for (const n of nodes) if (n.y + n.h > connectedBottomY) connectedBottomY = n.y + n.h;
427
+
428
+ // (8) Isolated nodes — grid below the connected DAG. Sorted by ticketKey
429
+ // for deterministic placement. The separator y is the mid-point
430
+ // between the connected band bottom and the isolated band top so the
431
+ // renderer can draw a dashed divider line there.
432
+ let isolatedBandTop = connectedBottomY;
433
+ let separatorY = connectedBottomY;
434
+ if (isolatedTickets.length > 0) {
435
+ isolatedTickets.sort(compareKey);
436
+ const isoCols = Math.max(1, DEPENDENCY_LAYOUT.ISOLATED_PER_ROW);
437
+ const isoColGap = DEPENDENCY_LAYOUT.ISOLATED_COL_GAP_PX;
438
+ const isoRowGap = DEPENDENCY_LAYOUT.ISOLATED_ROW_GAP_PX;
439
+ // Add a generous gap (only if there ARE connected nodes; otherwise the
440
+ // isolated band starts at the top).
441
+ const gap = connectedTickets.length === 0
442
+ ? 0
443
+ : DEPENDENCY_LAYOUT.ISOLATED_GAP_PX;
444
+ isolatedBandTop = connectedBottomY + gap;
445
+ separatorY = connectedBottomY + Math.max(0, gap / 2);
446
+ for (let i = 0; i < isolatedTickets.length; i++) {
447
+ const t = isolatedTickets[i];
448
+ const row = Math.floor(i / isoCols);
449
+ const col = i % isoCols;
450
+ nodes.push({
451
+ id: t.id,
452
+ ticketKey: t.ticketKey,
453
+ title: t.title,
454
+ status: t.status,
455
+ statusCategory: statusCategoryFor(project, t),
456
+ type: t.type,
457
+ x: pad + col * (w + isoColGap),
458
+ y: isolatedBandTop + row * (h + isoRowGap),
459
+ w: w,
460
+ h: h,
461
+ band: "isolated"
462
+ });
463
+ }
464
+ }
465
+
466
+ return {
467
+ nodes: nodes,
468
+ edges: edges,
469
+ // Render-side hints (separator divider, band counts).
470
+ bands: {
471
+ connectedCount: connectedTickets.length,
472
+ isolatedCount: isolatedTickets.length,
473
+ separatorY: separatorY,
474
+ isolatedTop: isolatedBandTop
475
+ }
476
+ };
477
+ }
478
+
479
+ // ---- Analysis helpers (SM-64) --------------------------------------
480
+ //
481
+ // These wrap the pure graph helpers in core/graph.js so the renderer
482
+ // can use them without reaching outside of its module boundary.
483
+
484
+ /**
485
+ * Returns a Set of `edgeKey(sourceId, targetId)` strings identifying the
486
+ * consecutive edges of a critical-path ID list. Used by render to tag
487
+ * matching edges with `.deps-edge-critical`.
488
+ */
489
+ function criticalEdgeKeySet(pathIds) {
490
+ const out = new Set();
491
+ if (!Array.isArray(pathIds) || pathIds.length === 0) return out;
492
+ // Accept either a flat ID array (legacy) OR an array of arrays (multiple
493
+ // critical paths). For multi-path input, every consecutive pair across
494
+ // every path contributes an edge key.
495
+ const paths = Array.isArray(pathIds[0]) ? pathIds : [pathIds];
496
+ for (const p of paths) {
497
+ if (!Array.isArray(p)) continue;
498
+ for (let i = 0; i + 1 < p.length; i++) {
499
+ out.add(edgeKey(p[i], p[i + 1]));
500
+ }
501
+ }
502
+ return out;
503
+ }
504
+ // Stable, explicit edge-key. The literal arrow keeps grep-ability and
505
+ // avoids any control-char accidents around bare-string-concat.
506
+ function edgeKey(src, tgt) {
507
+ return String(src) + "->" + String(tgt);
508
+ }
509
+
510
+ /**
511
+ * Build the JSON export payload (SM-64).
512
+ * {
513
+ * criticalPath: [{ticketKey, title, status}, ...],
514
+ * impact: {[anchorKey]: [...impactedKeys]},
515
+ * traceability: {[epicKey]: {stories: [keys], tests: [keys]}}
516
+ * }
517
+ * `impact` is computed for the first ticket on the critical path (a single
518
+ * pragmatic "what's downstream from where the work starts"). `traceability`
519
+ * is computed for every epic in the snapshot.
520
+ */
521
+ // Export-Helpers (buildExportJson / buildExportCsv / triggerDownload)
522
+ // entfernt — wir liefern keinen Analyse-Sub-Report. Full Project-Export
523
+ // läuft über SM-15 (Export/Import von Projekten als JSON) auf Projekt-
524
+ // Ebene.
525
+
526
+ // ---- Render helpers ------------------------------------------------
527
+
528
+ function renderToolbar(state, opts, rerender) {
529
+ const bar = el("div", { class: "deps-toolbar" });
530
+
531
+ // Type filter
532
+ const typeSelect = el("select", { class: "deps-filter-type", title: "Filter by ticket type" });
533
+ typeSelect.appendChild(el("option", { value: "" }, "All types"));
534
+ for (const t of (state.ticketTypes || [])) {
535
+ const opt = el("option", { value: t }, t);
536
+ if (state.filter && state.filter.type === t) opt.setAttribute("selected", "selected");
537
+ typeSelect.appendChild(opt);
538
+ }
539
+ typeSelect.addEventListener("change", () => {
540
+ state.filter = Object.assign({}, state.filter, { type: typeSelect.value || null });
541
+ rerender();
542
+ });
543
+ bar.appendChild(typeSelect);
544
+
545
+ // Release filter
546
+ const releaseSelect = el("select", { class: "deps-filter-release", title: "Filter by release" });
547
+ releaseSelect.appendChild(el("option", { value: "" }, "All releases"));
548
+ for (const r of (state.releases || [])) {
549
+ const opt = el("option", { value: r.id }, r.name || r.id);
550
+ if (state.filter && state.filter.releaseId === r.id) opt.setAttribute("selected", "selected");
551
+ releaseSelect.appendChild(opt);
552
+ }
553
+ releaseSelect.addEventListener("change", () => {
554
+ state.filter = Object.assign({}, state.filter, { releaseId: releaseSelect.value || null });
555
+ rerender();
556
+ });
557
+ bar.appendChild(releaseSelect);
558
+
559
+ // Search
560
+ const searchInput = el("input", {
561
+ class: "deps-filter-search",
562
+ type: "search",
563
+ placeholder: "Search ticket key or title…",
564
+ value: (state.filter && state.filter.search) || ""
565
+ });
566
+ searchInput.addEventListener("input", () => {
567
+ state.filter = Object.assign({}, state.filter, { search: searchInput.value || "" });
568
+ rerender();
569
+ });
570
+ bar.appendChild(searchInput);
571
+
572
+ // ---- Analysis controls (SM-64) --------------------------------
573
+ // Divider separates filter inputs from analysis actions.
574
+ bar.appendChild(el("span", { class: "deps-toolbar-divider", "aria-hidden": "true" }));
575
+
576
+ // (1) Critical-Path toggle button — highlights the path's nodes + edges.
577
+ // Re-Click clears. Persistence is in-state-only (not localStorage).
578
+ const cpBtn = el("button", {
579
+ type: "button",
580
+ class: "deps-toolbar-action deps-action-critical-path"
581
+ + (state.criticalPathHighlight ? " toggled" : ""),
582
+ title: "Highlight the longest dependency chain among open tickets"
583
+ }, "Critical Path");
584
+ cpBtn.addEventListener("click", () => {
585
+ state.criticalPathHighlight = !state.criticalPathHighlight;
586
+ rerender();
587
+ });
588
+ bar.appendChild(cpBtn);
589
+
590
+ // (2) Show-only-critical toggle — shrinks the visible graph to the path.
591
+ // Implies criticalPathHighlight=true so the path stays visually marked
592
+ // when the filter is active; toggling OFF reverts to full graph.
593
+ const onlyBtn = el("button", {
594
+ type: "button",
595
+ class: "deps-toolbar-action deps-action-critical-only"
596
+ + (state.criticalOnly ? " toggled" : ""),
597
+ title: "Hide every ticket that is not on the critical path"
598
+ }, "Show only critical");
599
+ onlyBtn.addEventListener("click", () => {
600
+ state.criticalOnly = !state.criticalOnly;
601
+ if (state.criticalOnly) state.criticalPathHighlight = true;
602
+ rerender();
603
+ });
604
+ bar.appendChild(onlyBtn);
605
+
606
+ // (3) Export buttons were removed — full project export/import will be
607
+ // covered by SM-15 ("Export/Import von Projekten") at the project
608
+ // scope, not as a dependency-analysis sub-report.
609
+
610
+ // (4) Inline status-colour legend. Right-aligned via flex margin-auto.
611
+ const legend = el("div", { class: "deps-legend", title: "What the node border colours mean" });
612
+ function legendItem(cls, label) {
613
+ const wrap = el("span", { class: "deps-legend-item" });
614
+ wrap.appendChild(el("span", { class: "deps-legend-swatch " + cls }));
615
+ wrap.appendChild(document.createTextNode(label));
616
+ return wrap;
617
+ }
618
+ legend.appendChild(legendItem("legend-todo", "Todo"));
619
+ legend.appendChild(legendItem("legend-doing", "Doing"));
620
+ legend.appendChild(legendItem("legend-blocked", "Blocked"));
621
+ legend.appendChild(legendItem("legend-done", "Done"));
622
+ legend.appendChild(legendItem("legend-critical", "Critical path"));
623
+ legend.appendChild(legendItem("legend-context", "Path's epic"));
624
+ bar.appendChild(legend);
625
+
626
+ return bar;
627
+ }
628
+
629
+ /**
630
+ * Build the SVG canvas with edges underneath and nodes on top. Hover-
631
+ * highlighting is wired here: pointerenter/leave on each node tags the
632
+ * neighbours via dataset-driven querySelectors.
633
+ */
634
+ function renderCanvas(layout, opts) {
635
+ opts = opts || {};
636
+ const criticalSet = opts.criticalSet || new Set(); // Set<ticketId>
637
+ const criticalContextSet = opts.criticalContextSet || new Set(); // Set<epicId>
638
+ const criticalEdgeKeys = opts.criticalEdgeKeys || new Set();
639
+ // Compute canvas size — extend a little past the right-most + bottom-
640
+ // most node so labels never clip.
641
+ const pad = DEPENDENCY_LAYOUT.CANVAS_PADDING_PX;
642
+ let maxX = 0, maxY = 0;
643
+ for (const n of layout.nodes) {
644
+ if (n.x + n.w > maxX) maxX = n.x + n.w;
645
+ if (n.y + n.h > maxY) maxY = n.y + n.h;
646
+ }
647
+ const canvasW = Math.max(maxX + pad, 200);
648
+ const canvasH = Math.max(maxY + pad, 200);
649
+
650
+ const root = el("div", { class: "deps-canvas" });
651
+ const svgEl = svg("svg", {
652
+ class: "deps-svg",
653
+ width: String(canvasW),
654
+ height: String(canvasH),
655
+ viewBox: "0 0 " + canvasW + " " + canvasH
656
+ });
657
+
658
+ // Index nodes by id for edge lookups + neighbour computation.
659
+ const byId = new Map(layout.nodes.map(n => [n.id, n]));
660
+
661
+ // Build neighbour adjacency (predecessor/successor sets) so hover can
662
+ // tag them in O(1) per hover instead of walking the edges list each time.
663
+ const succ = new Map(); // sourceId → Set<targetId>
664
+ const pred = new Map(); // targetId → Set<sourceId>
665
+ for (const e of layout.edges) {
666
+ if (!succ.has(e.sourceId)) succ.set(e.sourceId, new Set());
667
+ succ.get(e.sourceId).add(e.targetId);
668
+ if (!pred.has(e.targetId)) pred.set(e.targetId, new Set());
669
+ pred.get(e.targetId).add(e.sourceId);
670
+ }
671
+
672
+ // ---- separator between connected DAG and isolated band ---------
673
+ // Only render when there are isolated nodes AND at least one connected.
674
+ // A dashed horizontal rule + a small label make the band split explicit.
675
+ if (layout.bands && layout.bands.isolatedCount > 0 && layout.bands.connectedCount > 0) {
676
+ const sepY = layout.bands.separatorY;
677
+ svgEl.appendChild(svg("line", {
678
+ class: "deps-band-separator",
679
+ x1: String(DEPENDENCY_LAYOUT.CANVAS_PADDING_PX / 2),
680
+ x2: String(canvasW - DEPENDENCY_LAYOUT.CANVAS_PADDING_PX / 2),
681
+ y1: String(sepY),
682
+ y2: String(sepY),
683
+ stroke: "currentColor",
684
+ "stroke-width": "1",
685
+ "stroke-dasharray": "4 4"
686
+ }));
687
+ const label = svg("text", {
688
+ class: "deps-band-separator-label",
689
+ x: String(DEPENDENCY_LAYOUT.CANVAS_PADDING_PX),
690
+ y: String(layout.bands.isolatedTop - DEPENDENCY_LAYOUT.SEPARATOR_LABEL_OFFSET)
691
+ });
692
+ label.textContent = "Unrelated tickets (" + layout.bands.isolatedCount + ")";
693
+ svgEl.appendChild(label);
694
+ }
695
+
696
+ // SM-161: edges live in the SVG (UNDER), nodes are the shared HTML
697
+ // story-cards in an absolutely-positioned overlay (ABOVE) — both share the
698
+ // canvas coordinate space, so edges still connect at the node anchors.
699
+ const ticketsById = new Map(((opts.snapshot && opts.snapshot.tickets) || []).map(t => [t.id, t]));
700
+ const linkIndex = (rendererCard && typeof rendererCard.computeLinkIndex === "function")
701
+ ? rendererCard.computeLinkIndex(opts.snapshot || {}) : null;
702
+ const lastExecIndex = (rendererCard && typeof rendererCard.computeLastExecutionIndex === "function")
703
+ ? rendererCard.computeLastExecutionIndex(opts.snapshot || {}) : null;
704
+
705
+ const edgesLayer = svg("g", { class: "deps-edges-layer" });
706
+ svgEl.appendChild(edgesLayer);
707
+ const nodesHtml = el("div", {
708
+ class: "deps-nodes-html",
709
+ style: { position: "absolute", top: "0", left: "0", width: canvasW + "px", height: canvasH + "px" }
710
+ });
711
+
712
+ // ---- edges ------------------------------------------------------
713
+ for (const e of layout.edges) {
714
+ const sNode = byId.get(e.sourceId);
715
+ const tNode = byId.get(e.targetId);
716
+ if (!sNode || !tNode) continue;
717
+ const sx = sNode.x + sNode.w;
718
+ const sy = sNode.y + sNode.h / 2;
719
+ const tx = tNode.x;
720
+ const ty = tNode.y + tNode.h / 2;
721
+ // Simple bezier: control points pushed half-way horizontally.
722
+ const dx = Math.max(40, (tx - sx) / 2);
723
+ const path = "M" + sx + "," + sy +
724
+ " C" + (sx + dx) + "," + sy +
725
+ " " + (tx - dx) + "," + ty +
726
+ " " + tx + "," + ty;
727
+ const isCritical = criticalEdgeKeys.has(edgeKey(e.sourceId, e.targetId));
728
+ const edgeEl = svg("path", {
729
+ class: "deps-edge deps-edge-" + (e.semantic || "freeform")
730
+ + (isCritical ? " deps-edge-critical" : ""),
731
+ d: path,
732
+ fill: "none",
733
+ stroke: e.color || "currentColor",
734
+ "stroke-width": String(DEPENDENCY_LAYOUT.EDGE_STROKE_WIDTH_PX),
735
+ "data-source-id": e.sourceId,
736
+ "data-target-id": e.targetId,
737
+ "data-link-type-id": e.linkTypeId || ""
738
+ });
739
+ edgesLayer.appendChild(edgeEl);
740
+ }
741
+
742
+ // ---- nodes: shared story-cards in the HTML overlay -------------
743
+ for (const n of layout.nodes) {
744
+ const isCriticalNode = criticalSet.has(n.id);
745
+ const isCriticalContext = !isCriticalNode && criticalContextSet.has(n.id);
746
+ // The .deps-node wrapper carries all the hover/critical/band class hooks
747
+ // and the absolute position; the shared card provides the visuals + the
748
+ // ticket-key → editor link. The wrapper handles the single-click → modal
749
+ // (the dep-view's existing affordance), so the card itself gets no click
750
+ // handler (the key-link stops propagation so it opens the editor instead).
751
+ const nodeEl = el("div", {
752
+ class: "deps-node deps-node-status-" + n.statusCategory + " deps-node-type-" + (n.type || "unknown") + " deps-node-band-" + (n.band || "connected")
753
+ + (isCriticalNode ? " deps-node-critical" : "")
754
+ + (isCriticalContext ? " deps-node-critical-context" : ""),
755
+ dataset: {
756
+ ticketId: n.id,
757
+ status: n.status,
758
+ statusCategory: n.statusCategory,
759
+ type: n.type || "",
760
+ band: n.band || "connected"
761
+ },
762
+ style: { position: "absolute", left: n.x + "px", top: n.y + "px", width: n.w + "px" }
763
+ });
764
+ const fullTicket = ticketsById.get(n.id) || {
765
+ id: n.id, ticketKey: n.ticketKey, title: n.title, type: n.type, status: n.status
766
+ };
767
+ const card = rendererCard.renderTicketCard(fullTicket, {
768
+ projectId: opts.projectId,
769
+ width: n.w,
770
+ linkInfo: linkIndex && linkIndex.get(n.id) || null,
771
+ lastExecution: lastExecIndex && lastExecIndex.get(n.id) || null
772
+ });
773
+ nodeEl.appendChild(card);
774
+
775
+ if (typeof opts.onTicketClick === "function") {
776
+ nodeEl.addEventListener("click", () => opts.onTicketClick(n.id));
777
+ nodeEl.style.cursor = "pointer";
778
+ }
779
+ nodeEl.addEventListener("pointerenter", () => applyHoverHighlight(root, n.id, pred, succ));
780
+ nodeEl.addEventListener("pointerleave", () => clearHoverHighlight(root));
781
+
782
+ nodesHtml.appendChild(nodeEl);
783
+ }
784
+
785
+ // .deps-canvas is the shared coordinate root. The SVG stays in normal flow
786
+ // so its width/height define the scrollable area; the HTML node overlay is
787
+ // absolutely positioned on top of it at the same origin.
788
+ root.appendChild(svgEl);
789
+ root.appendChild(nodesHtml);
790
+ return root;
791
+ }
792
+
793
+ /**
794
+ * BFS closure over a neighbour map. Returns Set<id> reachable from `startId`
795
+ * via the given map (excluding `startId` itself). Used for transitive
796
+ * predecessor / successor highlighting on hover.
797
+ */
798
+ function transitiveClosure(startId, neighborMap) {
799
+ const visited = new Set();
800
+ const queue = [startId];
801
+ while (queue.length) {
802
+ const cur = queue.shift();
803
+ const ns = neighborMap.get(cur);
804
+ if (!ns) continue;
805
+ for (const nid of ns) {
806
+ if (nid === startId) continue;
807
+ if (visited.has(nid)) continue;
808
+ visited.add(nid);
809
+ queue.push(nid);
810
+ }
811
+ }
812
+ return visited;
813
+ }
814
+
815
+ // SM-161: operates on the `.deps-canvas` root — nodes live in the HTML
816
+ // overlay, edges in the SVG, both children of the root.
817
+ function applyHoverHighlight(root, hoveredId, predMap, succMap) {
818
+ clearHoverHighlight(root);
819
+ // Toggle a class on the canvas so CSS can scope the "dim non-neighbours"
820
+ // rule to "a node is actively hovered" rather than "cursor anywhere".
821
+ root.classList.add("deps-hover-active");
822
+ const hovered = root.querySelector('.deps-node[data-ticket-id="' + cssEscape(hoveredId) + '"]');
823
+ if (hovered) hovered.classList.add("deps-node-hovered");
824
+
825
+ // Transitive closures: the full forward chain (all downstream nodes) and
826
+ // the full backward chain (all upstream nodes). Hovering a "source" node
827
+ // therefore reveals the entire dependency path to its furthest sinks —
828
+ // the user's "kritischer Pfad ab hier" mental model.
829
+ const succClosure = transitiveClosure(hoveredId, succMap);
830
+ const predClosure = transitiveClosure(hoveredId, predMap);
831
+ for (const sid of succClosure) {
832
+ const el = root.querySelector('.deps-node[data-ticket-id="' + cssEscape(sid) + '"]');
833
+ if (el) el.classList.add("deps-node-succ");
834
+ }
835
+ for (const pid of predClosure) {
836
+ const el = root.querySelector('.deps-node[data-ticket-id="' + cssEscape(pid) + '"]');
837
+ if (el) el.classList.add("deps-node-pred");
838
+ }
839
+
840
+ // Edges: active if both endpoints sit in the same direction-closure
841
+ // (i.e. the edge is part of the forward chain OR the backward chain).
842
+ const forwardSet = new Set([hoveredId, ...succClosure]);
843
+ const backwardSet = new Set([hoveredId, ...predClosure]);
844
+ const edges = root.querySelectorAll(".deps-edge");
845
+ for (const e of edges) {
846
+ const sid = e.getAttribute("data-source-id");
847
+ const tid = e.getAttribute("data-target-id");
848
+ const inForward = forwardSet.has(sid) && forwardSet.has(tid);
849
+ const inBackward = backwardSet.has(sid) && backwardSet.has(tid);
850
+ if (inForward || inBackward) e.classList.add("deps-edge-active");
851
+ }
852
+ }
853
+
854
+ function clearHoverHighlight(root) {
855
+ root.classList.remove("deps-hover-active");
856
+ const classes = ["deps-node-hovered", "deps-node-pred", "deps-node-succ"];
857
+ for (const c of classes) {
858
+ const els = root.querySelectorAll("." + c);
859
+ for (const el of els) el.classList.remove(c);
860
+ }
861
+ const activeEdges = root.querySelectorAll(".deps-edge-active");
862
+ for (const el of activeEdges) el.classList.remove("deps-edge-active");
863
+ }
864
+
865
+ function cssEscape(s) {
866
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
867
+ }
868
+
869
+ // ---- Mount API -----------------------------------------------------
870
+
871
+ function mount(host, store, opts) {
872
+ opts = opts || {};
873
+ // Filter state is owned by the view, not by the store — switching back
874
+ // to Map/Kanban and back resets filters (intentional: keep cross-view
875
+ // navigation simple, don't surprise users with stale filters).
876
+ const state = {
877
+ filter: { type: null, releaseId: null, search: "" },
878
+ releases: [],
879
+ ticketTypes: [],
880
+ // SM-64 analysis toggles. Both default-off; toggles persist for the
881
+ // life of the mount only (re-mount = fresh state).
882
+ criticalPathHighlight: false,
883
+ criticalOnly: false,
884
+ snapshot: null // most-recently rendered snapshot; toolbar exports use it
885
+ };
886
+
887
+ // Expose the live snapshot to the toolbar (export buttons need it
888
+ // at click-time, not just render-time).
889
+ const mergedOpts = Object.assign({}, opts, {
890
+ getSnapshot: () => state.snapshot
891
+ });
892
+
893
+ function rerender() {
894
+ const snap = store.get();
895
+ state.snapshot = snap;
896
+ // Refresh filter-source data (releases, ticket types) every render so
897
+ // adding a release elsewhere is visible without a re-mount.
898
+ state.releases = (snap.releases || []).filter(r => !r.isDeleted);
899
+ state.ticketTypes = (snap.project && snap.project.ticketTypes) || [];
900
+
901
+ // Compute critical path(s) once per render if either toggle is on.
902
+ // criticalPath returns { path, paths, length } — `paths` is every
903
+ // longest path (diamond ties enumerate both). Highlight UNIONs them,
904
+ // include-filter UNIONs them too.
905
+ let cpAllIds = []; // union of every node id on any longest path
906
+ let cpAllPaths = []; // array of arrays for edge highlighting
907
+ let cpContextEpics = new Set(); // containing epics of path tickets
908
+ if (state.criticalPathHighlight || state.criticalOnly) {
909
+ const cp = coreGraph.criticalPath(snap) || { path: [], paths: [] };
910
+ cpAllPaths = (cp.paths && cp.paths.length > 0) ? cp.paths : (cp.path && cp.path.length > 0 ? [cp.path] : []);
911
+ const union = new Set();
912
+ for (const p of cpAllPaths) for (const id of p) union.add(id);
913
+ cpAllIds = Array.from(union);
914
+ // SM-64-followup: also mark the containing epics of every path
915
+ // ticket so the user immediately sees the cluster context.
916
+ if (core && core.tickets && typeof core.tickets.epicForStory === "function") {
917
+ for (const id of cpAllIds) {
918
+ const epic = core.tickets.epicForStory(snap, id);
919
+ if (epic && !union.has(epic.id)) cpContextEpics.add(epic.id);
920
+ }
921
+ }
922
+ }
923
+ const layoutOpts = { filter: state.filter, boardFilter: mergedOpts.boardFilter };
924
+ if (state.criticalOnly && cpAllIds.length > 0) {
925
+ // include-filter takes the path tickets PLUS their containing epics.
926
+ const incl = new Set(cpAllIds);
927
+ for (const eid of cpContextEpics) incl.add(eid);
928
+ layoutOpts.includeIds = incl;
929
+ }
930
+ const layout = computeDependencyLayout(snap, layoutOpts);
931
+ const canvasOpts = Object.assign({}, mergedOpts, {
932
+ criticalSet: state.criticalPathHighlight ? new Set(cpAllIds) : new Set(),
933
+ criticalContextSet: state.criticalPathHighlight ? cpContextEpics : new Set(),
934
+ criticalEdgeKeys: state.criticalPathHighlight ? criticalEdgeKeySet(cpAllPaths) : new Set(),
935
+ // SM-161: the canvas renders the shared story-cards as nodes — it needs
936
+ // the full tickets + project id (for the ticket-key → editor links).
937
+ snapshot: snap,
938
+ projectId: snap.project && snap.project.id
939
+ });
940
+
941
+ // Build the full subtree fresh each render. The view is read-only and
942
+ // the node count is modest (one node per ticket), so a full rebuild
943
+ // is simpler than diffing and plenty fast.
944
+ const root = el("div", { class: "deps-root" });
945
+ root.appendChild(renderToolbar(state, mergedOpts, rerender));
946
+ root.appendChild(renderCanvas(layout, canvasOpts));
947
+ host.innerHTML = "";
948
+ host.appendChild(root);
949
+ }
950
+
951
+ rerender();
952
+
953
+ const unsub = store.subscribe(function (_snap, _reason) {
954
+ // No animation gating — the dependency view is read-only and renders
955
+ // are cheap. WS-pushes and local commits both just rerender.
956
+ rerender();
957
+ });
958
+
959
+ return {
960
+ unmount: () => {
961
+ unsub();
962
+ host.innerHTML = "";
963
+ }
964
+ };
965
+ }
966
+
967
+ return {
968
+ DEPENDENCY_LAYOUT: DEPENDENCY_LAYOUT,
969
+ computeDependencyLayout: computeDependencyLayout,
970
+ mount: mount,
971
+ // SM-64 — exported for unit-tests.
972
+ criticalEdgeKeySet: criticalEdgeKeySet
973
+ };
974
+ }));