tecton 0.4.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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/tecton.js +417 -0
- package/brand/tecton-favicon.svg +2 -0
- package/package.json +60 -0
- package/src/diff.js +93 -0
- package/src/graph.js +130 -0
- package/src/layout.js +191 -0
- package/src/report/dist/client.js +3 -0
- package/src/report/fonts/Geist-Variable.woff2 +0 -0
- package/src/report/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/report/fonts/OFL-LICENSE.txt +92 -0
- package/src/report/template.html +619 -0
- package/src/report.js +110 -0
- package/src/rules.js +82 -0
- package/src/scan.js +306 -0
- package/src/system.js +672 -0
- package/src/types.d.ts +207 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tecton",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Tecton: see every shift in your architecture before it becomes a crack. Architecture diagrams, before/after diffs and boundary rules for JS/TS codebases.",
|
|
5
|
+
"homepage": "https://github.com/gaurav2180/tecton#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/gaurav2180/tecton.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/gaurav2180/tecton/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "gaurav2180",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"tecton": "bin/tecton.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/report/client",
|
|
22
|
+
"brand/tecton-favicon.svg",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/build.js",
|
|
31
|
+
"dev": "node scripts/build.js --watch",
|
|
32
|
+
"check:bundle": "node scripts/build.js --check",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p src/report/client/tsconfig.json",
|
|
34
|
+
"test": "node test/run.js",
|
|
35
|
+
"test:ui": "playwright test",
|
|
36
|
+
"check": "npm run check:bundle && npm run typecheck && npm test && npm run test:ui",
|
|
37
|
+
"prepublishOnly": "npm run check:bundle && npm run typecheck && npm test"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"architecture",
|
|
41
|
+
"dependency-graph",
|
|
42
|
+
"dependencies",
|
|
43
|
+
"diagram",
|
|
44
|
+
"boundaries",
|
|
45
|
+
"lint",
|
|
46
|
+
"monorepo",
|
|
47
|
+
"typescript",
|
|
48
|
+
"javascript",
|
|
49
|
+
"ci",
|
|
50
|
+
"pull-request",
|
|
51
|
+
"github-action"
|
|
52
|
+
],
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@playwright/test": "^1.63.0",
|
|
56
|
+
"@types/node": "^26.6.3",
|
|
57
|
+
"esbuild": "^0.28.2",
|
|
58
|
+
"typescript": "^7.0.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Comparing two snapshots: what appeared, what disappeared, which rule breaks are new.
|
|
2
|
+
import { nameMatches } from './graph.js';
|
|
3
|
+
import { packagesNamedInRules } from './rules.js';
|
|
4
|
+
|
|
5
|
+
/** @returns {import('./types.js').Status} */
|
|
6
|
+
const status = (inBase, inCur) => (inBase && inCur ? 'same' : inCur ? 'added' : 'removed');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('./types.js').Graph} base
|
|
10
|
+
* @param {import('./types.js').Graph} cur
|
|
11
|
+
* @param {import('./types.js').Violation[]} baseViol
|
|
12
|
+
* @param {import('./types.js').Violation[]} curViol
|
|
13
|
+
* @param {import('./types.js').Config} cfg
|
|
14
|
+
* @returns {{ nodes: import('./types.js').MapNode[], edges: import('./types.js').MapEdge[], violations: import('./types.js').Violation[], summary: import('./types.js').Summary }}
|
|
15
|
+
*/
|
|
16
|
+
export function diffGraphs(base, cur, baseViol, curViol, cfg) {
|
|
17
|
+
const showPkg = (name) => cfg.externals === true
|
|
18
|
+
|| packagesNamedInRules(cfg).some((p) => nameMatches(p, name));
|
|
19
|
+
|
|
20
|
+
// ---- dependencies (arrows) ----
|
|
21
|
+
const edges = [];
|
|
22
|
+
const keys = new Set([...base.edges.keys(), ...cur.edges.keys()]);
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
const b = base.edges.get(key);
|
|
25
|
+
const c = cur.edges.get(key);
|
|
26
|
+
const e = c || b;
|
|
27
|
+
if (e.external && !showPkg(e.to)) continue;
|
|
28
|
+
const st = status(!!b, !!c);
|
|
29
|
+
edges.push({
|
|
30
|
+
id: key, from: e.from, to: e.to, external: e.external, status: st,
|
|
31
|
+
countBase: b ? b.count : 0, countCur: c ? c.count : 0,
|
|
32
|
+
imports: (c || b).imports,
|
|
33
|
+
addedImports: st === 'same' ? newEvidence(b.imports, c.imports) : [],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- modules (boxes) ----
|
|
38
|
+
const nodes = [];
|
|
39
|
+
const names = new Set([...base.modules.keys(), ...cur.modules.keys()]);
|
|
40
|
+
for (const e of edges) { if (e.external) { names.add(e.from); names.add(e.to); } }
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const external = name.startsWith('npm:');
|
|
43
|
+
const b = base.modules.get(name);
|
|
44
|
+
const c = cur.modules.get(name);
|
|
45
|
+
let inBase = !!b; let inCur = !!c;
|
|
46
|
+
if (external) {
|
|
47
|
+
inBase = edges.some((e) => e.to === name && e.status !== 'added');
|
|
48
|
+
inCur = edges.some((e) => e.to === name && e.status !== 'removed');
|
|
49
|
+
}
|
|
50
|
+
const bf = new Set(b ? b.files : []);
|
|
51
|
+
const cf = new Set(c ? c.files : []);
|
|
52
|
+
nodes.push({
|
|
53
|
+
id: name, external, status: status(inBase, inCur),
|
|
54
|
+
filesBase: bf.size, filesCur: cf.size,
|
|
55
|
+
files: [...new Set([...bf, ...cf])].sort().map((f) => ({ path: f, status: status(bf.has(f), cf.has(f)) })),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- rule breaks ----
|
|
60
|
+
const baseIds = new Set(baseViol.map((v) => v.id));
|
|
61
|
+
const curIds = new Set(curViol.map((v) => v.id));
|
|
62
|
+
/** @type {import('./types.js').Violation[]} */
|
|
63
|
+
const violations = [
|
|
64
|
+
...curViol.map((v) => ({ ...v, status: baseIds.has(v.id) ? /** @type {const} */ ('existing') : /** @type {const} */ ('new') })),
|
|
65
|
+
...baseViol.filter((v) => !curIds.has(v.id)).map((v) => ({ ...v, status: /** @type {const} */ ('fixed') })),
|
|
66
|
+
];
|
|
67
|
+
const order = { new: 0, existing: 1, fixed: 2 };
|
|
68
|
+
violations.sort((a, b) => order[a.status] - order[b.status] || (a.severity === 'error' ? -1 : 1) - (b.severity === 'error' ? -1 : 1));
|
|
69
|
+
|
|
70
|
+
const count = (arr, st) => arr.filter((x) => x.status === st).length;
|
|
71
|
+
const summary = {
|
|
72
|
+
modules: { added: count(nodes.filter((n) => !n.external), 'added'), removed: count(nodes.filter((n) => !n.external), 'removed'), total: cur.modules.size },
|
|
73
|
+
deps: {
|
|
74
|
+
added: count(edges, 'added'), removed: count(edges, 'removed'),
|
|
75
|
+
changed: edges.filter((e) => e.status === 'same' && e.countBase !== e.countCur).length,
|
|
76
|
+
total: edges.filter((e) => e.status !== 'removed').length,
|
|
77
|
+
},
|
|
78
|
+
violations: {
|
|
79
|
+
newErrors: violations.filter((v) => v.status === 'new' && v.severity === 'error').length,
|
|
80
|
+
newWarnings: violations.filter((v) => v.status === 'new' && v.severity !== 'error').length,
|
|
81
|
+
existing: count(violations, 'existing'),
|
|
82
|
+
fixed: count(violations, 'fixed'),
|
|
83
|
+
},
|
|
84
|
+
files: { base: base.stats.files, cur: cur.stats.files },
|
|
85
|
+
};
|
|
86
|
+
return { nodes, edges, violations, summary };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function newEvidence(before, after) {
|
|
90
|
+
const k = (x) => `${x.file}>${x.target}`;
|
|
91
|
+
const seen = new Set(before.map(k));
|
|
92
|
+
return after.filter((x) => !seen.has(k(x)));
|
|
93
|
+
}
|
package/src/graph.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Turning files + imports into a module-level map ("which part of the app uses which").
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractImports, loadAliases, makeResolver, codeOf } from './scan.js';
|
|
4
|
+
|
|
5
|
+
const MAX_EVIDENCE = 60;
|
|
6
|
+
|
|
7
|
+
export function globToRegex(glob) {
|
|
8
|
+
let re = '';
|
|
9
|
+
for (let i = 0; i < glob.length; i++) {
|
|
10
|
+
const c = glob[i];
|
|
11
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
12
|
+
if (glob[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
|
|
13
|
+
} else if (c === '*') re += '[^/]*';
|
|
14
|
+
else if (c === '?') re += '[^/]';
|
|
15
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
}
|
|
17
|
+
return new RegExp(`^${re}$`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Match a module name against a rule pattern. `*` matches anything (including "/"). */
|
|
21
|
+
export function nameMatches(pattern, name) {
|
|
22
|
+
if (pattern === name) return true;
|
|
23
|
+
if (!pattern.includes('*')) return false;
|
|
24
|
+
const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
|
|
25
|
+
return re.test(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const WORKSPACE_DIRS = /^(apps|packages|libs|services|modules|projects|workspaces|sites|tools|plugins)$/;
|
|
29
|
+
|
|
30
|
+
const DEFAULT_EXCLUDE = ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/__mocks__/**',
|
|
31
|
+
'**/*.stories.*', '**/*.config.*'];
|
|
32
|
+
|
|
33
|
+
/** Decide which module each file belongs to. */
|
|
34
|
+
export function makeGrouper(cfg, files) {
|
|
35
|
+
const hasSrc = files.some((f) => f.startsWith('src/'));
|
|
36
|
+
const root = (cfg.root ?? (hasSrc ? 'src' : '')).replace(/^\.\/?/, '').replace(/\/$/, '');
|
|
37
|
+
const depth = cfg.depth ?? 1;
|
|
38
|
+
// zero config in a monorepo: apps/web and packages/ui are the parts, not "apps" and "packages"
|
|
39
|
+
const depthFor = (dirs) => (cfg.depth == null && !root && WORKSPACE_DIRS.test(dirs[0]) && dirs.length > 1 ? 2 : depth);
|
|
40
|
+
const exclude = [...DEFAULT_EXCLUDE, ...(cfg.exclude || [])].map(globToRegex);
|
|
41
|
+
|
|
42
|
+
const explicit = [];
|
|
43
|
+
const mods = cfg.modules || {};
|
|
44
|
+
for (const [name, pats] of Object.entries(mods)) {
|
|
45
|
+
for (const p of [].concat(pats)) {
|
|
46
|
+
if (/[*?]/.test(p)) { explicit.push({ name, re: globToRegex(p) }); continue; }
|
|
47
|
+
// a plain path means that exact file, or everything inside that folder
|
|
48
|
+
const clean = p.replace(/^\.\//, '').replace(/\/$/, '');
|
|
49
|
+
explicit.push({ name, re: globToRegex(clean) }, { name, re: globToRegex(`${clean}/**`) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
root,
|
|
55
|
+
moduleOf(file) {
|
|
56
|
+
if (exclude.some((re) => re.test(file))) return null;
|
|
57
|
+
for (const m of explicit) if (m.re.test(file)) return m.name;
|
|
58
|
+
if (cfg.modules && cfg.onlyListedModules) return null;
|
|
59
|
+
let rel = file;
|
|
60
|
+
if (root) {
|
|
61
|
+
if (!file.startsWith(`${root}/`)) return null; // e.g. top-level config files
|
|
62
|
+
rel = file.slice(root.length + 1);
|
|
63
|
+
}
|
|
64
|
+
const dirs = path.posix.dirname(rel).split('/').filter((s) => s && s !== '.');
|
|
65
|
+
return dirs.length ? dirs.slice(0, depthFor(dirs)).join('/') : '(root)';
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the module graph for one snapshot of the code.
|
|
72
|
+
* Returns { modules: Map<name,{files}>, edges: Map<key,{from,to,external,imports}>, stats }.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* @param {import('./types.js').Source} source
|
|
76
|
+
* @param {import('./types.js').Config} cfg
|
|
77
|
+
* @returns {import('./types.js').Graph}
|
|
78
|
+
*/
|
|
79
|
+
export function buildGraph(source, cfg) {
|
|
80
|
+
const fileSet = new Set(source.files);
|
|
81
|
+
const resolve = makeResolver(fileSet, loadAliases(source, cfg.aliases));
|
|
82
|
+
const grouper = makeGrouper(cfg, source.files);
|
|
83
|
+
|
|
84
|
+
const modules = new Map();
|
|
85
|
+
const edges = new Map();
|
|
86
|
+
const stats = { files: 0, imports: 0, unresolved: 0 };
|
|
87
|
+
const moduleOf = new Map();
|
|
88
|
+
const fileDeps = new Map(); // file -> Map<target file, first line>, for the per-file drill-down
|
|
89
|
+
|
|
90
|
+
for (const f of source.files) {
|
|
91
|
+
const m = grouper.moduleOf(f);
|
|
92
|
+
if (!m) continue;
|
|
93
|
+
moduleOf.set(f, m);
|
|
94
|
+
if (!modules.has(m)) modules.set(m, { files: [] });
|
|
95
|
+
modules.get(m).files.push(f);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [file, from] of moduleOf) {
|
|
99
|
+
const text = codeOf(file, source.read(file));
|
|
100
|
+
if (text == null) continue;
|
|
101
|
+
stats.files++;
|
|
102
|
+
const deps = new Map();
|
|
103
|
+
fileDeps.set(file, deps);
|
|
104
|
+
for (const { spec, line } of extractImports(text)) {
|
|
105
|
+
const r = resolve(file, spec);
|
|
106
|
+
let to;
|
|
107
|
+
let target;
|
|
108
|
+
if (r.kind === 'file') {
|
|
109
|
+
to = moduleOf.get(r.file);
|
|
110
|
+
target = r.file;
|
|
111
|
+
if (!to) continue; // imports something outside the analysed area (tests, configs)
|
|
112
|
+
if (r.file !== file && !deps.has(r.file)) deps.set(r.file, line);
|
|
113
|
+
} else if (r.kind === 'package') {
|
|
114
|
+
to = `npm:${r.pkg}`;
|
|
115
|
+
target = r.pkg;
|
|
116
|
+
} else {
|
|
117
|
+
if (r.kind === 'unresolved') stats.unresolved++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
stats.imports++;
|
|
121
|
+
if (to === from) continue;
|
|
122
|
+
const key = `${from}→${to}`;
|
|
123
|
+
if (!edges.has(key)) edges.set(key, { from, to, external: to.startsWith('npm:'), imports: [], count: 0 });
|
|
124
|
+
const e = edges.get(key);
|
|
125
|
+
e.count++;
|
|
126
|
+
if (e.imports.length < MAX_EVIDENCE) e.imports.push({ file, line, spec, target });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { modules, edges, stats, root: grouper.root, moduleOf, fileDeps };
|
|
130
|
+
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// A small, deterministic layered layout (Sugiyama-style), computed on base ∪ current so that
|
|
2
|
+
// switching between the Base / Current / Diff views never moves anything.
|
|
3
|
+
|
|
4
|
+
const NODE_H = 46;
|
|
5
|
+
const ROW_GAP = 86;
|
|
6
|
+
const COL_GAP = 36;
|
|
7
|
+
const DUMMY_W = 14;
|
|
8
|
+
|
|
9
|
+
export function nodeWidth(label) {
|
|
10
|
+
// icon (34px) + text (~7.3px per char at 13px Geist semibold) + right padding
|
|
11
|
+
return Math.max(120, Math.round(label.length * 7.3) + 56);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function layout(nodes, edges) {
|
|
15
|
+
const ids = nodes.map((n) => n.id).sort();
|
|
16
|
+
const metaLen = (n) => (n.external ? 11 : (n.filesBase !== n.filesCur ? `${n.filesBase} → ${n.filesCur} files` : `${n.filesCur || n.filesBase || 0} files`).length);
|
|
17
|
+
// box must fit the name (13px sans) and the file-count line (10.5px mono, ~6.4px per char)
|
|
18
|
+
const width = new Map(nodes.map((n) => [n.id, Math.max(nodeWidth((n.label || n.id).replace(/^npm:/, '')), n.filesCur === undefined ? 0 : Math.round(36 + metaLen(n) * 6.4 + 16))]));
|
|
19
|
+
|
|
20
|
+
// 1. Break loops: walk the graph depth-first; any arrow pointing back up the walk is flipped for layout.
|
|
21
|
+
const succ = new Map(ids.map((i) => [i, []]));
|
|
22
|
+
for (const e of edges) succ.get(e.from).push(e);
|
|
23
|
+
for (const list of succ.values()) list.sort((a, b) => (a.to < b.to ? -1 : 1));
|
|
24
|
+
const state = new Map();
|
|
25
|
+
const flipped = new Set();
|
|
26
|
+
const dfs = (v) => {
|
|
27
|
+
state.set(v, 1);
|
|
28
|
+
for (const e of succ.get(v)) {
|
|
29
|
+
const s = state.get(e.to);
|
|
30
|
+
if (s === 1) flipped.add(e.id);
|
|
31
|
+
else if (!s) dfs(e.to);
|
|
32
|
+
}
|
|
33
|
+
state.set(v, 2);
|
|
34
|
+
};
|
|
35
|
+
for (const v of ids) if (!state.get(v)) dfs(v);
|
|
36
|
+
const dag = edges.map((e) => (flipped.has(e.id) ? { id: e.id, u: e.to, v: e.from } : { id: e.id, u: e.from, v: e.to }));
|
|
37
|
+
|
|
38
|
+
// 2. Rows: users on top, things being used underneath (longest path).
|
|
39
|
+
const preds = new Map(ids.map((i) => [i, []]));
|
|
40
|
+
const outs = new Map(ids.map((i) => [i, []]));
|
|
41
|
+
for (const d of dag) { preds.get(d.v).push(d.u); outs.get(d.u).push(d.v); }
|
|
42
|
+
const indeg = new Map(ids.map((i) => [i, preds.get(i).length]));
|
|
43
|
+
const topo = [];
|
|
44
|
+
const queue = ids.filter((i) => indeg.get(i) === 0);
|
|
45
|
+
while (queue.length) {
|
|
46
|
+
queue.sort();
|
|
47
|
+
const v = queue.shift();
|
|
48
|
+
topo.push(v);
|
|
49
|
+
for (const w of outs.get(v)) { indeg.set(w, indeg.get(w) - 1); if (indeg.get(w) === 0) queue.push(w); }
|
|
50
|
+
}
|
|
51
|
+
const layer = new Map();
|
|
52
|
+
for (const v of topo) layer.set(v, Math.max(0, ...preds.get(v).map((u) => layer.get(u) + 1)));
|
|
53
|
+
// pull pure "users" down next to what they use, so arrows stay short
|
|
54
|
+
for (const v of [...topo].reverse()) {
|
|
55
|
+
if (preds.get(v).length === 0 && outs.get(v).length) layer.set(v, Math.min(...outs.get(v).map((w) => layer.get(w))) - 1);
|
|
56
|
+
}
|
|
57
|
+
const minL = Math.min(0, ...layer.values());
|
|
58
|
+
for (const v of ids) layer.set(v, layer.get(v) - minL);
|
|
59
|
+
|
|
60
|
+
// 3. Long arrows get invisible waypoints on each row they cross.
|
|
61
|
+
const allLayer = new Map(layer);
|
|
62
|
+
const chains = new Map(); // edge id -> [u, dummies..., v]
|
|
63
|
+
const segs = [];
|
|
64
|
+
for (const d of dag) {
|
|
65
|
+
const chain = [d.u];
|
|
66
|
+
for (let l = layer.get(d.u) + 1; l < layer.get(d.v); l++) {
|
|
67
|
+
const dummy = `\u0000${d.id}#${l}`;
|
|
68
|
+
allLayer.set(dummy, l);
|
|
69
|
+
width.set(dummy, DUMMY_W);
|
|
70
|
+
chain.push(dummy);
|
|
71
|
+
}
|
|
72
|
+
chain.push(d.v);
|
|
73
|
+
chains.set(d.id, chain);
|
|
74
|
+
for (let i = 0; i + 1 < chain.length; i++) segs.push([chain[i], chain[i + 1]]);
|
|
75
|
+
}
|
|
76
|
+
const up = new Map([...allLayer.keys()].map((k) => [k, []]));
|
|
77
|
+
const down = new Map([...allLayer.keys()].map((k) => [k, []]));
|
|
78
|
+
for (const [a, b] of segs) { down.get(a).push(b); up.get(b).push(a); }
|
|
79
|
+
|
|
80
|
+
// 4. Order within each row to reduce crossings (barycenter sweeps, keep the best).
|
|
81
|
+
const nLayers = Math.max(0, ...allLayer.values()) + 1;
|
|
82
|
+
let rows = Array.from({ length: nLayers }, () => []);
|
|
83
|
+
// initial order: breadth-first from the top, alphabetical tie-break
|
|
84
|
+
const placed = new Set();
|
|
85
|
+
const bfs = [...allLayer.keys()].filter((k) => up.get(k).length === 0).sort();
|
|
86
|
+
while (bfs.length) {
|
|
87
|
+
const v = bfs.shift();
|
|
88
|
+
if (placed.has(v)) continue;
|
|
89
|
+
placed.add(v);
|
|
90
|
+
rows[allLayer.get(v)].push(v);
|
|
91
|
+
for (const w of [...down.get(v)].sort()) if (!placed.has(w)) bfs.push(w);
|
|
92
|
+
}
|
|
93
|
+
for (const k of [...allLayer.keys()].sort()) if (!placed.has(k)) rows[allLayer.get(k)].push(k);
|
|
94
|
+
|
|
95
|
+
const crossings = (rs) => {
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (let l = 0; l + 1 < rs.length; l++) {
|
|
98
|
+
const pos = new Map(rs[l + 1].map((v, i) => [v, i]));
|
|
99
|
+
const list = [];
|
|
100
|
+
rs[l].forEach((v, i) => down.get(v).forEach((w) => list.push([i, pos.get(w)])));
|
|
101
|
+
for (let a = 0; a < list.length; a++) {
|
|
102
|
+
for (let b = a + 1; b < list.length; b++) {
|
|
103
|
+
if ((list[a][0] - list[b][0]) * (list[a][1] - list[b][1]) < 0) total++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return total;
|
|
108
|
+
};
|
|
109
|
+
const reorder = (rs, l, neighbours, ref) => {
|
|
110
|
+
const pos = new Map(rs[ref].map((v, i) => [v, i]));
|
|
111
|
+
const bc = new Map(rs[l].map((v, i) => {
|
|
112
|
+
const ns = neighbours.get(v).filter((w) => pos.has(w));
|
|
113
|
+
return [v, ns.length ? ns.reduce((s, w) => s + pos.get(w), 0) / ns.length : i];
|
|
114
|
+
}));
|
|
115
|
+
rs[l] = [...rs[l]].sort((a, b) => bc.get(a) - bc.get(b) || rs[l].indexOf(a) - rs[l].indexOf(b));
|
|
116
|
+
};
|
|
117
|
+
let best = rows.map((r) => [...r]);
|
|
118
|
+
let bestC = crossings(best);
|
|
119
|
+
for (let iter = 0; iter < 16 && bestC > 0; iter++) {
|
|
120
|
+
if (iter % 2 === 0) for (let l = 1; l < nLayers; l++) reorder(rows, l, up, l - 1);
|
|
121
|
+
else for (let l = nLayers - 2; l >= 0; l--) reorder(rows, l, down, l + 1);
|
|
122
|
+
const c = crossings(rows);
|
|
123
|
+
if (c < bestC) { bestC = c; best = rows.map((r) => [...r]); }
|
|
124
|
+
}
|
|
125
|
+
rows = best;
|
|
126
|
+
|
|
127
|
+
// 5. Horizontal positions: start packed, then nudge each box toward its neighbours.
|
|
128
|
+
const x = new Map();
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
let cur = 0;
|
|
131
|
+
for (const v of row) { x.set(v, cur + width.get(v) / 2); cur += width.get(v) + COL_GAP; }
|
|
132
|
+
const shift = (cur - COL_GAP) / 2;
|
|
133
|
+
for (const v of row) x.set(v, x.get(v) - shift);
|
|
134
|
+
}
|
|
135
|
+
const settle = (row, want) => {
|
|
136
|
+
const gap = (a, b) => (width.get(a) + width.get(b)) / 2 + (a.startsWith('\u0000') || b.startsWith('\u0000') ? 10 : COL_GAP);
|
|
137
|
+
const L = row.map((v) => want.get(v));
|
|
138
|
+
for (let i = 1; i < row.length; i++) L[i] = Math.max(L[i], L[i - 1] + gap(row[i - 1], row[i]));
|
|
139
|
+
const R = row.map((v) => want.get(v));
|
|
140
|
+
for (let i = row.length - 2; i >= 0; i--) R[i] = Math.min(R[i], R[i + 1] - gap(row[i], row[i + 1]));
|
|
141
|
+
row.forEach((v, i) => x.set(v, (L[i] + R[i]) / 2));
|
|
142
|
+
// averaging can re-introduce overlaps; one last left-to-right pass fixes them
|
|
143
|
+
for (let i = 1; i < row.length; i++) {
|
|
144
|
+
const min = x.get(row[i - 1]) + gap(row[i - 1], row[i]);
|
|
145
|
+
if (x.get(row[i]) < min) x.set(row[i], min);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
for (let iter = 0; iter < 10; iter++) {
|
|
149
|
+
const order = iter % 2 === 0 ? rows.map((_, i) => i) : rows.map((_, i) => rows.length - 1 - i);
|
|
150
|
+
for (const l of order) {
|
|
151
|
+
const nb = iter % 2 === 0 ? up : down;
|
|
152
|
+
const want = new Map(rows[l].map((v) => {
|
|
153
|
+
const ns = [...nb.get(v), ...(iter > 5 ? (nb === up ? down : up).get(v) : [])];
|
|
154
|
+
return [v, ns.length ? ns.reduce((s, w) => s + x.get(w), 0) / ns.length : x.get(v)];
|
|
155
|
+
}));
|
|
156
|
+
settle(rows[l], want);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 6. Coordinates and arrow waypoints.
|
|
161
|
+
const y = (l) => l * (NODE_H + ROW_GAP);
|
|
162
|
+
const minX = Math.min(...[...x.entries()].map(([v, cx]) => cx - width.get(v) / 2), 0);
|
|
163
|
+
const pos = {};
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
const w = width.get(id);
|
|
166
|
+
pos[id] = { x: x.get(id) - w / 2 - minX, y: y(layer.get(id)), w, h: NODE_H };
|
|
167
|
+
}
|
|
168
|
+
const pairs = new Set(edges.map((e) => `${e.from}→${e.to}`));
|
|
169
|
+
const routes = {};
|
|
170
|
+
for (const e of edges) {
|
|
171
|
+
let chain = chains.get(e.id);
|
|
172
|
+
const pts = chain.map((v, i) => {
|
|
173
|
+
const cx = x.get(v) - minX;
|
|
174
|
+
const cy = y(allLayer.get(v));
|
|
175
|
+
if (i === 0) return [cx, cy + NODE_H];
|
|
176
|
+
if (i === chain.length - 1) return [cx, cy];
|
|
177
|
+
return [cx, cy + NODE_H / 2];
|
|
178
|
+
});
|
|
179
|
+
if (flipped.has(e.id)) pts.reverse();
|
|
180
|
+
// two-way dependency between the same pair: offset so both arrows are visible
|
|
181
|
+
if (pairs.has(`${e.to}→${e.from}`)) {
|
|
182
|
+
const off = e.from < e.to ? -7 : 7;
|
|
183
|
+
pts.forEach((p) => { p[0] += off; });
|
|
184
|
+
}
|
|
185
|
+
routes[e.id] = pts.map(([a, b]) => [Math.round(a * 10) / 10, Math.round(b * 10) / 10]);
|
|
186
|
+
chain = null;
|
|
187
|
+
}
|
|
188
|
+
const W = Math.max(0, ...Object.values(pos).map((p) => p.x + p.w));
|
|
189
|
+
const H = Math.max(0, ...Object.values(pos).map((p) => p.y + p.h));
|
|
190
|
+
return { pos, routes, width: W, height: H, crossings: bestC };
|
|
191
|
+
}
|