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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- 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
|
+
}));
|