@nklisch/pi-agile-workflow 0.15.3
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
const EXTERNAL_PREFIX = "external:";
|
|
2
|
+
const KIND_CLASSES = new Set(["epic", "feature", "story", "release", "backlog"]);
|
|
3
|
+
const NODE_WIDTH = 280;
|
|
4
|
+
const MIN_NODE_HEIGHT = 132;
|
|
5
|
+
const NODE_WEB_WIDTH = 168;
|
|
6
|
+
const NODE_WEB_HEIGHT = 76;
|
|
7
|
+
const LAYER_GAP = 110;
|
|
8
|
+
const ROW_GAP = 28;
|
|
9
|
+
const CANVAS_PADDING = 24;
|
|
10
|
+
const LARGE_GRAPH_THRESHOLD = 48;
|
|
11
|
+
const ID_CHARS_PER_LINE = 25;
|
|
12
|
+
const EDGE_CHARS_PER_LINE = 22;
|
|
13
|
+
const NODE_HEIGHT_BUFFER = 10;
|
|
14
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
15
|
+
const NODE_DRAG_HOLD_MS = 160;
|
|
16
|
+
const GROUP_LABEL_HEIGHT = 24;
|
|
17
|
+
const GROUP_LABEL_GAP = 14;
|
|
18
|
+
const WEB_RING_RADIUS = 240;
|
|
19
|
+
const MIN_GRAPH_ZOOM = 0.5;
|
|
20
|
+
const MAX_GRAPH_ZOOM = 2;
|
|
21
|
+
const GRAPH_ZOOM_STEP = 0.25;
|
|
22
|
+
const DEFAULT_GRAPH_ZOOM = 0.75;
|
|
23
|
+
const GRAPH_LAYOUTS = [
|
|
24
|
+
{ id: "flow", label: "Flow" },
|
|
25
|
+
{ id: "stage", label: "Stage" },
|
|
26
|
+
{ id: "kind", label: "Kind" },
|
|
27
|
+
{ id: "impact", label: "Impact" },
|
|
28
|
+
{ id: "web", label: "Web" },
|
|
29
|
+
];
|
|
30
|
+
const GRAPH_TOOLS = [
|
|
31
|
+
{ id: "inspect", label: "Inspect", icon: "inspect", title: "Inspect items; hold and drag nodes to rearrange" },
|
|
32
|
+
{ id: "pan", label: "Hand", icon: "pan", title: "Pan empty canvas; click nodes for details; hold and drag nodes to rearrange" },
|
|
33
|
+
];
|
|
34
|
+
const STAGE_LAYOUT_ORDER = ["drafting", "implementing", "review", "done", "released", "backlog", "unstaged", "external"];
|
|
35
|
+
const KIND_LAYOUT_ORDER = ["epic", "feature", "story", "release", "backlog", "idea", "item", "external"];
|
|
36
|
+
|
|
37
|
+
let focusedNode = null;
|
|
38
|
+
let activeLayoutId = GRAPH_LAYOUTS[0].id;
|
|
39
|
+
let activeGraphTool = GRAPH_TOOLS[0].id;
|
|
40
|
+
let activeGraphZoom = DEFAULT_GRAPH_ZOOM;
|
|
41
|
+
let showTerminalBranches = false;
|
|
42
|
+
let activeEdgeGeometryCleanup = null;
|
|
43
|
+
|
|
44
|
+
function textElement(tag, className, text) {
|
|
45
|
+
const element = document.createElement(tag);
|
|
46
|
+
if (className) {
|
|
47
|
+
element.className = className;
|
|
48
|
+
}
|
|
49
|
+
element.textContent = text;
|
|
50
|
+
return element;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function svgIcon(name) {
|
|
54
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
55
|
+
svg.classList.add("dependency-toolbar__icon");
|
|
56
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
57
|
+
svg.setAttribute("aria-hidden", "true");
|
|
58
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
59
|
+
path.setAttribute("fill", "none");
|
|
60
|
+
path.setAttribute("stroke", "currentColor");
|
|
61
|
+
path.setAttribute("stroke-linecap", "round");
|
|
62
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
63
|
+
path.setAttribute("stroke-width", "1.6");
|
|
64
|
+
if (name === "pan") {
|
|
65
|
+
path.setAttribute("d", "M5.3 7.2V4.4a1 1 0 0 1 2 0v2.4M7.3 6.6V3.5a1 1 0 0 1 2 0v3.3M9.3 6.8V4.7a1 1 0 0 1 2 0v3.5M5.3 7.2 4.2 6.1a1.1 1.1 0 0 0-1.6 1.5l2.8 3.6c.8 1 1.9 1.6 3.2 1.6h1.2c2 0 3.2-1.3 3.2-3.4V7a1 1 0 0 0-2 0");
|
|
66
|
+
} else {
|
|
67
|
+
path.setAttribute("d", "M4.3 2.7 12.2 8l-3.5.8 2 3.9-1.8.9-2-3.9-2.4 2.6-.2-9.6Z");
|
|
68
|
+
}
|
|
69
|
+
svg.append(path);
|
|
70
|
+
return svg;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clampGraphZoom(value) {
|
|
74
|
+
const rounded = Math.round(Number(value) * 100) / 100;
|
|
75
|
+
return Math.min(MAX_GRAPH_ZOOM, Math.max(MIN_GRAPH_ZOOM, rounded));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setGraphZoom(value) {
|
|
79
|
+
activeGraphZoom = clampGraphZoom(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function graphZoomLabel() {
|
|
83
|
+
return `${Math.round(activeGraphZoom * 100)}%`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function updateGraphZoomControls() {
|
|
87
|
+
for (const button of document.querySelectorAll("[data-zoom-action]")) {
|
|
88
|
+
if (button.dataset.zoomAction === "reset") {
|
|
89
|
+
button.textContent = graphZoomLabel();
|
|
90
|
+
}
|
|
91
|
+
if (button.dataset.zoomAction === "out") {
|
|
92
|
+
button.disabled = activeGraphZoom <= MIN_GRAPH_ZOOM;
|
|
93
|
+
}
|
|
94
|
+
if (button.dataset.zoomAction === "in") {
|
|
95
|
+
button.disabled = activeGraphZoom >= MAX_GRAPH_ZOOM;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyGraphZoom(viewport, surface, canvas, layout) {
|
|
101
|
+
viewport.dataset.zoom = String(activeGraphZoom);
|
|
102
|
+
surface.style.width = `${Math.ceil(layout.width * activeGraphZoom)}px`;
|
|
103
|
+
surface.style.height = `${Math.ceil(layout.height * activeGraphZoom)}px`;
|
|
104
|
+
canvas.style.transform = `scale(${activeGraphZoom})`;
|
|
105
|
+
updateGraphZoomControls();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function arrayOfStrings(value) {
|
|
109
|
+
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dependencyIds(item) {
|
|
113
|
+
return arrayOfStrings(item?.depends_on);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function unmetIds(item) {
|
|
117
|
+
return new Set(arrayOfStrings(item?.unmet_deps));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function externalId(id) {
|
|
121
|
+
return `${EXTERNAL_PREFIX}${id}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function itemLabel(node) {
|
|
125
|
+
if (!node) {
|
|
126
|
+
return "(missing)";
|
|
127
|
+
}
|
|
128
|
+
return node.external ? node.id.slice(EXTERNAL_PREFIX.length) : node.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function kindClass(kind) {
|
|
132
|
+
return KIND_CLASSES.has(kind) ? kind : "backlog";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isTerminalItem(item) {
|
|
136
|
+
return Boolean(item?.is_terminal || item?.stage === "done" || item?.stage === "released");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildDependencyModel(items) {
|
|
140
|
+
const nodes = new Map();
|
|
141
|
+
const edges = [];
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
if (item?.id) {
|
|
144
|
+
nodes.set(String(item.id), { id: String(item.id), item, external: false });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
if (!item?.id) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const to = String(item.id);
|
|
152
|
+
const unmet = unmetIds(item);
|
|
153
|
+
for (const dep of dependencyIds(item)) {
|
|
154
|
+
const visibleDep = nodes.has(dep);
|
|
155
|
+
const from = visibleDep ? dep : externalId(dep);
|
|
156
|
+
if (!visibleDep && !nodes.has(from)) {
|
|
157
|
+
nodes.set(from, { id: from, item: null, external: true });
|
|
158
|
+
}
|
|
159
|
+
edges.push({
|
|
160
|
+
from,
|
|
161
|
+
to,
|
|
162
|
+
external: !visibleDep,
|
|
163
|
+
unmet: unmet.has(dep),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return layerGraph({ nodes, edges });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function layerGraph(model) {
|
|
171
|
+
const indegree = new Map();
|
|
172
|
+
const outgoing = new Map();
|
|
173
|
+
const depth = new Map();
|
|
174
|
+
for (const id of model.nodes.keys()) {
|
|
175
|
+
indegree.set(id, 0);
|
|
176
|
+
outgoing.set(id, []);
|
|
177
|
+
depth.set(id, 0);
|
|
178
|
+
}
|
|
179
|
+
for (const edge of model.edges) {
|
|
180
|
+
indegree.set(edge.to, (indegree.get(edge.to) || 0) + 1);
|
|
181
|
+
outgoing.get(edge.from)?.push(edge);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const queue = Array.from(indegree.entries())
|
|
185
|
+
.filter(([, count]) => count === 0)
|
|
186
|
+
.map(([id]) => id)
|
|
187
|
+
.sort((a, b) => itemLabel(model.nodes.get(a)).localeCompare(itemLabel(model.nodes.get(b))));
|
|
188
|
+
const processed = new Set();
|
|
189
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
190
|
+
const id = queue[index];
|
|
191
|
+
processed.add(id);
|
|
192
|
+
for (const edge of outgoing.get(id) || []) {
|
|
193
|
+
depth.set(edge.to, Math.max(depth.get(edge.to) || 0, (depth.get(id) || 0) + 1));
|
|
194
|
+
const nextCount = (indegree.get(edge.to) || 0) - 1;
|
|
195
|
+
indegree.set(edge.to, nextCount);
|
|
196
|
+
if (nextCount === 0) {
|
|
197
|
+
queue.push(edge.to);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const cycleIds = Array.from(model.nodes.keys()).filter((id) => !processed.has(id));
|
|
203
|
+
if (cycleIds.length > 0) {
|
|
204
|
+
const fallbackDepth = Math.max(0, ...Array.from(depth.values())) + 1;
|
|
205
|
+
for (const id of cycleIds) {
|
|
206
|
+
depth.set(id, fallbackDepth);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const layers = new Map();
|
|
211
|
+
for (const [id, node] of model.nodes) {
|
|
212
|
+
const layer = depth.get(id) || 0;
|
|
213
|
+
if (!layers.has(layer)) {
|
|
214
|
+
layers.set(layer, []);
|
|
215
|
+
}
|
|
216
|
+
layers.get(layer).push(node);
|
|
217
|
+
}
|
|
218
|
+
for (const nodes of layers.values()) {
|
|
219
|
+
nodes.sort((a, b) => itemLabel(a).localeCompare(itemLabel(b)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...model,
|
|
224
|
+
depth,
|
|
225
|
+
layers: Array.from(layers.entries()).sort(([a], [b]) => a - b),
|
|
226
|
+
cycleIds,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function edgesFor(model, nodeId, direction) {
|
|
231
|
+
return model.edges.filter((edge) => direction === "in" ? edge.to === nodeId : edge.from === nodeId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderEdgeLine(label, ids, className) {
|
|
235
|
+
const line = document.createElement("div");
|
|
236
|
+
line.className = "edge";
|
|
237
|
+
line.append(textElement("span", className, label), textElement("span", "", ids.length === 0 ? "none" : ids.join(", ")));
|
|
238
|
+
return line;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderNode(node, model, ctx) {
|
|
242
|
+
const nodeElement = node.external ? document.createElement("div") : document.createElement("button");
|
|
243
|
+
nodeElement.className = "dep-node";
|
|
244
|
+
if (node.external) {
|
|
245
|
+
nodeElement.classList.add("dep-node--external");
|
|
246
|
+
}
|
|
247
|
+
if (node.item?.blocked) {
|
|
248
|
+
nodeElement.classList.add("is-blocked");
|
|
249
|
+
}
|
|
250
|
+
if (!node.external) {
|
|
251
|
+
nodeElement.type = "button";
|
|
252
|
+
nodeElement.addEventListener("click", () => {
|
|
253
|
+
ctx.openDetail(node.id);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const head = document.createElement("div");
|
|
258
|
+
head.className = "dn-head";
|
|
259
|
+
const kind = node.external ? "external" : String(node.item?.kind || "item");
|
|
260
|
+
head.append(
|
|
261
|
+
textElement("span", `chip chip--${kindClass(kind)}`, kind),
|
|
262
|
+
textElement("span", "ic-id", itemLabel(node)),
|
|
263
|
+
);
|
|
264
|
+
if (node.item?.blocked) {
|
|
265
|
+
head.append(textElement("span", "badge badge--blocked", "blocked"));
|
|
266
|
+
} else if (node.item?.ready) {
|
|
267
|
+
head.append(textElement("span", "badge badge--ready", "ready"));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const incoming = edgesFor(model, node.id, "in");
|
|
271
|
+
const outgoing = edgesFor(model, node.id, "out");
|
|
272
|
+
const edges = document.createElement("div");
|
|
273
|
+
edges.className = "edges";
|
|
274
|
+
edges.append(
|
|
275
|
+
renderEdgeLine("depends on", incoming.map((edge) => itemLabel(model.nodes.get(edge.from))), "up"),
|
|
276
|
+
renderEdgeLine("unblocks", outgoing.map((edge) => itemLabel(model.nodes.get(edge.to))), "down"),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
nodeElement.append(head, edges);
|
|
280
|
+
return nodeElement;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderWebNode(node, model, ctx) {
|
|
284
|
+
const nodeElement = node.external ? document.createElement("div") : document.createElement("button");
|
|
285
|
+
const kind = node.external ? "external" : String(node.item?.kind || "item");
|
|
286
|
+
nodeElement.className = `dep-node dep-node--web dep-node--${kindClass(kind)}`;
|
|
287
|
+
if (node.external) {
|
|
288
|
+
nodeElement.classList.add("dep-node--external");
|
|
289
|
+
}
|
|
290
|
+
if (node.item?.blocked) {
|
|
291
|
+
nodeElement.classList.add("is-blocked");
|
|
292
|
+
}
|
|
293
|
+
if (!node.external) {
|
|
294
|
+
nodeElement.type = "button";
|
|
295
|
+
nodeElement.addEventListener("click", () => {
|
|
296
|
+
ctx.openDetail(node.id);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const head = document.createElement("div");
|
|
301
|
+
head.className = "dn-head";
|
|
302
|
+
head.append(textElement("span", `chip chip--${kindClass(kind)}`, kind));
|
|
303
|
+
if (node.item?.blocked) {
|
|
304
|
+
head.append(textElement("span", "badge badge--blocked", "blocked"));
|
|
305
|
+
} else if (node.item?.ready) {
|
|
306
|
+
head.append(textElement("span", "badge badge--ready", "ready"));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
nodeElement.append(head, textElement("span", "ic-id", itemLabel(node)));
|
|
310
|
+
if (!node.external && node.item?.stage) {
|
|
311
|
+
nodeElement.append(textElement("span", "dn-web-meta", String(node.item.stage)));
|
|
312
|
+
}
|
|
313
|
+
return nodeElement;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderCycleWarning(model) {
|
|
317
|
+
if (model.cycleIds.length === 0) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const warning = document.createElement("div");
|
|
321
|
+
warning.className = "alert alert--warning";
|
|
322
|
+
warning.append(
|
|
323
|
+
textElement("span", "ico", "?"),
|
|
324
|
+
textElement("span", "body", `Cycle detected: ${model.cycleIds.map((id) => itemLabel(model.nodes.get(id))).join(", ")}`),
|
|
325
|
+
);
|
|
326
|
+
return warning;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function wrappedLineCount(text, charsPerLine) {
|
|
330
|
+
const length = String(text || "").length;
|
|
331
|
+
return Math.max(1, Math.ceil(length / charsPerLine));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function edgeLabels(model, nodeId, direction) {
|
|
335
|
+
return edgesFor(model, nodeId, direction).map((edge) => itemLabel(model.nodes.get(direction === "in" ? edge.from : edge.to)));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function edgeTextLineCount(labels) {
|
|
339
|
+
const text = labels.length === 0 ? "none" : labels.join(", ");
|
|
340
|
+
return wrappedLineCount(text, EDGE_CHARS_PER_LINE);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function estimatedNodeHeight(node, model) {
|
|
344
|
+
const labelLines = wrappedLineCount(itemLabel(node), ID_CHARS_PER_LINE);
|
|
345
|
+
const incomingLines = edgeTextLineCount(edgeLabels(model, node.id, "in"));
|
|
346
|
+
const outgoingLines = edgeTextLineCount(edgeLabels(model, node.id, "out"));
|
|
347
|
+
const headerHeight = Math.max(30, labelLines * 24);
|
|
348
|
+
const edgesHeight = (incomingLines + outgoingLines) * 17;
|
|
349
|
+
return Math.max(MIN_NODE_HEIGHT, 42 + headerHeight + edgesHeight + NODE_HEIGHT_BUFFER);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function titleLabel(value) {
|
|
353
|
+
return String(value || "unknown")
|
|
354
|
+
.replace(/[-_]/g, " ")
|
|
355
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function compareKeysByOrder(a, b, order) {
|
|
359
|
+
const aIndex = order.indexOf(a);
|
|
360
|
+
const bIndex = order.indexOf(b);
|
|
361
|
+
if (aIndex !== -1 || bIndex !== -1) {
|
|
362
|
+
if (aIndex === -1) {
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
if (bIndex === -1) {
|
|
366
|
+
return -1;
|
|
367
|
+
}
|
|
368
|
+
return aIndex - bIndex;
|
|
369
|
+
}
|
|
370
|
+
return a.localeCompare(b);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function sortNodesByLabel(nodes) {
|
|
374
|
+
return [...nodes].sort((a, b) => itemLabel(a).localeCompare(itemLabel(b)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function groupedBy(model, groupForNode, order) {
|
|
378
|
+
const groups = new Map();
|
|
379
|
+
for (const node of model.nodes.values()) {
|
|
380
|
+
const key = groupForNode(node);
|
|
381
|
+
if (!groups.has(key)) {
|
|
382
|
+
groups.set(key, []);
|
|
383
|
+
}
|
|
384
|
+
groups.get(key).push(node);
|
|
385
|
+
}
|
|
386
|
+
return Array.from(groups.entries())
|
|
387
|
+
.sort(([a], [b]) => compareKeysByOrder(a, b, order))
|
|
388
|
+
.map(([key, nodes]) => ({
|
|
389
|
+
id: key,
|
|
390
|
+
label: titleLabel(key),
|
|
391
|
+
nodes: sortNodesByLabel(nodes),
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function groupNodesByFlow(model) {
|
|
396
|
+
return model.layers.map(([layer, nodes]) => ({
|
|
397
|
+
id: `flow-${layer}`,
|
|
398
|
+
label: `Depth ${layer}`,
|
|
399
|
+
nodes,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function groupNodesByStage(model) {
|
|
404
|
+
return groupedBy(model, (node) => {
|
|
405
|
+
if (node.external) {
|
|
406
|
+
return "external";
|
|
407
|
+
}
|
|
408
|
+
return String(node.item?.stage || "unstaged");
|
|
409
|
+
}, STAGE_LAYOUT_ORDER);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function groupNodesByKind(model) {
|
|
413
|
+
return groupedBy(model, (node) => {
|
|
414
|
+
if (node.external) {
|
|
415
|
+
return "external";
|
|
416
|
+
}
|
|
417
|
+
return String(node.item?.kind || "item");
|
|
418
|
+
}, KIND_LAYOUT_ORDER);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function unlockImpactCounts(model) {
|
|
422
|
+
const outgoing = new Map();
|
|
423
|
+
for (const id of model.nodes.keys()) {
|
|
424
|
+
outgoing.set(id, []);
|
|
425
|
+
}
|
|
426
|
+
for (const edge of model.edges) {
|
|
427
|
+
outgoing.get(edge.from)?.push(edge.to);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const counts = new Map();
|
|
431
|
+
for (const id of model.nodes.keys()) {
|
|
432
|
+
const reachable = new Set();
|
|
433
|
+
const stack = [...(outgoing.get(id) || [])];
|
|
434
|
+
while (stack.length > 0) {
|
|
435
|
+
const next = stack.pop();
|
|
436
|
+
if (!next || next === id || reachable.has(next)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
reachable.add(next);
|
|
440
|
+
for (const child of outgoing.get(next) || []) {
|
|
441
|
+
stack.push(child);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
counts.set(id, reachable.size);
|
|
445
|
+
}
|
|
446
|
+
return counts;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function impactGroupLabel(count) {
|
|
450
|
+
const noun = count === 1 ? "Item" : "Items";
|
|
451
|
+
return `Unblocks ${count} Downstream ${noun}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function groupNodesByImpact(model) {
|
|
455
|
+
const counts = unlockImpactCounts(model);
|
|
456
|
+
const groups = new Map();
|
|
457
|
+
for (const node of model.nodes.values()) {
|
|
458
|
+
const count = counts.get(node.id) || 0;
|
|
459
|
+
if (!groups.has(count)) {
|
|
460
|
+
groups.set(count, []);
|
|
461
|
+
}
|
|
462
|
+
groups.get(count).push(node);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return Array.from(groups.entries())
|
|
466
|
+
.sort(([a], [b]) => b - a)
|
|
467
|
+
.map(([count, nodes]) => ({
|
|
468
|
+
id: `impact-${count}`,
|
|
469
|
+
label: impactGroupLabel(count),
|
|
470
|
+
nodes: sortNodesByLabel(nodes),
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function groupsForLayout(model, layoutId) {
|
|
475
|
+
if (layoutId === "stage") {
|
|
476
|
+
return groupNodesByStage(model);
|
|
477
|
+
}
|
|
478
|
+
if (layoutId === "kind") {
|
|
479
|
+
return groupNodesByKind(model);
|
|
480
|
+
}
|
|
481
|
+
if (layoutId === "impact") {
|
|
482
|
+
return groupNodesByImpact(model);
|
|
483
|
+
}
|
|
484
|
+
return groupNodesByFlow(model);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function layoutWebGraph(model) {
|
|
488
|
+
const rings = new Map();
|
|
489
|
+
for (const [id, node] of model.nodes) {
|
|
490
|
+
const depth = model.depth.get(id) || 0;
|
|
491
|
+
if (!rings.has(depth)) {
|
|
492
|
+
rings.set(depth, []);
|
|
493
|
+
}
|
|
494
|
+
rings.get(depth).push(node);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ringLayouts = Array.from(rings.entries())
|
|
498
|
+
.sort(([a], [b]) => a - b)
|
|
499
|
+
.map(([depth, nodes]) => {
|
|
500
|
+
const sortedNodes = sortNodesByLabel(nodes);
|
|
501
|
+
const neededRadius = sortedNodes.length * (NODE_WEB_WIDTH + 42) / (2 * Math.PI);
|
|
502
|
+
return {
|
|
503
|
+
depth,
|
|
504
|
+
nodes: sortedNodes,
|
|
505
|
+
radius: Math.max((depth + 1) * WEB_RING_RADIUS, neededRadius),
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
const maxRadius = Math.max(WEB_RING_RADIUS, ...ringLayouts.map((ring) => ring.radius));
|
|
509
|
+
const center = CANVAS_PADDING + maxRadius + NODE_WEB_WIDTH;
|
|
510
|
+
const positions = new Map();
|
|
511
|
+
|
|
512
|
+
for (const ring of ringLayouts) {
|
|
513
|
+
ring.nodes.forEach((node, index) => {
|
|
514
|
+
const angleOffset = ring.depth % 2 === 0 ? 0 : Math.PI / Math.max(1, ring.nodes.length);
|
|
515
|
+
const angle = ring.nodes.length === 1
|
|
516
|
+
? -Math.PI / 2
|
|
517
|
+
: (index / ring.nodes.length) * 2 * Math.PI - Math.PI / 2 + angleOffset;
|
|
518
|
+
positions.set(node.id, {
|
|
519
|
+
x: Math.round(center + ring.radius * Math.cos(angle) - NODE_WEB_WIDTH / 2),
|
|
520
|
+
y: Math.round(center + ring.radius * Math.sin(angle) - NODE_WEB_HEIGHT / 2),
|
|
521
|
+
width: NODE_WEB_WIDTH,
|
|
522
|
+
height: NODE_WEB_HEIGHT,
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
positions,
|
|
529
|
+
groups: [],
|
|
530
|
+
width: Math.ceil(center * 2),
|
|
531
|
+
height: Math.ceil(center * 2),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function layoutGraph(model, layoutId) {
|
|
536
|
+
if (layoutId === "web") {
|
|
537
|
+
return layoutWebGraph(model);
|
|
538
|
+
}
|
|
539
|
+
const groups = groupsForLayout(model, layoutId);
|
|
540
|
+
const positions = new Map();
|
|
541
|
+
const laidOutGroups = [];
|
|
542
|
+
let maxLayerHeight = MIN_NODE_HEIGHT;
|
|
543
|
+
groups.forEach((group, layerIndex) => {
|
|
544
|
+
const x = CANVAS_PADDING + layerIndex * (NODE_WIDTH + LAYER_GAP);
|
|
545
|
+
let y = CANVAS_PADDING + GROUP_LABEL_HEIGHT + GROUP_LABEL_GAP;
|
|
546
|
+
laidOutGroups.push({ ...group, x });
|
|
547
|
+
for (const node of group.nodes) {
|
|
548
|
+
const height = estimatedNodeHeight(node, model);
|
|
549
|
+
positions.set(node.id, {
|
|
550
|
+
x,
|
|
551
|
+
y,
|
|
552
|
+
width: NODE_WIDTH,
|
|
553
|
+
height,
|
|
554
|
+
});
|
|
555
|
+
y += height + ROW_GAP;
|
|
556
|
+
}
|
|
557
|
+
maxLayerHeight = Math.max(maxLayerHeight, y - ROW_GAP);
|
|
558
|
+
});
|
|
559
|
+
return {
|
|
560
|
+
positions,
|
|
561
|
+
groups: laidOutGroups,
|
|
562
|
+
width: CANVAS_PADDING * 2 + groups.length * NODE_WIDTH + Math.max(0, groups.length - 1) * LAYER_GAP,
|
|
563
|
+
height: maxLayerHeight + CANVAS_PADDING,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function webEdgePath(from, to) {
|
|
568
|
+
const x1 = from.x + from.width / 2;
|
|
569
|
+
const y1 = from.y + from.height / 2;
|
|
570
|
+
const x2 = to.x + to.width / 2;
|
|
571
|
+
const y2 = to.y + to.height / 2;
|
|
572
|
+
const midX = (x1 + x2) / 2;
|
|
573
|
+
const midY = (y1 + y2) / 2;
|
|
574
|
+
const dx = x2 - x1;
|
|
575
|
+
const dy = y2 - y1;
|
|
576
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
577
|
+
const nudge = 10;
|
|
578
|
+
const controlX = midX - dy / length * nudge;
|
|
579
|
+
const controlY = midY + dx / length * nudge;
|
|
580
|
+
return `M ${x1} ${y1} Q ${controlX} ${controlY}, ${x2} ${y2}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function edgePath(from, to) {
|
|
584
|
+
if (activeLayoutId === "web") {
|
|
585
|
+
return webEdgePath(from, to);
|
|
586
|
+
}
|
|
587
|
+
const x1 = from.x + from.width;
|
|
588
|
+
const y1 = from.y + from.height / 2;
|
|
589
|
+
const x2 = to.x;
|
|
590
|
+
const y2 = to.y + to.height / 2;
|
|
591
|
+
const control = Math.max(48, (x2 - x1) / 2);
|
|
592
|
+
return `M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function renderEdges(model) {
|
|
596
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
597
|
+
svg.classList.add("dependency-edges");
|
|
598
|
+
svg.setAttribute("aria-hidden", "true");
|
|
599
|
+
for (const edge of model.edges) {
|
|
600
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
601
|
+
path.classList.add("dependency-edge", edge.unmet ? "dependency-edge--unmet" : "dependency-edge--met");
|
|
602
|
+
path.dataset.from = edge.from;
|
|
603
|
+
path.dataset.to = edge.to;
|
|
604
|
+
svg.append(path);
|
|
605
|
+
}
|
|
606
|
+
return svg;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderLayoutLabels(layout) {
|
|
610
|
+
const fragment = document.createDocumentFragment();
|
|
611
|
+
for (const group of layout.groups) {
|
|
612
|
+
const label = textElement("div", "dependency-layout-label", group.label);
|
|
613
|
+
label.style.left = `${group.x}px`;
|
|
614
|
+
label.style.top = `${CANVAS_PADDING}px`;
|
|
615
|
+
fragment.append(label);
|
|
616
|
+
}
|
|
617
|
+
return fragment;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function measuredNodeBounds(canvas) {
|
|
621
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
622
|
+
const scale = activeGraphZoom || 1;
|
|
623
|
+
const bounds = new Map();
|
|
624
|
+
for (const wrapper of canvas.querySelectorAll(".dependency-graph-node")) {
|
|
625
|
+
const rect = wrapper.getBoundingClientRect();
|
|
626
|
+
bounds.set(wrapper.dataset.nodeId, {
|
|
627
|
+
x: (rect.left - canvasRect.left) / scale,
|
|
628
|
+
y: (rect.top - canvasRect.top) / scale,
|
|
629
|
+
width: rect.width / scale,
|
|
630
|
+
height: rect.height / scale,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return bounds;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function syncEdgeGeometry(canvas, model) {
|
|
637
|
+
const svg = canvas.querySelector(".dependency-edges");
|
|
638
|
+
if (!svg) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const width = Math.max(1, canvas.scrollWidth, canvas.offsetWidth);
|
|
642
|
+
const height = Math.max(1, canvas.scrollHeight, canvas.offsetHeight);
|
|
643
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
644
|
+
svg.style.width = `${width}px`;
|
|
645
|
+
svg.style.height = `${height}px`;
|
|
646
|
+
|
|
647
|
+
const bounds = measuredNodeBounds(canvas);
|
|
648
|
+
for (const path of svg.querySelectorAll(".dependency-edge")) {
|
|
649
|
+
const from = bounds.get(path.dataset.from);
|
|
650
|
+
const to = bounds.get(path.dataset.to);
|
|
651
|
+
if (!from || !to) {
|
|
652
|
+
path.hidden = true;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
path.hidden = false;
|
|
656
|
+
path.setAttribute("d", edgePath(from, to));
|
|
657
|
+
}
|
|
658
|
+
applyTrace(canvas, model, focusedNode);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function positionValue(value) {
|
|
662
|
+
const number = Number.parseFloat(value);
|
|
663
|
+
return Number.isFinite(number) ? number : 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function expandCanvasForNode(canvas, wrapper) {
|
|
667
|
+
const left = positionValue(wrapper.style.left);
|
|
668
|
+
const top = positionValue(wrapper.style.top);
|
|
669
|
+
const width = Math.max(canvas.offsetWidth, canvas.scrollWidth, left + wrapper.offsetWidth + CANVAS_PADDING);
|
|
670
|
+
const height = Math.max(canvas.offsetHeight, canvas.scrollHeight, top + wrapper.offsetHeight + CANVAS_PADDING);
|
|
671
|
+
canvas.style.width = `${width}px`;
|
|
672
|
+
canvas.style.height = `${height}px`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function installNodeDragging(canvas, wrapper, model) {
|
|
676
|
+
let drag = null;
|
|
677
|
+
let suppressClick = false;
|
|
678
|
+
|
|
679
|
+
const clearNodeDragHold = (dragState) => {
|
|
680
|
+
if (dragState?.holdTimer) {
|
|
681
|
+
window.clearTimeout(dragState.holdTimer);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const detachNodeDragListeners = (dragState) => {
|
|
686
|
+
if (!dragState) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
window.removeEventListener("pointermove", dragState.onPointerMove, true);
|
|
690
|
+
window.removeEventListener("pointerup", dragState.onPointerEnd, true);
|
|
691
|
+
window.removeEventListener("pointercancel", dragState.onPointerEnd, true);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const cleanupNodeDrag = (dragState) => {
|
|
695
|
+
clearNodeDragHold(dragState);
|
|
696
|
+
detachNodeDragListeners(dragState);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
wrapper.addEventListener("click", (event) => {
|
|
700
|
+
if (!suppressClick) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
suppressClick = false;
|
|
704
|
+
event.preventDefault();
|
|
705
|
+
event.stopImmediatePropagation();
|
|
706
|
+
}, true);
|
|
707
|
+
|
|
708
|
+
const moveNodeDrag = (event) => {
|
|
709
|
+
if (!drag || event.pointerId !== drag.pointerId) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const rawDeltaX = event.clientX - drag.startX;
|
|
713
|
+
const rawDeltaY = event.clientY - drag.startY;
|
|
714
|
+
if (!drag.moved && Math.hypot(rawDeltaX, rawDeltaY) < DRAG_THRESHOLD_PX) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (!drag.ready) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!drag.captured) {
|
|
721
|
+
try {
|
|
722
|
+
wrapper.setPointerCapture(event.pointerId);
|
|
723
|
+
drag.captured = true;
|
|
724
|
+
} catch {
|
|
725
|
+
drag.captured = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const deltaX = rawDeltaX / activeGraphZoom;
|
|
729
|
+
const deltaY = rawDeltaY / activeGraphZoom;
|
|
730
|
+
drag.moved = true;
|
|
731
|
+
suppressClick = true;
|
|
732
|
+
wrapper.classList.add("is-dragging");
|
|
733
|
+
wrapper.style.left = `${Math.max(CANVAS_PADDING, drag.startLeft + deltaX)}px`;
|
|
734
|
+
wrapper.style.top = `${Math.max(CANVAS_PADDING, drag.startTop + deltaY)}px`;
|
|
735
|
+
expandCanvasForNode(canvas, wrapper);
|
|
736
|
+
syncEdgeGeometry(canvas, model);
|
|
737
|
+
event.preventDefault();
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const endDrag = (event) => {
|
|
741
|
+
if (!drag || event.pointerId !== drag.pointerId) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const didMove = drag.moved;
|
|
745
|
+
cleanupNodeDrag(drag);
|
|
746
|
+
if (wrapper.hasPointerCapture(event.pointerId)) {
|
|
747
|
+
wrapper.releasePointerCapture(event.pointerId);
|
|
748
|
+
}
|
|
749
|
+
wrapper.classList.remove("is-dragging");
|
|
750
|
+
if (didMove) {
|
|
751
|
+
event.preventDefault();
|
|
752
|
+
window.setTimeout(() => {
|
|
753
|
+
suppressClick = false;
|
|
754
|
+
}, 0);
|
|
755
|
+
}
|
|
756
|
+
drag = null;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
wrapper.addEventListener("pointerdown", (event) => {
|
|
760
|
+
if (event.button !== 0) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (drag) {
|
|
764
|
+
cleanupNodeDrag(drag);
|
|
765
|
+
}
|
|
766
|
+
const pointerId = event.pointerId;
|
|
767
|
+
const onPointerMove = (moveEvent) => moveNodeDrag(moveEvent);
|
|
768
|
+
const onPointerEnd = (endEvent) => endDrag(endEvent);
|
|
769
|
+
drag = {
|
|
770
|
+
pointerId,
|
|
771
|
+
startX: event.clientX,
|
|
772
|
+
startY: event.clientY,
|
|
773
|
+
startLeft: positionValue(wrapper.style.left),
|
|
774
|
+
startTop: positionValue(wrapper.style.top),
|
|
775
|
+
moved: false,
|
|
776
|
+
ready: false,
|
|
777
|
+
captured: false,
|
|
778
|
+
holdTimer: 0,
|
|
779
|
+
onPointerMove,
|
|
780
|
+
onPointerEnd,
|
|
781
|
+
};
|
|
782
|
+
drag.holdTimer = window.setTimeout(() => {
|
|
783
|
+
if (drag?.pointerId === pointerId) {
|
|
784
|
+
drag.ready = true;
|
|
785
|
+
}
|
|
786
|
+
}, NODE_DRAG_HOLD_MS);
|
|
787
|
+
window.addEventListener("pointermove", onPointerMove, true);
|
|
788
|
+
window.addEventListener("pointerup", onPointerEnd, true);
|
|
789
|
+
window.addEventListener("pointercancel", onPointerEnd, true);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function cleanupEdgeGeometrySync() {
|
|
794
|
+
if (activeEdgeGeometryCleanup) {
|
|
795
|
+
activeEdgeGeometryCleanup();
|
|
796
|
+
activeEdgeGeometryCleanup = null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function installEdgeGeometrySync(canvas, model) {
|
|
801
|
+
cleanupEdgeGeometrySync();
|
|
802
|
+
const cleanups = [];
|
|
803
|
+
let frame = 0;
|
|
804
|
+
const schedule = () => {
|
|
805
|
+
if (frame !== 0) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
frame = window.requestAnimationFrame(() => {
|
|
809
|
+
frame = 0;
|
|
810
|
+
if (canvas.isConnected) {
|
|
811
|
+
syncEdgeGeometry(canvas, model);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
if (typeof ResizeObserver === "function") {
|
|
817
|
+
const observer = new ResizeObserver(schedule);
|
|
818
|
+
observer.observe(canvas);
|
|
819
|
+
for (const wrapper of canvas.querySelectorAll(".dependency-graph-node")) {
|
|
820
|
+
observer.observe(wrapper);
|
|
821
|
+
}
|
|
822
|
+
cleanups.push(() => observer.disconnect());
|
|
823
|
+
} else {
|
|
824
|
+
window.addEventListener("resize", schedule);
|
|
825
|
+
cleanups.push(() => window.removeEventListener("resize", schedule));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
schedule();
|
|
829
|
+
activeEdgeGeometryCleanup = () => {
|
|
830
|
+
if (frame !== 0) {
|
|
831
|
+
window.cancelAnimationFrame(frame);
|
|
832
|
+
frame = 0;
|
|
833
|
+
}
|
|
834
|
+
for (const cleanup of cleanups) {
|
|
835
|
+
cleanup();
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function installViewportPanning(viewport) {
|
|
841
|
+
let pan = null;
|
|
842
|
+
let suppressClick = false;
|
|
843
|
+
|
|
844
|
+
viewport.addEventListener("click", (event) => {
|
|
845
|
+
if (!suppressClick) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
suppressClick = false;
|
|
849
|
+
event.preventDefault();
|
|
850
|
+
event.stopImmediatePropagation();
|
|
851
|
+
}, true);
|
|
852
|
+
|
|
853
|
+
viewport.addEventListener("pointerdown", (event) => {
|
|
854
|
+
if (event.target.closest(".dependency-graph-node")) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const isMiddlePan = event.button === 1;
|
|
858
|
+
if (activeGraphTool !== "pan" && !isMiddlePan) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (event.button !== 0 && !isMiddlePan) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
pan = {
|
|
865
|
+
pointerId: event.pointerId,
|
|
866
|
+
startX: event.clientX,
|
|
867
|
+
startY: event.clientY,
|
|
868
|
+
startLeft: viewport.scrollLeft,
|
|
869
|
+
startTop: viewport.scrollTop,
|
|
870
|
+
moved: false,
|
|
871
|
+
};
|
|
872
|
+
viewport.setPointerCapture(event.pointerId);
|
|
873
|
+
viewport.classList.add("is-panning");
|
|
874
|
+
event.preventDefault();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
viewport.addEventListener("pointermove", (event) => {
|
|
878
|
+
if (!pan || event.pointerId !== pan.pointerId) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const deltaX = event.clientX - pan.startX;
|
|
882
|
+
const deltaY = event.clientY - pan.startY;
|
|
883
|
+
if (!pan.moved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD_PX) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
pan.moved = true;
|
|
887
|
+
suppressClick = true;
|
|
888
|
+
viewport.scrollLeft = pan.startLeft - deltaX;
|
|
889
|
+
viewport.scrollTop = pan.startTop - deltaY;
|
|
890
|
+
event.preventDefault();
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const endPan = (event) => {
|
|
894
|
+
if (!pan || event.pointerId !== pan.pointerId) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (viewport.hasPointerCapture(event.pointerId)) {
|
|
898
|
+
viewport.releasePointerCapture(event.pointerId);
|
|
899
|
+
}
|
|
900
|
+
viewport.classList.remove("is-panning");
|
|
901
|
+
pan = null;
|
|
902
|
+
};
|
|
903
|
+
viewport.addEventListener("pointerup", endPan);
|
|
904
|
+
viewport.addEventListener("pointercancel", endPan);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function installWheelZoom(viewport, surface, canvas, layout, model) {
|
|
908
|
+
viewport.addEventListener("wheel", (event) => {
|
|
909
|
+
if (event.deltaY === 0) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const nextZoom = clampGraphZoom(activeGraphZoom + (event.deltaY < 0 ? GRAPH_ZOOM_STEP : -GRAPH_ZOOM_STEP));
|
|
913
|
+
if (nextZoom === activeGraphZoom) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
event.preventDefault();
|
|
917
|
+
const previousZoom = activeGraphZoom;
|
|
918
|
+
const viewportRect = viewport.getBoundingClientRect();
|
|
919
|
+
const pointerX = event.clientX - viewportRect.left;
|
|
920
|
+
const pointerY = event.clientY - viewportRect.top;
|
|
921
|
+
const graphX = (viewport.scrollLeft + pointerX) / previousZoom;
|
|
922
|
+
const graphY = (viewport.scrollTop + pointerY) / previousZoom;
|
|
923
|
+
|
|
924
|
+
setGraphZoom(nextZoom);
|
|
925
|
+
applyGraphZoom(viewport, surface, canvas, layout);
|
|
926
|
+
viewport.scrollLeft = graphX * activeGraphZoom - pointerX;
|
|
927
|
+
viewport.scrollTop = graphY * activeGraphZoom - pointerY;
|
|
928
|
+
syncEdgeGeometry(canvas, model);
|
|
929
|
+
}, { passive: false });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function renderGraphCanvas(model, ctx) {
|
|
933
|
+
const layout = layoutGraph(model, activeLayoutId);
|
|
934
|
+
const viewport = document.createElement("div");
|
|
935
|
+
viewport.className = "dependency-canvas-viewport";
|
|
936
|
+
viewport.dataset.tool = activeGraphTool;
|
|
937
|
+
viewport.dataset.zoom = String(activeGraphZoom);
|
|
938
|
+
|
|
939
|
+
const surface = document.createElement("div");
|
|
940
|
+
surface.className = "dependency-canvas-surface";
|
|
941
|
+
|
|
942
|
+
const canvas = document.createElement("div");
|
|
943
|
+
canvas.className = "dependency-canvas";
|
|
944
|
+
canvas.style.width = `${layout.width}px`;
|
|
945
|
+
canvas.style.height = `${layout.height}px`;
|
|
946
|
+
applyGraphZoom(viewport, surface, canvas, layout);
|
|
947
|
+
canvas.append(renderEdges(model));
|
|
948
|
+
canvas.append(renderLayoutLabels(layout));
|
|
949
|
+
|
|
950
|
+
for (const node of model.nodes.values()) {
|
|
951
|
+
const position = layout.positions.get(node.id);
|
|
952
|
+
if (!position) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const wrapper = document.createElement("div");
|
|
956
|
+
wrapper.className = "dependency-graph-node";
|
|
957
|
+
wrapper.dataset.nodeId = node.id;
|
|
958
|
+
wrapper.style.left = `${position.x}px`;
|
|
959
|
+
wrapper.style.top = `${position.y}px`;
|
|
960
|
+
wrapper.style.width = `${position.width}px`;
|
|
961
|
+
wrapper.style.minHeight = `${position.height}px`;
|
|
962
|
+
wrapper.append(activeLayoutId === "web" ? renderWebNode(node, model, ctx) : renderNode(node, model, ctx));
|
|
963
|
+
installNodeDragging(canvas, wrapper, model);
|
|
964
|
+
canvas.append(wrapper);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
wireTraceInteractions(canvas, model);
|
|
968
|
+
installEdgeGeometrySync(canvas, model);
|
|
969
|
+
installViewportPanning(viewport);
|
|
970
|
+
installWheelZoom(viewport, surface, canvas, layout, model);
|
|
971
|
+
surface.append(canvas);
|
|
972
|
+
viewport.append(surface);
|
|
973
|
+
return viewport;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function connectedIds(model, nodeId) {
|
|
977
|
+
const ids = new Set([nodeId]);
|
|
978
|
+
for (const edge of model.edges) {
|
|
979
|
+
if (edge.from === nodeId) {
|
|
980
|
+
ids.add(edge.to);
|
|
981
|
+
}
|
|
982
|
+
if (edge.to === nodeId) {
|
|
983
|
+
ids.add(edge.from);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return ids;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function applyTrace(canvas, model, nodeId) {
|
|
990
|
+
const active = nodeId && model.nodes.has(nodeId) ? connectedIds(model, nodeId) : null;
|
|
991
|
+
for (const node of canvas.querySelectorAll(".dependency-graph-node")) {
|
|
992
|
+
const isActive = active == null || active.has(node.dataset.nodeId);
|
|
993
|
+
node.classList.toggle("is-dimmed", !isActive);
|
|
994
|
+
}
|
|
995
|
+
for (const edge of canvas.querySelectorAll(".dependency-edge")) {
|
|
996
|
+
const isTraced = active != null && (edge.dataset.from === nodeId || edge.dataset.to === nodeId);
|
|
997
|
+
const isDimmed = active != null && !isTraced;
|
|
998
|
+
edge.classList.toggle("is-traced", isTraced);
|
|
999
|
+
edge.classList.toggle("is-dimmed", isDimmed);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function wireTraceInteractions(canvas, model) {
|
|
1004
|
+
for (const wrapper of canvas.querySelectorAll(".dependency-graph-node")) {
|
|
1005
|
+
const nodeId = wrapper.dataset.nodeId;
|
|
1006
|
+
wrapper.addEventListener("pointerenter", () => {
|
|
1007
|
+
focusedNode = nodeId;
|
|
1008
|
+
applyTrace(canvas, model, focusedNode);
|
|
1009
|
+
});
|
|
1010
|
+
wrapper.addEventListener("pointerleave", () => {
|
|
1011
|
+
if (!wrapper.contains(document.activeElement)) {
|
|
1012
|
+
focusedNode = null;
|
|
1013
|
+
applyTrace(canvas, model, null);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
wrapper.addEventListener("focusin", () => {
|
|
1017
|
+
focusedNode = nodeId;
|
|
1018
|
+
applyTrace(canvas, model, focusedNode);
|
|
1019
|
+
});
|
|
1020
|
+
wrapper.addEventListener("focusout", (event) => {
|
|
1021
|
+
if (!canvas.contains(event.relatedTarget)) {
|
|
1022
|
+
focusedNode = null;
|
|
1023
|
+
applyTrace(canvas, model, null);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
applyTrace(canvas, model, focusedNode);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function graphItems(items) {
|
|
1031
|
+
if (items.length <= LARGE_GRAPH_THRESHOLD || showTerminalBranches) {
|
|
1032
|
+
return items;
|
|
1033
|
+
}
|
|
1034
|
+
const active = items.filter((item) => !isTerminalItem(item));
|
|
1035
|
+
return active.length > 0 ? active : items;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function renderLayoutButtons(root, ctx) {
|
|
1039
|
+
const group = document.createElement("div");
|
|
1040
|
+
group.className = "dependency-layout-buttons";
|
|
1041
|
+
group.setAttribute("role", "group");
|
|
1042
|
+
for (const layout of GRAPH_LAYOUTS) {
|
|
1043
|
+
const button = document.createElement("button");
|
|
1044
|
+
button.className = "filter-chip dependency-toolbar__button";
|
|
1045
|
+
button.type = "button";
|
|
1046
|
+
button.dataset.layoutId = layout.id;
|
|
1047
|
+
button.textContent = layout.label;
|
|
1048
|
+
button.setAttribute("aria-pressed", String(activeLayoutId === layout.id));
|
|
1049
|
+
button.addEventListener("click", () => {
|
|
1050
|
+
activeLayoutId = layout.id;
|
|
1051
|
+
dependencyView.mount(root, ctx);
|
|
1052
|
+
});
|
|
1053
|
+
group.append(button);
|
|
1054
|
+
}
|
|
1055
|
+
return group;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function renderGraphInteractionControls(root, ctx) {
|
|
1059
|
+
const controls = document.createElement("div");
|
|
1060
|
+
controls.className = "dependency-interaction-controls";
|
|
1061
|
+
|
|
1062
|
+
const toolGroup = document.createElement("div");
|
|
1063
|
+
toolGroup.className = "dependency-tool-buttons";
|
|
1064
|
+
toolGroup.setAttribute("role", "group");
|
|
1065
|
+
for (const tool of GRAPH_TOOLS) {
|
|
1066
|
+
const button = document.createElement("button");
|
|
1067
|
+
button.className = "filter-chip dependency-toolbar__button";
|
|
1068
|
+
button.type = "button";
|
|
1069
|
+
button.dataset.toolId = tool.id;
|
|
1070
|
+
button.setAttribute("aria-label", tool.title);
|
|
1071
|
+
button.title = tool.title;
|
|
1072
|
+
button.append(svgIcon(tool.icon), textElement("span", "dependency-tool-label", tool.label));
|
|
1073
|
+
button.setAttribute("aria-pressed", String(activeGraphTool === tool.id));
|
|
1074
|
+
button.addEventListener("click", () => {
|
|
1075
|
+
activeGraphTool = tool.id;
|
|
1076
|
+
dependencyView.mount(root, ctx);
|
|
1077
|
+
});
|
|
1078
|
+
toolGroup.append(button);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const zoomGroup = document.createElement("div");
|
|
1082
|
+
zoomGroup.className = "dependency-zoom-buttons";
|
|
1083
|
+
zoomGroup.setAttribute("role", "group");
|
|
1084
|
+
const zoomOut = document.createElement("button");
|
|
1085
|
+
zoomOut.className = "filter-chip dependency-toolbar__button";
|
|
1086
|
+
zoomOut.type = "button";
|
|
1087
|
+
zoomOut.dataset.zoomAction = "out";
|
|
1088
|
+
zoomOut.textContent = "-";
|
|
1089
|
+
zoomOut.setAttribute("aria-label", "Zoom out");
|
|
1090
|
+
zoomOut.disabled = activeGraphZoom <= MIN_GRAPH_ZOOM;
|
|
1091
|
+
zoomOut.addEventListener("click", () => {
|
|
1092
|
+
setGraphZoom(activeGraphZoom - GRAPH_ZOOM_STEP);
|
|
1093
|
+
dependencyView.mount(root, ctx);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
const zoomReset = document.createElement("button");
|
|
1097
|
+
zoomReset.className = "filter-chip dependency-toolbar__button dependency-toolbar__zoom-label";
|
|
1098
|
+
zoomReset.type = "button";
|
|
1099
|
+
zoomReset.dataset.zoomAction = "reset";
|
|
1100
|
+
zoomReset.textContent = graphZoomLabel();
|
|
1101
|
+
zoomReset.setAttribute("aria-label", "Reset zoom");
|
|
1102
|
+
zoomReset.addEventListener("click", () => {
|
|
1103
|
+
setGraphZoom(1);
|
|
1104
|
+
dependencyView.mount(root, ctx);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
const zoomIn = document.createElement("button");
|
|
1108
|
+
zoomIn.className = "filter-chip dependency-toolbar__button";
|
|
1109
|
+
zoomIn.type = "button";
|
|
1110
|
+
zoomIn.dataset.zoomAction = "in";
|
|
1111
|
+
zoomIn.textContent = "+";
|
|
1112
|
+
zoomIn.setAttribute("aria-label", "Zoom in");
|
|
1113
|
+
zoomIn.disabled = activeGraphZoom >= MAX_GRAPH_ZOOM;
|
|
1114
|
+
zoomIn.addEventListener("click", () => {
|
|
1115
|
+
setGraphZoom(activeGraphZoom + GRAPH_ZOOM_STEP);
|
|
1116
|
+
dependencyView.mount(root, ctx);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
zoomGroup.append(zoomOut, zoomReset, zoomIn);
|
|
1120
|
+
controls.append(toolGroup, zoomGroup);
|
|
1121
|
+
return controls;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function renderEdgeLegendItem(className, label) {
|
|
1125
|
+
const item = document.createElement("span");
|
|
1126
|
+
item.className = "dependency-edge-legend__item";
|
|
1127
|
+
const line = document.createElement("span");
|
|
1128
|
+
line.className = `dependency-edge-legend__line ${className}`;
|
|
1129
|
+
item.append(line, textElement("span", "", label));
|
|
1130
|
+
return item;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function renderEdgeLegend() {
|
|
1134
|
+
const legend = document.createElement("div");
|
|
1135
|
+
legend.className = "dependency-edge-legend";
|
|
1136
|
+
legend.setAttribute("aria-label", "Dependency edge legend");
|
|
1137
|
+
legend.append(
|
|
1138
|
+
renderEdgeLegendItem("dependency-edge-legend__line--met", "Satisfied dependency"),
|
|
1139
|
+
renderEdgeLegendItem("dependency-edge-legend__line--unmet", "Unmet dependency"),
|
|
1140
|
+
);
|
|
1141
|
+
return legend;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function renderGraphToolbar(root, ctx, items) {
|
|
1145
|
+
const toolbar = document.createElement("div");
|
|
1146
|
+
toolbar.className = "dependency-toolbar";
|
|
1147
|
+
toolbar.append(renderLayoutButtons(root, ctx), renderGraphInteractionControls(root, ctx));
|
|
1148
|
+
|
|
1149
|
+
if (items.length > LARGE_GRAPH_THRESHOLD) {
|
|
1150
|
+
const terminalButton = document.createElement("button");
|
|
1151
|
+
terminalButton.className = "filter-chip dependency-toolbar__button";
|
|
1152
|
+
terminalButton.type = "button";
|
|
1153
|
+
terminalButton.textContent = showTerminalBranches ? "Collapse terminal" : "Show terminal";
|
|
1154
|
+
terminalButton.setAttribute("aria-pressed", String(showTerminalBranches));
|
|
1155
|
+
terminalButton.addEventListener("click", () => {
|
|
1156
|
+
showTerminalBranches = !showTerminalBranches;
|
|
1157
|
+
dependencyView.mount(root, ctx);
|
|
1158
|
+
});
|
|
1159
|
+
toolbar.append(terminalButton);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
toolbar.append(renderEdgeLegend());
|
|
1163
|
+
return toolbar;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export const dependencyView = {
|
|
1167
|
+
id: "dependency",
|
|
1168
|
+
label: "Dependency",
|
|
1169
|
+
mount(root, ctx) {
|
|
1170
|
+
cleanupEdgeGeometrySync();
|
|
1171
|
+
const items = ctx.visibleItems();
|
|
1172
|
+
const model = buildDependencyModel(graphItems(items));
|
|
1173
|
+
|
|
1174
|
+
const view = document.createElement("div");
|
|
1175
|
+
view.className = "dependency-view";
|
|
1176
|
+
const header = document.createElement("header");
|
|
1177
|
+
header.className = "dependency-view__header";
|
|
1178
|
+
header.append(
|
|
1179
|
+
textElement("h1", "", "Dependency"),
|
|
1180
|
+
textElement("p", "view-digest", `${model.nodes.size} nodes and ${model.edges.length} dependency edges in the filtered set.`),
|
|
1181
|
+
);
|
|
1182
|
+
view.append(header);
|
|
1183
|
+
|
|
1184
|
+
const warning = renderCycleWarning(model);
|
|
1185
|
+
if (warning) {
|
|
1186
|
+
view.append(warning);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
view.append(renderGraphToolbar(root, ctx, items));
|
|
1190
|
+
view.append(renderGraphCanvas(model, ctx));
|
|
1191
|
+
root.replaceChildren(view);
|
|
1192
|
+
},
|
|
1193
|
+
};
|