dagward 0.1.0 → 0.1.1
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/README.md +8 -0
- package/dist/cli.js +58 -11
- package/dist/viz.js +286 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,14 @@ Requirements: Node.js ≥ 20, a TypeScript project with a `tsconfig.json`. Monor
|
|
|
44
44
|
- **`ARCHITECTURE.md`** — a human-readable snapshot of your system as it actually is: layers, module boundaries, existing cycles, and the edges that look accidental. Useful on its own, even if you never adopt the rules.
|
|
45
45
|
- **`dagward.yml`** — a draft rule file. Nothing is enforced until you review, edit, and commit it. Confirming is a 10-minute job; writing from scratch would take days.
|
|
46
46
|
|
|
47
|
+
## Visualize
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx dagward graph && npx dagward viz
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`dagward viz` renders the graphs in `dagward-out/` to a single self-contained `viz.html` — no network, no CDN, works offline — and opens it in your default browser (`--no-open` to skip). Tabs switch between folder, file, and function level; dependencies point downward, type-only imports are dashed, and cycle members are flagged red.
|
|
54
|
+
|
|
47
55
|
## The rule file
|
|
48
56
|
|
|
49
57
|
Rules are declarative YAML — language-agnostic by design, and readable by both humans and agents. MVP supports three rule types:
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
@@ -9,21 +10,31 @@ import { buildFunctionGraph } from "./functionGraph.js";
|
|
|
9
10
|
import { serializeGraph } from "./graph.js";
|
|
10
11
|
import { ConfigError, loadProject } from "./project.js";
|
|
11
12
|
import { renderArchitectureMd } from "./report.js";
|
|
13
|
+
import { renderVizHtml } from "./viz.js";
|
|
12
14
|
const HELP = `dagward — multi-level dependency graphs for TypeScript projects
|
|
13
15
|
|
|
14
16
|
Usage:
|
|
15
17
|
dagward graph [dir] [options] Analyze the project and write graphs + report
|
|
16
18
|
dagward init [dir] [options] Alias of \`graph\` (rule inference coming later)
|
|
19
|
+
dagward viz [dir] [options] Render dagward-out graphs to an interactive viz.html
|
|
17
20
|
|
|
18
21
|
Options:
|
|
19
22
|
--project <path> Explicit tsconfig.json (default: nearest to [dir])
|
|
20
23
|
--out <dir> Output directory (default: <dir>/dagward-out)
|
|
24
|
+
--no-open viz: do not open viz.html in the browser
|
|
21
25
|
--help Show this help
|
|
22
26
|
--version Show version
|
|
23
27
|
|
|
24
28
|
Outputs: graph.folders.json, graph.files.json, graph.functions.json, ARCHITECTURE.md
|
|
25
29
|
|
|
26
30
|
Exit codes: 0 ok, 2 config error.`;
|
|
31
|
+
function timed(label, fn) {
|
|
32
|
+
const start = performance.now();
|
|
33
|
+
const result = fn();
|
|
34
|
+
const text = typeof label === "string" ? label : label(result);
|
|
35
|
+
console.error(`→ ${text} … ${Math.round(performance.now() - start)}ms`);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
27
38
|
function version() {
|
|
28
39
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
29
40
|
return pkg.version;
|
|
@@ -34,6 +45,7 @@ export function main(argv) {
|
|
|
34
45
|
options: {
|
|
35
46
|
project: { type: "string" },
|
|
36
47
|
out: { type: "string" },
|
|
48
|
+
"no-open": { type: "boolean" },
|
|
37
49
|
help: { type: "boolean" },
|
|
38
50
|
version: { type: "boolean" },
|
|
39
51
|
},
|
|
@@ -48,7 +60,7 @@ export function main(argv) {
|
|
|
48
60
|
return 0;
|
|
49
61
|
}
|
|
50
62
|
const [command, dirArg] = positionals;
|
|
51
|
-
if (command !== "graph" && command !== "init") {
|
|
63
|
+
if (command !== "graph" && command !== "init" && command !== "viz") {
|
|
52
64
|
console.error(command ? `Unknown command: ${command}` : HELP);
|
|
53
65
|
return command ? 2 : 0;
|
|
54
66
|
}
|
|
@@ -57,9 +69,12 @@ export function main(argv) {
|
|
|
57
69
|
}
|
|
58
70
|
const targetDir = path.resolve(dirArg ?? ".");
|
|
59
71
|
const outDir = path.resolve(values.out ?? path.join(targetDir, "dagward-out"));
|
|
72
|
+
if (command === "viz") {
|
|
73
|
+
return runViz(outDir, values["no-open"] ?? false);
|
|
74
|
+
}
|
|
60
75
|
let project;
|
|
61
76
|
try {
|
|
62
|
-
project = loadProject(targetDir, values.project ? path.resolve(values.project) : undefined);
|
|
77
|
+
project = timed((p) => `load project (${p.sourceFiles.length} files, tsconfig at ${p.rootDir})`, () => loadProject(targetDir, values.project ? path.resolve(values.project) : undefined));
|
|
63
78
|
}
|
|
64
79
|
catch (error) {
|
|
65
80
|
if (error instanceof ConfigError) {
|
|
@@ -68,15 +83,16 @@ export function main(argv) {
|
|
|
68
83
|
}
|
|
69
84
|
throw error;
|
|
70
85
|
}
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const { graph: files, skippedDynamicImports } = timed("file graph", () => buildFileGraph(project));
|
|
87
|
+
const folders = timed("folder graph", () => buildFolderGraph(files));
|
|
88
|
+
const functions = timed("function graph", () => buildFunctionGraph(project));
|
|
89
|
+
timed("write outputs", () => {
|
|
90
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
91
|
+
fs.writeFileSync(path.join(outDir, "graph.folders.json"), serializeGraph(folders));
|
|
92
|
+
fs.writeFileSync(path.join(outDir, "graph.files.json"), serializeGraph(files));
|
|
93
|
+
fs.writeFileSync(path.join(outDir, "graph.functions.json"), serializeGraph(functions));
|
|
94
|
+
fs.writeFileSync(path.join(outDir, "ARCHITECTURE.md"), renderArchitectureMd({ folders, files, functions, skippedDynamicImports, version: version() }));
|
|
95
|
+
});
|
|
80
96
|
for (const graph of [folders, files, functions]) {
|
|
81
97
|
const cycles = graph.cycles.length > 0 ? `${graph.cycles.length} cycle(s)!` : "no cycles";
|
|
82
98
|
console.error(` ${graph.level}: ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${cycles}`);
|
|
@@ -84,6 +100,37 @@ export function main(argv) {
|
|
|
84
100
|
console.error(`Wrote 4 files to ${outDir}`);
|
|
85
101
|
return 0;
|
|
86
102
|
}
|
|
103
|
+
function runViz(outDir, noOpen) {
|
|
104
|
+
const graphs = {};
|
|
105
|
+
for (const level of ["folders", "files", "functions"]) {
|
|
106
|
+
const file = path.join(outDir, `graph.${level}.json`);
|
|
107
|
+
try {
|
|
108
|
+
graphs[level] = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
console.error(`Cannot read ${file}. Run \`dagward graph\` first.`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const htmlPath = path.join(outDir, "viz.html");
|
|
116
|
+
timed("write viz.html", () => fs.writeFileSync(htmlPath, renderVizHtml(graphs)));
|
|
117
|
+
console.error(`Wrote ${htmlPath}`);
|
|
118
|
+
if (!noOpen)
|
|
119
|
+
openInBrowser(htmlPath);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
// Never shell: true — the path goes through as a plain argv element, and
|
|
123
|
+
// rundll32 (not `cmd /c start`) avoids cmd.exe re-parsing it on Windows.
|
|
124
|
+
function openInBrowser(file) {
|
|
125
|
+
const [cmd, args] = process.platform === "darwin"
|
|
126
|
+
? ["open", [file]]
|
|
127
|
+
: process.platform === "win32"
|
|
128
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", file]]
|
|
129
|
+
: ["xdg-open", [file]];
|
|
130
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
131
|
+
child.on("error", () => { }); // a missing opener is not a CLI failure
|
|
132
|
+
child.unref();
|
|
133
|
+
}
|
|
87
134
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
88
135
|
process.exit(main(process.argv.slice(2)));
|
|
89
136
|
}
|
package/dist/viz.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const STYLE = `
|
|
2
|
+
* { margin: 0; box-sizing: border-box; }
|
|
3
|
+
html, body { height: 100%; }
|
|
4
|
+
body { font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; display: flex; flex-direction: column; background: #f8fafc; color: #0f172a; }
|
|
5
|
+
header { display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: #0f172a; color: #e2e8f0; }
|
|
6
|
+
.brand { font-weight: 700; margin-right: 8px; }
|
|
7
|
+
.tab { background: none; border: 1px solid #334155; color: #cbd5e1; border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; }
|
|
8
|
+
.tab.active { background: #1e293b; color: #fff; border-color: #64748b; }
|
|
9
|
+
.tab span { opacity: 0.6; font-size: 11px; }
|
|
10
|
+
#legend { margin-left: auto; font-size: 11px; color: #94a3b8; }
|
|
11
|
+
#canvas { flex: 1; touch-action: none; cursor: grab; }
|
|
12
|
+
.node { cursor: pointer; }
|
|
13
|
+
.node rect { fill: #fff; stroke: #94a3b8; }
|
|
14
|
+
.node text { font: 11px ui-monospace, Menlo, monospace; fill: #0f172a; pointer-events: none; }
|
|
15
|
+
.node.cyclic rect { stroke: #dc2626; fill: #fef2f2; }
|
|
16
|
+
.edge { fill: none; stroke: #94a3b8; stroke-width: 1.2; }
|
|
17
|
+
.edge.cyc { stroke: #dc2626; }
|
|
18
|
+
.k-type { stroke-dasharray: 4 3; }
|
|
19
|
+
.k-dynamic { stroke-dasharray: 1.5 3; }
|
|
20
|
+
.dimmed .node:not(.hi), .dimmed .edge:not(.hi) { opacity: 0.12; }
|
|
21
|
+
.edge.hi { stroke: #0f766e; stroke-width: 1.8; }
|
|
22
|
+
#details { position: fixed; right: 12px; top: 54px; background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; max-width: 380px; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12); font-size: 12px; word-break: break-all; }
|
|
23
|
+
#details div { margin: 2px 0; }
|
|
24
|
+
`;
|
|
25
|
+
// Client-side JS. Lives inside a TS template literal, so it must not contain
|
|
26
|
+
// backticks, \${, or a literal </script> sequence.
|
|
27
|
+
const SCRIPT = `
|
|
28
|
+
"use strict";
|
|
29
|
+
var DATA = JSON.parse(document.getElementById("data").textContent);
|
|
30
|
+
var NW = 170, NH = 26, HGAP = 14, VGAP = 64;
|
|
31
|
+
var svg = document.getElementById("canvas");
|
|
32
|
+
var world = document.getElementById("world");
|
|
33
|
+
var details = document.getElementById("details");
|
|
34
|
+
var layouts = {};
|
|
35
|
+
var current = null;
|
|
36
|
+
var view = { s: 1, tx: 0, ty: 0 };
|
|
37
|
+
var nodeEls = {}, incident = {}, neighbors = {};
|
|
38
|
+
|
|
39
|
+
// graph.cycles is exactly the set of non-trivial SCCs, so the condensation
|
|
40
|
+
// (collapse each cycle to one component) is acyclic by construction.
|
|
41
|
+
function condense(graph) {
|
|
42
|
+
var compOf = {};
|
|
43
|
+
graph.nodes.forEach(function (n) { compOf[n.id] = n.id; });
|
|
44
|
+
graph.cycles.forEach(function (c) {
|
|
45
|
+
c.nodes.forEach(function (id) { compOf[id] = "cycle:" + c.id; });
|
|
46
|
+
});
|
|
47
|
+
var deps = {};
|
|
48
|
+
graph.nodes.forEach(function (n) { deps[compOf[n.id]] = deps[compOf[n.id]] || {}; });
|
|
49
|
+
graph.edges.forEach(function (e) {
|
|
50
|
+
var a = compOf[e.from], b = compOf[e.to];
|
|
51
|
+
if (a !== b) deps[a][b] = true;
|
|
52
|
+
});
|
|
53
|
+
return { compOf: compOf, deps: deps };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Longest-path layering: depth 0 depends on nothing; cycle members share a depth.
|
|
57
|
+
function layerDepths(deps) {
|
|
58
|
+
var depth = {};
|
|
59
|
+
function resolve(c) {
|
|
60
|
+
if (depth[c] !== undefined) return depth[c];
|
|
61
|
+
var d = 0;
|
|
62
|
+
Object.keys(deps[c]).forEach(function (t) { d = Math.max(d, resolve(t) + 1); });
|
|
63
|
+
depth[c] = d;
|
|
64
|
+
return d;
|
|
65
|
+
}
|
|
66
|
+
Object.keys(deps).forEach(resolve);
|
|
67
|
+
return depth;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function computeLayout(graph) {
|
|
71
|
+
var c = condense(graph);
|
|
72
|
+
var depth = layerDepths(c.deps);
|
|
73
|
+
var rows = {}, maxDepth = 0;
|
|
74
|
+
graph.nodes.forEach(function (n) {
|
|
75
|
+
var d = depth[c.compOf[n.id]];
|
|
76
|
+
(rows[d] = rows[d] || []).push(n.id);
|
|
77
|
+
maxDepth = Math.max(maxDepth, d);
|
|
78
|
+
});
|
|
79
|
+
var maxRowWidth = 0;
|
|
80
|
+
Object.keys(rows).forEach(function (d) {
|
|
81
|
+
rows[d].sort();
|
|
82
|
+
maxRowWidth = Math.max(maxRowWidth, rows[d].length * (NW + HGAP) - HGAP);
|
|
83
|
+
});
|
|
84
|
+
var pos = {};
|
|
85
|
+
Object.keys(rows).forEach(function (d) {
|
|
86
|
+
var row = rows[d];
|
|
87
|
+
var x0 = (maxRowWidth - (row.length * (NW + HGAP) - HGAP)) / 2;
|
|
88
|
+
row.forEach(function (id, i) {
|
|
89
|
+
pos[id] = { x: x0 + i * (NW + HGAP), y: (maxDepth - d) * (NH + VGAP) };
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
var cyclic = {};
|
|
93
|
+
graph.cycles.forEach(function (cy) { cy.nodes.forEach(function (id) { cyclic[id] = cy.id; }); });
|
|
94
|
+
return { pos: pos, cyclic: cyclic, width: maxRowWidth, height: maxDepth * (NH + VGAP) + NH };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function el(tag, attrs) {
|
|
98
|
+
var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
99
|
+
for (var k in attrs) e.setAttribute(k, attrs[k]);
|
|
100
|
+
return e;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function edgePath(p1, p2, self) {
|
|
104
|
+
if (self) {
|
|
105
|
+
var rx = p1.x + NW, ry = p1.y + NH / 2;
|
|
106
|
+
return "M" + rx + " " + (ry - 6) + " C " + (rx + 34) + " " + (ry - 20) + ", " + (rx + 34) + " " + (ry + 20) + ", " + rx + " " + (ry + 6);
|
|
107
|
+
}
|
|
108
|
+
if (p1.y === p2.y) {
|
|
109
|
+
var leftToRight = p1.x < p2.x;
|
|
110
|
+
var x1 = p1.x + (leftToRight ? NW : 0), x2 = p2.x + (leftToRight ? 0 : NW);
|
|
111
|
+
var y = p1.y + NH / 2, mx = (x1 + x2) / 2;
|
|
112
|
+
return "M" + x1 + " " + y + " C " + mx + " " + (y - 34) + ", " + mx + " " + (y - 34) + ", " + x2 + " " + y;
|
|
113
|
+
}
|
|
114
|
+
var sx = p1.x + NW / 2, sy = p1.y + NH, tx = p2.x + NW / 2, ty = p2.y;
|
|
115
|
+
var my = (sy + ty) / 2;
|
|
116
|
+
return "M" + sx + " " + sy + " C " + sx + " " + my + ", " + tx + " " + my + ", " + tx + " " + ty;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function label(id) {
|
|
120
|
+
return id.length > 24 ? "\\u2026" + id.slice(-23) : id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reg(id, edgeEl, neighborId) {
|
|
124
|
+
(incident[id] = incident[id] || []).push(edgeEl);
|
|
125
|
+
(neighbors[id] = neighbors[id] || []).push(neighborId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function highlight(id) {
|
|
129
|
+
svg.classList.add("dimmed");
|
|
130
|
+
nodeEls[id].classList.add("hi");
|
|
131
|
+
(incident[id] || []).forEach(function (p) { p.classList.add("hi"); });
|
|
132
|
+
(neighbors[id] || []).forEach(function (nb) { if (nodeEls[nb]) nodeEls[nb].classList.add("hi"); });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearHighlight() {
|
|
136
|
+
svg.classList.remove("dimmed");
|
|
137
|
+
world.querySelectorAll(".hi").forEach(function (e) { e.classList.remove("hi"); });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function showDetails(n) {
|
|
141
|
+
var graph = DATA[current], fanIn = 0, fanOut = 0;
|
|
142
|
+
graph.edges.forEach(function (e) {
|
|
143
|
+
if (e.from === n.id) fanOut++;
|
|
144
|
+
if (e.to === n.id) fanIn++;
|
|
145
|
+
});
|
|
146
|
+
details.hidden = false;
|
|
147
|
+
details.textContent = "";
|
|
148
|
+
var rows = [["id", n.id]];
|
|
149
|
+
if (n.file) rows.push(["file", n.file + (n.line ? ":" + n.line : "")]);
|
|
150
|
+
rows.push(["depends on", fanOut], ["depended on by", fanIn]);
|
|
151
|
+
var cyc = layouts[current].cyclic[n.id];
|
|
152
|
+
if (cyc !== undefined) rows.push(["cycle", "#" + cyc]);
|
|
153
|
+
rows.forEach(function (r) {
|
|
154
|
+
var d = document.createElement("div");
|
|
155
|
+
var b = document.createElement("b");
|
|
156
|
+
b.textContent = r[0] + ": ";
|
|
157
|
+
d.appendChild(b);
|
|
158
|
+
d.appendChild(document.createTextNode(r[1]));
|
|
159
|
+
details.appendChild(d);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function render(level) {
|
|
164
|
+
current = level;
|
|
165
|
+
var graph = DATA[level];
|
|
166
|
+
if (!layouts[level]) layouts[level] = computeLayout(graph);
|
|
167
|
+
var L = layouts[level];
|
|
168
|
+
world.textContent = "";
|
|
169
|
+
nodeEls = {}; incident = {}; neighbors = {};
|
|
170
|
+
graph.edges.forEach(function (e) {
|
|
171
|
+
var sameCycle = L.cyclic[e.from] !== undefined && L.cyclic[e.from] === L.cyclic[e.to];
|
|
172
|
+
var p = el("path", {
|
|
173
|
+
d: edgePath(L.pos[e.from], L.pos[e.to], e.from === e.to),
|
|
174
|
+
"class": "edge k-" + e.kind + (sameCycle ? " cyc" : ""),
|
|
175
|
+
"marker-end": "url(#arrow)",
|
|
176
|
+
});
|
|
177
|
+
world.appendChild(p);
|
|
178
|
+
reg(e.from, p, e.to);
|
|
179
|
+
if (e.from !== e.to) reg(e.to, p, e.from);
|
|
180
|
+
});
|
|
181
|
+
graph.nodes.forEach(function (n) {
|
|
182
|
+
var p = L.pos[n.id];
|
|
183
|
+
var g = el("g", {
|
|
184
|
+
"class": "node" + (L.cyclic[n.id] !== undefined ? " cyclic" : ""),
|
|
185
|
+
transform: "translate(" + p.x + "," + p.y + ")",
|
|
186
|
+
});
|
|
187
|
+
g.appendChild(el("rect", { width: NW, height: NH, rx: 6 }));
|
|
188
|
+
var t = el("text", { x: 8, y: 17 });
|
|
189
|
+
t.textContent = label(n.id);
|
|
190
|
+
g.appendChild(t);
|
|
191
|
+
var title = el("title", {});
|
|
192
|
+
title.textContent = n.id;
|
|
193
|
+
g.appendChild(title);
|
|
194
|
+
g.addEventListener("mouseenter", function () { highlight(n.id); });
|
|
195
|
+
g.addEventListener("mouseleave", clearHighlight);
|
|
196
|
+
g.addEventListener("click", function () { showDetails(n); });
|
|
197
|
+
world.appendChild(g);
|
|
198
|
+
nodeEls[n.id] = g;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function applyView() {
|
|
203
|
+
world.setAttribute("transform", "translate(" + view.tx + "," + view.ty + ") scale(" + view.s + ")");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fit(L) {
|
|
207
|
+
var vw = svg.clientWidth, vh = svg.clientHeight;
|
|
208
|
+
view.s = Math.min(vw / (L.width + 80), vh / (L.height + 80), 1.5);
|
|
209
|
+
view.tx = (vw - L.width * view.s) / 2;
|
|
210
|
+
view.ty = (vh - L.height * view.s) / 2;
|
|
211
|
+
applyView();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
svg.addEventListener("wheel", function (ev) {
|
|
215
|
+
ev.preventDefault();
|
|
216
|
+
var rect = svg.getBoundingClientRect();
|
|
217
|
+
var mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
|
218
|
+
var s2 = Math.min(4, Math.max(0.05, view.s * Math.exp(-ev.deltaY * 0.0015)));
|
|
219
|
+
var f = s2 / view.s;
|
|
220
|
+
view.tx = mx - (mx - view.tx) * f;
|
|
221
|
+
view.ty = my - (my - view.ty) * f;
|
|
222
|
+
view.s = s2;
|
|
223
|
+
applyView();
|
|
224
|
+
}, { passive: false });
|
|
225
|
+
|
|
226
|
+
var drag = null;
|
|
227
|
+
svg.addEventListener("pointerdown", function (ev) {
|
|
228
|
+
if (ev.target.closest(".node")) return;
|
|
229
|
+
details.hidden = true;
|
|
230
|
+
drag = { x: ev.clientX, y: ev.clientY, tx: view.tx, ty: view.ty };
|
|
231
|
+
svg.setPointerCapture(ev.pointerId);
|
|
232
|
+
});
|
|
233
|
+
svg.addEventListener("pointermove", function (ev) {
|
|
234
|
+
if (!drag) return;
|
|
235
|
+
view.tx = drag.tx + ev.clientX - drag.x;
|
|
236
|
+
view.ty = drag.ty + ev.clientY - drag.y;
|
|
237
|
+
applyView();
|
|
238
|
+
});
|
|
239
|
+
svg.addEventListener("pointerup", function () { drag = null; });
|
|
240
|
+
|
|
241
|
+
function setLevel(level) {
|
|
242
|
+
document.querySelectorAll(".tab").forEach(function (b) {
|
|
243
|
+
b.classList.toggle("active", b.dataset.level === level);
|
|
244
|
+
});
|
|
245
|
+
details.hidden = true;
|
|
246
|
+
clearHighlight();
|
|
247
|
+
render(level);
|
|
248
|
+
fit(layouts[level]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
document.querySelectorAll(".tab").forEach(function (b) {
|
|
252
|
+
b.addEventListener("click", function () { setLevel(b.dataset.level); });
|
|
253
|
+
});
|
|
254
|
+
setLevel("folders");
|
|
255
|
+
`;
|
|
256
|
+
function escapeHtml(text) {
|
|
257
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
258
|
+
}
|
|
259
|
+
export function renderVizHtml(input) {
|
|
260
|
+
// <-escape so a node id containing </script> cannot terminate the data
|
|
261
|
+
// tag; the blob stays valid JSON.
|
|
262
|
+
const data = JSON.stringify(input).replaceAll("<", "\\u003c");
|
|
263
|
+
const tabs = ["folders", "files", "functions"]
|
|
264
|
+
.map((level) => {
|
|
265
|
+
const graph = input[level];
|
|
266
|
+
return `<button class="tab" data-level="${level}">${level} <span>${graph.nodes.length}·${graph.edges.length}</span></button>`;
|
|
267
|
+
})
|
|
268
|
+
.join("");
|
|
269
|
+
return `<!doctype html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="utf-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
274
|
+
<title>dagward — ${escapeHtml(input.files.root)}</title>
|
|
275
|
+
<style>${STYLE}</style>
|
|
276
|
+
</head>
|
|
277
|
+
<body>
|
|
278
|
+
<header><span class="brand">dagward</span><nav>${tabs}</nav><span id="legend">dependencies point down · dashed = type-only · red = cycle · scroll zoom · drag pan</span></header>
|
|
279
|
+
<svg id="canvas"><defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="#94a3b8"/></marker></defs><g id="world"></g></svg>
|
|
280
|
+
<div id="details" hidden></div>
|
|
281
|
+
<script id="data" type="application/json">${data}</script>
|
|
282
|
+
<script>${SCRIPT}</script>
|
|
283
|
+
</body>
|
|
284
|
+
</html>
|
|
285
|
+
`;
|
|
286
|
+
}
|
package/package.json
CHANGED