@draht/tools 2026.7.7 → 2026.7.11

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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require("node:fs");
10
10
  const path = require("node:path");
11
- const { execSync } = require("node:child_process");
11
+ const { execSync, execFileSync } = require("node:child_process");
12
12
 
13
13
  const PLANNING_DIR = ".planning";
14
14
  const BANNER_WIDTH = 55;
@@ -85,6 +85,31 @@ function hasGit() {
85
85
  }
86
86
  }
87
87
 
88
+ // Whole-repo enforcement (WP6, defect 22): walk up from `startDir` to the nearest `.git`, then
89
+ // (if none found) the nearest package.json declaring `workspaces` or a pnpm-workspace.yaml.
90
+ // Falls back to startDir itself so single-package / non-git checkouts still work.
91
+ function findRepoRoot(startDir) {
92
+ let dir = path.resolve(startDir);
93
+ while (true) {
94
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
95
+ const parent = path.dirname(dir);
96
+ if (parent === dir) break;
97
+ dir = parent;
98
+ }
99
+ dir = path.resolve(startDir);
100
+ while (true) {
101
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
102
+ try {
103
+ const pj = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"));
104
+ if (pj && pj.workspaces) return dir;
105
+ } catch { /* empty */ }
106
+ const parent = path.dirname(dir);
107
+ if (parent === dir) break;
108
+ dir = parent;
109
+ }
110
+ return path.resolve(startDir);
111
+ }
112
+
88
113
  function gitCommit(message) {
89
114
  if (!hasGit()) return null;
90
115
  try {
@@ -186,11 +211,19 @@ commands.init = function () {
186
211
 
187
212
  // --- map-codebase ---
188
213
  commands["map-codebase"] = function (dir) {
189
- const cwd = dir || process.cwd();
190
- const outDir = planningPath("codebase");
214
+ // Whole-repo enforcement (WP6, defect 22): the graph output always lives at the repo root's
215
+ // .planning/codebase/ — previously this used planningPath() (= process.cwd()) while the
216
+ // narrative-doc scan below used `cwd` (= dir arg), so passing a `dir` silently wrote docs to
217
+ // the wrong .planning/ entirely. `dir` still scopes the narrative file-tree/domain-hint scan.
218
+ const repoRoot = findRepoRoot(process.cwd());
219
+ const cwd = dir ? path.resolve(dir) : process.cwd();
220
+ const outDir = path.join(repoRoot, PLANNING_DIR, "codebase");
191
221
  ensureDir(outDir);
192
222
 
193
223
  console.log(banner("MAPPING CODEBASE"));
224
+ if (dir && cwd !== repoRoot) {
225
+ console.log(`note: map-codebase always maps the whole repo graph (${repoRoot}); '${dir}' only scopes the narrative doc scan below.`);
226
+ }
194
227
 
195
228
  // Gather file tree
196
229
  let tree = "";
@@ -242,14 +275,14 @@ commands["map-codebase"] = function (dir) {
242
275
  // so `map-codebase` is a single command that produces both the prose docs and the graphify map.
243
276
  let kg = null;
244
277
  try {
245
- kg = visWriteOutputs(cwd);
278
+ kg = visWriteOutputs(repoRoot);
246
279
  } catch (err) {
247
280
  console.error(`\n⚠️ Knowledge graph build failed (run \`draht-tools map-graph\` to retry): ${err.message}`);
248
281
  }
249
282
 
250
283
  console.log(`\nCreated:\n ${outDir}/STACK.md\n ${outDir}/ARCHITECTURE.md\n ${outDir}/CONVENTIONS.md\n ${outDir}/CONCERNS.md\n ${outDir}/DOMAIN-HINTS.md`);
251
284
  if (kg) {
252
- console.log(` ${path.relative(cwd, kg.jsonPath)}\n ${path.relative(cwd, kg.htmlPath)}\n ${path.relative(cwd, kg.reportPath)}`);
285
+ console.log(` ${kg.jsonPath}\n ${kg.htmlPath}\n ${kg.reportPath}`);
253
286
  console.log(`\nIndexed ${kg.map.stats.files} modules · ${kg.map.clusters.length} clusters · view it live: draht-tools map-serve`);
254
287
  }
255
288
  console.log("\n→ Review and fill in the TODOs, then run: draht-tools commit-docs \"map existing codebase\"");
@@ -290,7 +323,7 @@ commands["create-domain-model"] = function () {
290
323
 
291
324
  // --- init-state ---
292
325
  commands["init-state"] = function () {
293
- const tmpl = `# State\n\n## Current Phase: 1\n## Status: initialized\n\n## Decisions\n(none yet)\n\n## Blockers\nNone.\n\n## Quick Tasks Completed\n(none)\n\n## Last Activity: ${timestamp()}\n`;
326
+ const tmpl = `# State\n\n## Current Phase: 1\n## Status: initialized\n\n## Decisions\n(none yet)\n\n## Lessons\n(none yet — one dated bullet per lesson: what was tried, why it failed or worked, what to do instead)\n\n## Blockers\nNone.\n\n## Quick Tasks Completed\n(none)\n\n## Last Activity: ${timestamp()}\n`;
294
327
  writeMd(planningPath("STATE.md"), tmpl);
295
328
  writeJson(planningPath("config.json"), getConfig());
296
329
  console.log(`Created: ${PLANNING_DIR}/STATE.md`);
@@ -1225,28 +1258,55 @@ function visLangFor(file) {
1225
1258
  return VIS_LANG_BY_EXT[ext] || "other";
1226
1259
  }
1227
1260
 
1261
+ // Dot-directories that are still worth walking despite the leading "." — extend deliberately.
1262
+ // Everything else starting with "." is skipped by default (defect 19: the previous check here was
1263
+ // dead code — `if (ignore.has(e.name)) continue` duplicated the line right above it — so arbitrary
1264
+ // hidden directories like `.dev`, `.github`, `.changeset` were silently walked into the map).
1265
+ const VIS_HIDDEN_DIR_ALLOWLIST = new Set([]);
1266
+
1267
+ // Returns { files, truncated }. `files` is sorted for cross-machine determinism (defect 24).
1228
1268
  function visWalk(root, options = {}) {
1229
1269
  const ignore = options.ignore || VIS_DEFAULT_IGNORES;
1230
1270
  const maxFiles = options.maxFiles || 5000;
1231
1271
  const files = [];
1232
1272
  const stack = [root];
1273
+ let truncated = false;
1233
1274
  while (stack.length && files.length < maxFiles) {
1234
1275
  const dir = stack.pop();
1235
1276
  let entries;
1236
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
1237
- catch { continue; }
1277
+ try {
1278
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1279
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1280
+ } catch { continue; }
1238
1281
  for (const e of entries) {
1239
1282
  if (ignore.has(e.name)) continue;
1240
- if (e.name.startsWith(".") && !["..", "."].includes(e.name)) {
1241
- if (ignore.has(e.name)) continue;
1242
- }
1283
+ if (e.isDirectory() && e.name.startsWith(".") && !VIS_HIDDEN_DIR_ALLOWLIST.has(e.name)) continue;
1243
1284
  const full = path.join(dir, e.name);
1244
1285
  if (e.isDirectory()) stack.push(full);
1245
1286
  else if (e.isFile()) files.push(full);
1246
- if (files.length >= maxFiles) break;
1287
+ if (files.length >= maxFiles) { truncated = true; break; }
1247
1288
  }
1248
1289
  }
1249
- return files;
1290
+ if (truncated) {
1291
+ console.error(`⚠️ codebase map hit the ${maxFiles}-file cap — output is truncated. Narrow the scan or extend VIS_DEFAULT_IGNORES.`);
1292
+ }
1293
+ files.sort();
1294
+ return { files, truncated };
1295
+ }
1296
+
1297
+ // Defect 20: respect .gitignore. When `root` is inside a git repo, the tracked + untracked-but-
1298
+ // not-ignored file list from `git ls-files` is ground truth for "should this file be in the map".
1299
+ // Returns null (silent fallback to the static ignore set) when git is unavailable or errors.
1300
+ function visGitFileFilter(root) {
1301
+ try {
1302
+ const out = execFileSync(
1303
+ "git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
1304
+ { cwd: root, encoding: "utf-8", maxBuffer: 1024 * 1024 * 64 }
1305
+ );
1306
+ return new Set(out.split("\0").filter(Boolean).map((p) => p.split(path.sep).join("/")));
1307
+ } catch {
1308
+ return null;
1309
+ }
1250
1310
  }
1251
1311
 
1252
1312
  // Parse JS/TS imports into structured entries: { specifier, names: [{local, imported}], default, namespace }
@@ -1597,7 +1657,10 @@ function visExpandWorkspacePattern(root, pattern) {
1597
1657
  const [next, ...rest] = remaining;
1598
1658
  if (next === "*") {
1599
1659
  let entries;
1600
- try { entries = fs.readdirSync(currentPath, { withFileTypes: true }); } catch { return; }
1660
+ try {
1661
+ entries = fs.readdirSync(currentPath, { withFileTypes: true })
1662
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); // defect 24: deterministic order
1663
+ } catch { return; }
1601
1664
  for (const e of entries) {
1602
1665
  if (!e.isDirectory()) continue;
1603
1666
  if (VIS_DEFAULT_IGNORES.has(e.name)) continue;
@@ -1608,7 +1671,10 @@ function visExpandWorkspacePattern(root, pattern) {
1608
1671
  // Match this depth too (zero or more directories)
1609
1672
  visit(currentPath, rest);
1610
1673
  let entries;
1611
- try { entries = fs.readdirSync(currentPath, { withFileTypes: true }); } catch { return; }
1674
+ try {
1675
+ entries = fs.readdirSync(currentPath, { withFileTypes: true })
1676
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); // defect 24: deterministic order
1677
+ } catch { return; }
1612
1678
  for (const e of entries) {
1613
1679
  if (!e.isDirectory()) continue;
1614
1680
  if (VIS_DEFAULT_IGNORES.has(e.name)) continue;
@@ -1619,7 +1685,10 @@ function visExpandWorkspacePattern(root, pattern) {
1619
1685
  // Partial-wildcard segment ("foo-*", "*.bar") — match by regex
1620
1686
  const re = new RegExp("^" + next.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
1621
1687
  let entries;
1622
- try { entries = fs.readdirSync(currentPath, { withFileTypes: true }); } catch { return; }
1688
+ try {
1689
+ entries = fs.readdirSync(currentPath, { withFileTypes: true })
1690
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); // defect 24: deterministic order
1691
+ } catch { return; }
1623
1692
  for (const e of entries) {
1624
1693
  if (!e.isDirectory()) continue;
1625
1694
  if (VIS_DEFAULT_IGNORES.has(e.name)) continue;
@@ -1772,57 +1841,184 @@ function visBuildSymbols(exportsArr, content, language) {
1772
1841
  return syms;
1773
1842
  }
1774
1843
 
1775
- // Deterministic structural clusters via async label propagation over the undirected import graph.
1776
- function visComputeClusters(modules, edges, moduleByRel, containerOf) {
1777
- const adj = new Map();
1778
- const link = (a, b) => { if (a === b) return; if (!adj.has(a)) adj.set(a, new Set()); adj.get(a).add(b); };
1779
- for (const e of edges) {
1780
- if (e.kind !== "import") continue;
1781
- if (!moduleByRel.has(e.from) || !moduleByRel.has(e.to)) continue;
1782
- link(e.from, e.to); link(e.to, e.from);
1844
+ // WP3 — deterministic structural clusters via directory-seeded, hub-suppressed label propagation
1845
+ // over the undirected import graph (modules[] is code-only after WP2, so this only ever clusters
1846
+ // real code — no CHANGELOG/README/lockfile noise).
1847
+ //
1848
+ // Fixes defect 17 (monster-community guard fired at 53% and discarded 45 real communities in one
1849
+ // shot by falling back to one-cluster-per-package) and defect 18 (cluster ids were an arbitrary
1850
+ // member's file path, e.g. `cluster:packages/ai/CHANGELOG.md`).
1851
+ const CLUSTER_SEED_DEPTH = 4; // initial seed: first N dir segments of the module's directory
1852
+ const CLUSTER_MAX_SHARE = 0.25; // communities above this share of all modules get recursively split
1853
+ const CLUSTER_MAX_RECURSION = 5;
1854
+ const CLUSTER_BARREL_RE = /(^|\/)index\.(ts|tsx|js|mjs|cjs)$/;
1855
+
1856
+ // Seed = first `depth` directory segments of the module's own directory (never the filename).
1857
+ // Deep files (e.g. packages/ai/src/providers/x.ts, depth 4) seed to their real subdirectory;
1858
+ // shallow files (e.g. packages/ai/index.ts) seed to the package dir itself.
1859
+ function visClusterSeed(id, depth) {
1860
+ const dirname = id.includes("/") ? id.slice(0, id.lastIndexOf("/")) : "";
1861
+ if (!dirname) return null; // root-level file — caller falls back to its package/dir container
1862
+ return dirname.split("/").filter(Boolean).slice(0, depth).join("/");
1863
+ }
1864
+
1865
+ function visLongestCommonDirPrefix(memberIds) {
1866
+ let common = null;
1867
+ for (const id of memberIds) {
1868
+ const dirname = id.includes("/") ? id.slice(0, id.lastIndexOf("/")) : "";
1869
+ const segs = dirname.split("/").filter(Boolean);
1870
+ if (common === null) { common = segs; continue; }
1871
+ let j = 0;
1872
+ while (j < common.length && j < segs.length && common[j] === segs[j]) j++;
1873
+ common = common.slice(0, j);
1874
+ if (common.length === 0) break;
1783
1875
  }
1784
- const ids = modules.map((m) => m.id).sort();
1785
- const label = new Map(ids.map((id) => [id, id]));
1876
+ return (common || []).join("/");
1877
+ }
1878
+
1879
+ // Sequential (not synchronous) label propagation: ids are visited in sorted order every pass, and
1880
+ // ties break lexicographically, so the result is 100% reproducible across machines/runs. `hubSet`
1881
+ // members still receive labels from their non-hub neighbours but never contribute their OWN label
1882
+ // as a vote to anyone — that's what stops a single god-file/barrel from label-flooding its entire
1883
+ // neighbourhood into one mega-cluster.
1884
+ function visPropagateLabels(ids, adj, hubSet, seedOf) {
1885
+ const sortedIds = ids.slice().sort();
1886
+ const label = new Map(sortedIds.map((id) => [id, seedOf(id)]));
1786
1887
  for (let pass = 0; pass < 10; pass++) {
1787
1888
  let changed = false;
1788
- for (const id of ids) { // async: later nodes this pass see earlier updates
1889
+ for (const id of sortedIds) {
1789
1890
  const nbrs = adj.get(id);
1790
1891
  if (!nbrs || nbrs.size === 0) continue;
1791
1892
  const freq = new Map();
1792
- for (const n of nbrs) { const l = label.get(n); freq.set(l, (freq.get(l) || 0) + 1); }
1893
+ for (const n of nbrs) {
1894
+ if (!label.has(n)) continue; // neighbour outside this subgraph (recursive split)
1895
+ if (hubSet.has(n)) continue; // hubs receive labels but never broadcast their own
1896
+ const l = label.get(n);
1897
+ freq.set(l, (freq.get(l) || 0) + 1);
1898
+ }
1899
+ if (freq.size === 0) continue;
1793
1900
  let best = label.get(id), bestC = -1;
1794
1901
  for (const [l, c] of freq) { if (c > bestC || (c === bestC && l < best)) { best = l; bestC = c; } }
1795
1902
  if (best !== label.get(id)) { label.set(id, best); changed = true; }
1796
1903
  }
1797
1904
  if (!changed) break;
1798
1905
  }
1906
+ return label;
1907
+ }
1908
+
1909
+ function visComputeClusters(modules, edges, moduleByRel, containerOf) {
1910
+ const adj = new Map();
1911
+ const link = (a, b) => { if (a === b) return; if (!adj.has(a)) adj.set(a, new Set()); adj.get(a).add(b); };
1912
+ for (const e of edges) {
1913
+ if (e.kind !== "import") continue;
1914
+ if (!moduleByRel.has(e.from) || !moduleByRel.has(e.to)) continue;
1915
+ link(e.from, e.to); link(e.to, e.from);
1916
+ }
1917
+ const ids = modules.map((m) => m.id).sort();
1918
+ const total = ids.length || 1;
1919
+
1920
+ // Hub set: import degree (in the undirected adjacency above) strictly above the 95th
1921
+ // percentile, plus barrel files — they still join a cluster, they just don't drag their whole
1922
+ // neighbourhood into it.
1923
+ const degrees = ids.map((id) => (adj.get(id) ? adj.get(id).size : 0)).sort((a, b) => a - b);
1924
+ const pIdx = Math.min(degrees.length - 1, Math.floor(0.95 * degrees.length));
1925
+ const hubThreshold = degrees.length ? degrees[pIdx] : 0;
1926
+ const hubSet = new Set();
1927
+ for (const id of ids) {
1928
+ const deg = adj.has(id) ? adj.get(id).size : 0;
1929
+ if (deg > hubThreshold || CLUSTER_BARREL_RE.test(id)) hubSet.add(id);
1930
+ }
1931
+
1932
+ const seedAtDepth = (depth) => (id) => visClusterSeed(id, depth) || containerOf(moduleByRel.get(id));
1933
+ const label = visPropagateLabels(ids, adj, hubSet, seedAtDepth(CLUSTER_SEED_DEPTH));
1934
+
1799
1935
  const byLabel = new Map();
1800
1936
  for (const id of ids) {
1801
1937
  const m = moduleByRel.get(id);
1802
1938
  const hasEdges = adj.has(id) && adj.get(id).size > 0;
1803
- const key = hasEdges ? label.get(id) : containerOf(m); // unlinked modules → package cluster
1939
+ const key = hasEdges ? label.get(id) : containerOf(m); // package fallback ONLY for edge-less modules
1804
1940
  if (!byLabel.has(key)) byLabel.set(key, []);
1805
1941
  byLabel.get(key).push(id);
1806
1942
  }
1807
- // Monster-community guard: if one cluster swallows >50% of non-test modules, fall back to packages.
1808
- const nonTest = modules.filter((m) => !m.isTest).length || 1;
1809
- const maxSize = Math.max(0, ...[...byLabel.values()].map((v) => v.length));
1810
- let groupsMap = byLabel;
1811
- if (maxSize > nonTest * 0.5) {
1812
- groupsMap = new Map();
1813
- for (const m of modules) { const k = containerOf(m); if (!groupsMap.has(k)) groupsMap.set(k, []); groupsMap.get(k).push(m.id); }
1943
+
1944
+ // Recursively split any community that swallows more than CLUSTER_MAX_SHARE of all modules,
1945
+ // re-seeding with a deeper directory prefix each level — NOT a wholesale fallback to packages
1946
+ // (defect 17). Terminates because each recursion either shrinks the group or (if propagation
1947
+ // truly makes no progress) forces a per-module split, which trivially satisfies the size cap.
1948
+ function splitOversized(memberIds, depth, recursion) {
1949
+ if (memberIds.length <= total * CLUSTER_MAX_SHARE || recursion >= CLUSTER_MAX_RECURSION) {
1950
+ return [memberIds.slice().sort()];
1951
+ }
1952
+ const memberSet = new Set(memberIds);
1953
+ const subAdj = new Map();
1954
+ for (const id of memberIds) {
1955
+ const nbrs = adj.get(id);
1956
+ if (!nbrs) continue;
1957
+ const filtered = new Set();
1958
+ for (const n of nbrs) if (memberSet.has(n)) filtered.add(n);
1959
+ if (filtered.size) subAdj.set(id, filtered);
1960
+ }
1961
+ const subLabel = visPropagateLabels(memberIds, subAdj, hubSet, seedAtDepth(depth));
1962
+ let groups = new Map();
1963
+ for (const id of memberIds) {
1964
+ const l = subLabel.get(id);
1965
+ if (!groups.has(l)) groups.set(l, []);
1966
+ groups.get(l).push(id);
1967
+ }
1968
+ if (groups.size <= 1) {
1969
+ // Deeper seeding made no progress (e.g. every member truly shares one directory) —
1970
+ // force a per-module split so recursion is guaranteed to terminate below the cap.
1971
+ groups = new Map(memberIds.map((id) => [id, [id]]));
1972
+ }
1973
+ const out = [];
1974
+ for (const grp of groups.values()) out.push(...splitOversized(grp, depth + 2, recursion + 1));
1975
+ return out;
1814
1976
  }
1977
+
1978
+ const finalGroups = [];
1979
+ for (const members of byLabel.values()) finalGroups.push(...splitOversized(members, CLUSTER_SEED_DEPTH + 2, 0));
1980
+
1981
+ // Cluster identity = longest common directory prefix of its members (never a filename —
1982
+ // defect 18). When the prefix is empty/ambiguous (e.g. a forced per-module split shares no
1983
+ // directory), fall back to the dominant package + an ordinal to keep ids unique.
1984
+ const usedIds = new Set();
1985
+ const fallbackOrdinal = new Map();
1815
1986
  const clusters = [];
1816
1987
  const clusterOf = new Map();
1817
- for (const members of groupsMap.values()) {
1818
- const sorted = members.slice().sort();
1819
- const cid = "cluster:" + sorted[0];
1988
+ const sortedGroups = finalGroups
1989
+ .map((members) => members.slice().sort())
1990
+ .sort((a, b) => b.length - a.length || (a[0] < b[0] ? -1 : 1));
1991
+ for (const sorted of sortedGroups) {
1820
1992
  const pkgCount = {}, layerCount = {};
1821
- for (const id of sorted) { const m = moduleByRel.get(id); const p = m.package || containerOf(m).replace(/^dir:/, ""); pkgCount[p] = (pkgCount[p] || 0) + 1; layerCount[m.layer] = (layerCount[m.layer] || 0) + 1; }
1993
+ for (const id of sorted) {
1994
+ const m = moduleByRel.get(id);
1995
+ const p = m.package || containerOf(m).replace(/^(pkg|dir):/, "");
1996
+ pkgCount[p] = (pkgCount[p] || 0) + 1;
1997
+ layerCount[m.layer] = (layerCount[m.layer] || 0) + 1;
1998
+ }
1822
1999
  const dominantPackage = Object.keys(pkgCount).sort((a, b) => pkgCount[b] - pkgCount[a] || (a < b ? -1 : 1))[0] || null;
1823
2000
  const dominantLayer = Object.keys(layerCount).sort((a, b) => layerCount[b] - layerCount[a] || (a < b ? -1 : 1))[0] || "support";
1824
2001
  const packages = Object.keys(pkgCount).sort();
1825
- const clabel = packages.length === 1 ? String(dominantPackage).replace(/^@[^/]+\//, "") : (sorted[0].split("/")[0] + " · " + dominantLayer);
2002
+
2003
+ const prefix = visLongestCommonDirPrefix(sorted);
2004
+ let cid, clabel;
2005
+ if (prefix) {
2006
+ cid = "cluster:" + prefix;
2007
+ clabel = prefix.split("/").slice(-2).join("/");
2008
+ } else {
2009
+ const base = "cluster:" + (dominantPackage || "root");
2010
+ const n = (fallbackOrdinal.get(base) || 0) + 1;
2011
+ fallbackOrdinal.set(base, n);
2012
+ cid = n === 1 ? base : base + ":" + n;
2013
+ clabel = (dominantPackage ? String(dominantPackage).replace(/^@[^/]+\//, "") : "root") + (packages.length > 1 ? " · " + dominantLayer : "");
2014
+ }
2015
+ if (usedIds.has(cid)) { // guarantee uniqueness even if two prefixes collide
2016
+ let n = 2;
2017
+ while (usedIds.has(cid + ":" + n)) n++;
2018
+ cid = cid + ":" + n;
2019
+ }
2020
+ usedIds.add(cid);
2021
+
1826
2022
  for (const id of sorted) clusterOf.set(id, cid);
1827
2023
  clusters.push({ id: cid, label: clabel, size: sorted.length, members: sorted, dominantPackage, dominantLayer, packages });
1828
2024
  }
@@ -1868,7 +2064,14 @@ function visComputeSurprising(edges, callEdges, moduleByRel, clusterOf, groupOfC
1868
2064
 
1869
2065
  function visBuildMap(root) {
1870
2066
  const startedAt = Date.now();
1871
- const files = visWalk(root);
2067
+ const walked = visWalk(root);
2068
+ // Defect 20: filter the walk down to git-tracked + untracked-not-ignored files when available,
2069
+ // so .gitignore is respected everywhere the static VIS_DEFAULT_IGNORES set doesn't already cover.
2070
+ const gitFiles = visGitFileFilter(root);
2071
+ let files = gitFiles
2072
+ ? walked.files.filter((abs) => gitFiles.has(path.relative(root, abs).split(path.sep).join("/")))
2073
+ : walked.files;
2074
+ files = files.slice().sort();
1872
2075
  const pkgs = visScanPackages(root);
1873
2076
  const planning = visReadPlanning(root);
1874
2077
 
@@ -1937,8 +2140,8 @@ function visBuildMap(root) {
1937
2140
  if (size < 1024 * 1024 && lang !== "other") {
1938
2141
  content = fs.readFileSync(abs, "utf-8");
1939
2142
  loc = content.split("\n").length;
1940
- totalLoc.value += loc;
1941
2143
  if (VIS_CODE_LANGS.has(lang)) {
2144
+ totalLoc.value += loc; // WP2: LOC rollup tracks code modules only, matching stats.files
1942
2145
  // Use raw content for export/JSDoc extraction (need comments) and stripped for sink/import detection
1943
2146
  const stripped = content
1944
2147
  .replace(/\/\*[\s\S]*?\*\//g, "")
@@ -1950,11 +2153,19 @@ function visBuildMap(root) {
1950
2153
  routes = visDetectRoutes(stripped);
1951
2154
  symbols = visBuildSymbols(exports, content, lang); // symbol-level nodes (from untruncated exports + decls)
1952
2155
  }
1953
- // Inline rationale works for any commented language (incl. markdown), not just code langs.
2156
+ // Inline rationale works for any commented language (incl. markdown), not just code langs
2157
+ // — kept for EVERY file (WP2 noise policy only excludes non-code files from `modules`,
2158
+ // not from rationale scanning, so NOTE/WHY/HACK notes in docs still surface).
1954
2159
  for (const r of visExtractRationale(content, lang, rel)) rationaleAll.push(r);
1955
2160
  }
1956
2161
  } catch { /* empty */ }
1957
2162
 
2163
+ // WP2 noise policy (schema v5): only code-language files become graph modules. Markdown/
2164
+ // JSON/YAML/etc. are still counted in `langCounts` (→ stats.languages) above and rolled up
2165
+ // into `assets` below, but they'd otherwise dilute the import graph/clusters/hotspots with
2166
+ // docs/config noise instead of real code structure.
2167
+ if (!VIS_CODE_LANGS.has(lang)) continue;
2168
+
1958
2169
  const isTest = visIsTest(rel);
1959
2170
  const pkg = pkgs.find((p) => p.path !== "." && rel.startsWith(p.path + "/"))?.name
1960
2171
  || (pkgs[0]?.path === "." ? pkgs[0].name : null);
@@ -1991,6 +2202,16 @@ function visBuildMap(root) {
1991
2202
  perFile.set(rel, { parsedImports, content, stripped: content });
1992
2203
  }
1993
2204
 
2205
+ // Assets rollup (WP2): files the scan saw but excluded from the module graph as noise (docs,
2206
+ // config, lockfiles, images, …) — derived straight from langCounts so it never drifts from
2207
+ // `stats.languages`.
2208
+ const assets = { total: 0, byLanguage: {} };
2209
+ for (const [lang, count] of Object.entries(langCounts)) {
2210
+ if (VIS_CODE_LANGS.has(lang)) continue;
2211
+ assets.byLanguage[lang] = count;
2212
+ assets.total += count;
2213
+ }
2214
+
1994
2215
  // Resolve local imports → edges with symbol-level dataflow
1995
2216
  const tsLikeExts = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
1996
2217
  const tsxIndexExts = tsLikeExts;
@@ -2664,14 +2885,22 @@ function visBuildMap(root) {
2664
2885
  const layerCounts = {};
2665
2886
  for (const m of modules) layerCounts[m.layer] = (layerCounts[m.layer] || 0) + 1;
2666
2887
 
2667
- // Symbol index for agents (the searchable identifier glossary)
2888
+ // Symbol index for agents (the searchable identifier glossary). Defect 25: rank exporting
2889
+ // modules by import in-degree (most-depended-on first) BEFORE cutting, so a hard cap doesn't
2890
+ // arbitrarily amputate whichever modules happened to sort last by file-walk order.
2891
+ const SYMBOL_INDEX_CAP = 5000;
2892
+ let symbolIndexTruncated = false;
2893
+ const exportingModulesRanked = modules
2894
+ .filter((m) => m.exports && m.exports.length > 0)
2895
+ .slice()
2896
+ .sort((a, b) => (inDeg.get(b.id) || 0) - (inDeg.get(a.id) || 0) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
2668
2897
  const symbolIndex = [];
2669
- for (const m of modules) {
2898
+ for (const m of exportingModulesRanked) {
2899
+ if (symbolIndex.length >= SYMBOL_INDEX_CAP) { symbolIndexTruncated = true; break; }
2670
2900
  for (const e of m.exports) {
2901
+ if (symbolIndex.length >= SYMBOL_INDEX_CAP) { symbolIndexTruncated = true; break; }
2671
2902
  symbolIndex.push({ name: e.name, kind: e.kind, line: e.line, file: m.path, package: m.package, exported: true });
2672
- if (symbolIndex.length > 1500) break;
2673
2903
  }
2674
- if (symbolIndex.length > 1500) break;
2675
2904
  }
2676
2905
 
2677
2906
  const tests = { total: modules.filter((m) => m.isTest).length, byContainer: {} };
@@ -2707,11 +2936,13 @@ function visBuildMap(root) {
2707
2936
  .slice(0, 600);
2708
2937
 
2709
2938
  return {
2710
- schemaVersion: 4,
2939
+ schemaVersion: 5,
2711
2940
  generatedAt: new Date().toISOString(),
2712
2941
  buildMs: Date.now() - startedAt,
2713
2942
  root: path.basename(root),
2714
2943
  stats: {
2944
+ // files = code modules only (schema v5 noise policy, WP2). Non-code file counts live in
2945
+ // `languages` (unchanged: every scanned file) and the `assets` rollup below.
2715
2946
  files: modules.length,
2716
2947
  totalLoc: totalLoc.value,
2717
2948
  languages: langCounts,
@@ -2724,7 +2955,9 @@ function visBuildMap(root) {
2724
2955
  entryPoints: entryPoints.length,
2725
2956
  sinkModules: sinkNodes.length,
2726
2957
  layers: layerCounts,
2958
+ truncated: !!walked.truncated, // defect 23: maxFiles cap was hit during the walk
2727
2959
  },
2960
+ assets,
2728
2961
  packages: pkgs,
2729
2962
  groups,
2730
2963
  containers,
@@ -2739,6 +2972,7 @@ function visBuildMap(root) {
2739
2972
  lanes,
2740
2973
  boxes,
2741
2974
  symbolIndex,
2975
+ symbolIndexTruncated,
2742
2976
  hotspots,
2743
2977
  clusters,
2744
2978
  surprisingConnections,
@@ -2753,6 +2987,7 @@ function visBuildMap(root) {
2753
2987
  agentHints: {
2754
2988
  description: "Architecture map of this codebase. Read this BEFORE walking the file tree.",
2755
2989
  howToUse: [
2990
+ "Schema v5 noise policy: `modules`/`edges`/`clusters`/`hotspots` only cover CODE files (ts/js/py/go/rust/java/kotlin/swift/ruby/php/csharp/c/cpp/shell) — docs/config/lockfiles never become graph nodes. `stats.languages` still counts EVERY scanned file (incl. markdown/json/yaml), and `assets.byLanguage`/`assets.total` rolls up the excluded ones so you can see they were seen, just not graphed. `stats.files` = code module count.",
2756
2991
  "`groups` partitions packages into functional containers (Frontend, CLI & Runtime, Core, Domain Services, Workflows & Infra). Each group's `members` array lists `pkg:NAME` ids. Overridable via `.planning/codebase/GROUPS.json`.",
2757
2992
  "`containers[*].groupId` links a package to its functional group; `containers[*].topFiles` is the 3-5 most relevant source files in the package (with a `reason` tag like `CLI entry` / `most depended-on` / `HTTP handler`).",
2758
2993
  "`containerEdges[*].label` is the relationship verb between two packages (Calls / Uses / Sends Events / Renders / Updates / Reads State / Configures / Analyzes / Executes Actions / Persists). `containerEdges[*].symbolSamples` shows the top imported symbol names.",
@@ -2770,20 +3005,29 @@ function visBuildMap(root) {
2770
3005
  "`surprisingConnections` flags import edges that bridge distant clusters, cross functional groups, or violate layer direction — review these.",
2771
3006
  "`rationaleIndex` collects inline NOTE/WHY/HACK/TODO/FIXME/SECURITY notes ({file,line,tag,text}).",
2772
3007
  "`edges[*].confidence` / `callEdges[*].confidence` ∈ EXTRACTED (literal in source) / INFERRED (regex call heuristic) / AMBIGUOUS (member-call, imported symbol uncertain).",
3008
+ "`stats.truncated` is true if the scan hit the file cap (rare, huge repos only). `symbolIndexTruncated` is true if `symbolIndex` was cut — when it is, the surviving entries are ranked by import in-degree so the most-depended-on modules' symbols are kept.",
2773
3009
  "Query the map with `draht-tools graph-context|graph-impact|graph-query|graph-callers|graph-callees|graph-path|graph-hotspots|graph-clusters` instead of reading this whole file or grepping.",
2774
3010
  ],
2775
3011
  },
2776
3012
  };
2777
3013
  }
2778
3014
 
2779
- function visRenderHtml(jsonPath) {
3015
+ function visRenderHtml(jsonPath, map) {
2780
3016
  const jsonName = path.basename(jsonPath);
3017
+ // The committed MAP.html must be byte-identical for the same graph data (determinism gate) —
3018
+ // zero the volatile generatedAt/buildMs fields in the EMBEDDED copy so wall-clock never leaks
3019
+ // into the artifact. The live map.generatedAt (for the on-disk MAP.json) is untouched.
3020
+ const embedded = Object.assign({}, map, { generatedAt: "", buildMs: 0 });
3021
+ // Escape '<' so a literal '</script>' inside any string value can't prematurely close the
3022
+ // embedded JSON <script> tag.
3023
+ const embeddedJson = JSON.stringify(embedded).replace(/</g, "\\u003c");
2781
3024
  return `<!doctype html>
2782
3025
  <html lang="en">
2783
3026
  <head>
2784
3027
  <meta charset="utf-8" />
2785
3028
  <title>Draht ▸ Architecture & Flows</title>
2786
3029
  <meta name="viewport" content="width=device-width,initial-scale=1" />
3030
+ <link rel="icon" href="data:," />
2787
3031
  <style>
2788
3032
  :root {
2789
3033
  --bg:#0b0d10; --bg-soft:#11151a; --panel:#151b22; --text:#e6edf3;
@@ -2824,25 +3068,44 @@ aside.right { border-right:none; border-left:1px solid var(--border); }
2824
3068
  .dot { width:9px; height:9px; border-radius:50%; flex:0 0 9px; }
2825
3069
 
2826
3070
  #viz { position:relative; overflow:hidden; background:radial-gradient(circle at 30% 20%, #0f141a 0%, #0b0d10 70%); }
2827
- svg { display:block; user-select:none; }
2828
- .controls { position:absolute; top:10px; right:10px; display:flex; gap:6px; z-index: 2; }
3071
+ svg, canvas { display:block; user-select:none; position:absolute; inset:0; width:100%; height:100%; }
3072
+ #error-banner { display:none; position:absolute; top:0; left:0; right:0; z-index:50;
3073
+ background:#3a1a1a; color:#ffb4b4; padding:8px 14px; font-size:12px; border-bottom:1px solid #6b2b2b; }
3074
+ .controls { position:absolute; top:10px; right:10px; display:flex; gap:6px; z-index: 2; flex-wrap:wrap; justify-content:flex-end; max-width:60%; }
2829
3075
  .controls button { background:var(--panel); color:var(--text); border:1px solid var(--border);
2830
3076
  border-radius:6px; padding:4px 10px; cursor:pointer; font-size:11px; }
2831
3077
  .controls button:hover { border-color:var(--pres); }
3078
+ /* B4 fix: graph-controls and the legend used to both be absolutely positioned at fixed
3079
+ top offsets (10px / 56px), so once the checkbox/button row wrapped to 2+ lines the legend's
3080
+ top rows sat underneath it. #overlay-left stacks them in normal flow instead, so the legend
3081
+ always starts below however tall graph-controls actually renders (0, 1, or N wrapped rows). */
3082
+ #overlay-left { position:absolute; top:10px; left:12px; z-index:2; display:flex;
3083
+ flex-direction:column; align-items:flex-start; gap:8px; max-width:70%; }
3084
+ #graph-controls { display:none; gap:10px; background:rgba(15,18,22,.85); border:1px solid var(--border);
3085
+ border-radius:8px; padding:6px 10px; flex-wrap:wrap; }
2832
3086
  .hud { position:absolute; bottom:10px; left:12px; color:var(--muted); font-size:11px;
2833
- background:rgba(15,18,22,.6); padding:4px 8px; border-radius:6px; }
2834
- .legend { position:absolute; top:10px; left:12px; background:rgba(15,18,22,.85);
2835
- border:1px solid var(--border); border-radius:8px; padding:8px 10px; font-size:11px; z-index: 1; }
2836
- .legend .row { display:flex; align-items:center; gap:6px; padding:1px 0; }
2837
-
2838
- .detail h3 { font-size:13px; margin:0 0 6px; word-break:break-all; font-weight:600; }
2839
- .detail .pill { display:inline-block; padding:2px 8px; border:1px solid var(--border);
3087
+ background:rgba(15,18,22,.6); padding:4px 8px; border-radius:6px; z-index:2; }
3088
+ .legend { background:rgba(15,18,22,.85);
3089
+ border:1px solid var(--border); border-radius:8px; padding:8px 10px; font-size:11px;
3090
+ max-height:60vh; overflow:auto; max-width:260px; }
3091
+ .legend .row { display:flex; align-items:center; gap:6px; padding:2px 0; }
3092
+ .legend .row.legend-row { cursor:pointer; border-radius:4px; }
3093
+ .legend .row.legend-row:hover { background:#1c2530; }
3094
+ .legend .row.legend-row.active { background:#1f2a36; }
3095
+
3096
+ .inspector h3 { font-size:13px; margin:0 0 6px; word-break:break-all; font-weight:600; }
3097
+ .inspector h4 { font-size:10.5px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted);
3098
+ margin:10px 0 4px; font-weight:700; }
3099
+ .inspector .pill { display:inline-block; padding:2px 8px; border:1px solid var(--border);
2840
3100
  border-radius:99px; font-size:11px; color:var(--muted); margin:2px 4px 2px 0; }
2841
- .detail pre { background:var(--bg); border:1px solid var(--border); border-radius:6px;
3101
+ .inspector pre { background:var(--bg); border:1px solid var(--border); border-radius:6px;
2842
3102
  padding:8px; overflow:auto; font-size:11px; line-height:1.4; max-height:220px;
2843
3103
  margin:6px 0 0; white-space:pre-wrap; }
2844
- .detail .kv { font-size:11.5px; color:var(--muted); margin:2px 0; }
2845
- .detail .kv b { color:var(--text); }
3104
+ .inspector .kv { font-size:11.5px; color:var(--muted); margin:2px 0; }
3105
+ .inspector .kv b { color:var(--text); }
3106
+ .ins-link { color:var(--pres); cursor:pointer; font-family:ui-monospace,SF Mono,Menlo,monospace;
3107
+ font-size:10.5px; }
3108
+ .ins-link:hover { text-decoration:underline; }
2846
3109
 
2847
3110
  .flow-list { max-height:280px; overflow-y:auto; }
2848
3111
  .flow-list .flow-item { padding:8px 14px; border-bottom:1px solid var(--border); cursor:pointer; }
@@ -2879,15 +3142,10 @@ svg { display:block; user-select:none; }
2879
3142
  .container-rect.hi { stroke:#79c0ff; stroke-width:2; }
2880
3143
  .container-label { fill:var(--text); font-size:11px; font-weight:600; pointer-events:none; }
2881
3144
  .module-node { cursor:pointer; }
2882
- .module-node circle { stroke:#0b0d10; stroke-width:1.2; transition:r .15s, stroke .15s; }
2883
- .module-node:hover circle { stroke:#fff; }
2884
- .module-node.selected circle { stroke:#fff; stroke-width:2.4; }
2885
3145
  .edge { fill:none; stroke:#3a4858; stroke-width:0.8; opacity:.5; }
2886
3146
  .edge.cross { stroke:#7ee787; stroke-width:1.4; opacity:.65; }
2887
3147
  .edge.flow { stroke:#ffdf5d; stroke-width:1.5; opacity:.95; }
2888
3148
  .edge.flow.active { stroke-width:2.5; }
2889
- .edge.hi { stroke:#ffdf5d; stroke-width:1.6; opacity:1; }
2890
- .edge.dim { opacity:.05; }
2891
3149
  .layer-band { stroke:#1a2028; stroke-dasharray:2 4; fill:none; }
2892
3150
  .layer-label { fill:var(--muted); font-size:11px; font-weight:600; letter-spacing:1.5px; text-transform:uppercase; }
2893
3151
  .lane-divider { stroke:#1a2028; stroke-dasharray:2 4; }
@@ -2896,7 +3154,6 @@ svg { display:block; user-select:none; }
2896
3154
  .step-num { cursor:pointer; }
2897
3155
  .step-num.active circle { fill:#fff; }
2898
3156
 
2899
- /* Architecture (CodeViz-style nested) view */
2900
3157
  .group-rect { fill:rgba(28,37,48,0.32); stroke-width:1.4; rx:12; cursor:default; }
2901
3158
  .group-rect:hover { filter:brightness(1.15); }
2902
3159
  .group-rect.focused { stroke-width:2.5; }
@@ -2942,21 +3199,17 @@ svg { display:block; user-select:none; }
2942
3199
  <div class="title">DRAHT ▸ <span id="root-name">codebase</span></div>
2943
3200
  <div class="meta" id="meta">loading…</div>
2944
3201
  <div class="tabs">
2945
- <button id="tab-architecture" class="active">Architecture</button>
2946
- <button id="tab-modules">Modules</button>
2947
- <button id="tab-flows">Flow Trace</button>
2948
- <button id="tab-insights">Insights</button>
2949
- <button id="tab-concepts">Concepts</button>
2950
- <button id="tab-graph">Knowledge Graph</button>
2951
- <button id="tab-calls">Calls</button>
3202
+ <button id="tab-graph" class="active">Graph</button>
3203
+ <button id="tab-architecture">Architecture</button>
3204
+ <button id="tab-flows">Flows</button>
2952
3205
  </div>
2953
- <div class="live" id="live-indicator">● live</div>
3206
+ <div class="live dead" id="live-indicator">● static snapshot</div>
2954
3207
  </header>
2955
3208
  <main>
2956
3209
  <aside id="left-aside">
2957
3210
  <div class="section">
2958
3211
  <h2>Search</h2>
2959
- <input id="q" placeholder="filter…" />
3212
+ <input id="q" placeholder="filter modules & symbols…" />
2960
3213
  </div>
2961
3214
  <div class="section">
2962
3215
  <h2>Entry Points</h2>
@@ -2976,98 +3229,146 @@ svg { display:block; user-select:none; }
2976
3229
  </div>
2977
3230
  </aside>
2978
3231
  <div id="viz">
2979
- <svg id="svg" width="100%" height="100%"></svg>
2980
- <div class="legend" id="legend"></div>
3232
+ <div id="error-banner"></div>
3233
+ <svg id="svg" width="100%" height="100%" style="display:none"></svg>
3234
+ <canvas id="canvas"></canvas>
3235
+ <div id="overlay-left">
3236
+ <div id="graph-controls">
3237
+ <label class="ctrl-checkbox"><input type="checkbox" id="show-tests-cb"/> show tests</label>
3238
+ <label class="ctrl-checkbox"><input type="checkbox" id="degree-cb"/> size by degree</label>
3239
+ <label class="ctrl-checkbox"><input type="checkbox" id="surprising-cb"/> highlight surprising</label>
3240
+ <label class="ctrl-checkbox"><input type="checkbox" id="conf-extracted" checked/> extracted</label>
3241
+ <label class="ctrl-checkbox"><input type="checkbox" id="conf-inferred" checked/> inferred</label>
3242
+ <label class="ctrl-checkbox"><input type="checkbox" id="conf-ambiguous" checked/> ambiguous</label>
3243
+ <button id="expand-all">expand all</button>
3244
+ <button id="collapse-all">collapse all</button>
3245
+ </div>
3246
+ <div class="legend" id="legend"></div>
3247
+ </div>
2981
3248
  <div class="controls">
2982
3249
  <label class="ctrl-checkbox"><input type="checkbox" id="overlay-flow-cb"/> overlay flow</label>
3250
+ <button id="export-png">PNG</button>
3251
+ <button id="export-svg">SVG</button>
2983
3252
  <button id="fit">fit</button>
2984
3253
  <button id="clear">clear</button>
2985
3254
  <button id="reload">reload</button>
2986
3255
  </div>
2987
- <div class="hud" id="hud">click a package · hover for details · double-click a group to focus</div>
3256
+ <div class="hud" id="hud">loading…</div>
2988
3257
  <div id="tooltip"></div>
2989
3258
  </div>
2990
3259
  <aside class="right">
2991
- <div class="section">
2992
- <h2>Flows</h2>
2993
- <div class="flow-list" id="flow-list"></div>
2994
- </div>
2995
- <div class="section">
2996
- <h2>Steps</h2>
2997
- <div class="step-list" id="step-list">
2998
- <div style="color:var(--muted); padding:8px 0">Select a flow above to see numbered steps.</div>
3260
+ <div id="flows-sidebar" style="display:none">
3261
+ <div class="section">
3262
+ <h2>Flows</h2>
3263
+ <div class="flow-list" id="flow-list"></div>
3264
+ </div>
3265
+ <div class="section">
3266
+ <h2>Steps</h2>
3267
+ <div class="step-list" id="step-list">
3268
+ <div style="color:var(--muted); padding:8px 0">Select a flow above to see numbered steps.</div>
3269
+ </div>
2999
3270
  </div>
3000
3271
  </div>
3001
- <div class="section detail" id="detail" style="display:none">
3002
- <h3>Detail</h3>
3272
+ <div class="section inspector" id="inspector-section">
3273
+ <h2>Inspector</h2>
3274
+ <div id="inspector"><div style="color:var(--muted)">Click a node, cluster, or package to inspect it.</div></div>
3003
3275
  </div>
3004
3276
  </aside>
3005
3277
  </main>
3278
+ <script id="map-data" type="application/json">${embeddedJson}</script>
3006
3279
  <script>
3280
+ var JSON_PATH = ${JSON.stringify("./" + jsonName)};
3007
3281
  "use strict";
3008
- var JSON_PATH = ${JSON.stringify('./' + jsonName)};
3009
3282
  var LAYER_COLOR = { presentation:"#79c0ff", application:"#7ee787",
3010
3283
  domain:"#d2a8ff", infrastructure:"#f0883e", support:"#8b949e" };
3011
3284
  var LAYER_ORDER = ["presentation","application","domain","infrastructure","support"];
3012
3285
  var SVG_NS = "http://www.w3.org/2000/svg";
3286
+ var GOLD = 2.399963229; // golden angle (radians) — deterministic spiral, no RNG
3013
3287
  function $(id) { return document.getElementById(id); }
3014
3288
  var svg = $("svg");
3289
+ var canvas = $("canvas");
3015
3290
 
3016
3291
  var state = {
3017
3292
  map: null,
3018
- view: "architecture",
3293
+ view: "graph",
3294
+ // svg camera (Architecture / Flows)
3019
3295
  zoom: 1,
3020
3296
  pan: { x:0, y:0 },
3021
3297
  drag: null,
3022
- selected: null,
3298
+ // canvas camera (Graph)
3299
+ gcam: { zoom: 1, pan: { x:0, y:0 } },
3300
+ gdrag: null,
3301
+ selected: null, // { kind: "module"|"cluster"|"box", id }
3302
+ hovered: null,
3023
3303
  selectedFlow: null,
3024
3304
  selectedStep: null,
3025
3305
  collapsedGroups: {},
3026
3306
  overlayFlow: false,
3027
- focus: null,
3307
+ focus: null, // { kind, id, moduleSet, containerSet }
3028
3308
  filter: "",
3309
+ searchMatches: null, // Set of module ids matching current filter
3310
+ showTests: false,
3311
+ highlightSurprising: false,
3312
+ sizeByDegree: false,
3313
+ confidence: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true },
3029
3314
  };
3030
3315
 
3031
- function layerOf(m) { return m.layer || "support"; }
3316
+ // ====================== small utils ======================
3032
3317
  function escapeHtml(s) {
3033
3318
  return String(s).replace(/[&<>"]/g, function (c) {
3034
3319
  return ({"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;"})[c];
3035
3320
  });
3036
3321
  }
3037
3322
  function truncate(s, n) { if (!s) return ""; s = String(s); if (s.length <= n) return s; return s.slice(0, n - 1) + "…"; }
3323
+ function layerOf(m) { return (m && m.layer) || "support"; }
3038
3324
  function containerIdOf(m) {
3039
3325
  if (m.package) return "pkg:" + m.package;
3040
3326
  return "dir:" + m.path.split("/")[0];
3041
3327
  }
3328
+ function clusterColor(cid) {
3329
+ if (!cid) return "#8b949e";
3330
+ var h = 0; for (var i=0; i<cid.length; i++) h = (h * 31 + cid.charCodeAt(i)) % 360;
3331
+ return "hsl(" + h + ",58%,58%)";
3332
+ }
3333
+ function el(tag, attrs, text) {
3334
+ var e = document.createElementNS(SVG_NS, tag);
3335
+ if (attrs) for (var k in attrs) {
3336
+ if (attrs[k] == null) continue;
3337
+ e.setAttribute(k, attrs[k]);
3338
+ }
3339
+ if (text != null) e.textContent = text;
3340
+ return e;
3341
+ }
3342
+ function flowPath(x1, y1, x2, y2) {
3343
+ var dx = x2 - x1, dy = y2 - y1;
3344
+ if (Math.abs(dx) > Math.abs(dy)) {
3345
+ var c1x = x1 + dx * 0.5, c1y = y1;
3346
+ var c2x = x2 - dx * 0.5, c2y = y2;
3347
+ return "M " + x1 + " " + y1 + " C " + c1x + " " + c1y + ", " + c2x + " " + c2y + ", " + x2 + " " + y2;
3348
+ } else {
3349
+ var c1xv = x1, c1yv = y1 + dy * 0.5;
3350
+ var c2xv = x2, c2yv = y2 - dy * 0.5;
3351
+ return "M " + x1 + " " + y1 + " C " + c1xv + " " + c1yv + ", " + c2xv + " " + c2yv + ", " + x2 + " " + y2;
3352
+ }
3353
+ }
3042
3354
  function tx(g) {
3043
3355
  g.setAttribute("transform", "translate(" + state.pan.x + "," + state.pan.y + ") scale(" + state.zoom + ")");
3044
3356
  }
3045
-
3046
- async function load(initial) {
3047
- var res = await fetch(JSON_PATH + "?t=" + Date.now());
3048
- var map = await res.json();
3049
- state.map = map;
3050
- $("root-name").textContent = map.root;
3051
- $("meta").textContent =
3052
- map.stats.files + " files · " + map.stats.totalLoc.toLocaleString() + " LOC · " +
3053
- map.stats.entryPoints + " entries · " + map.stats.sinkModules + " sinks · " +
3054
- map.stats.containerEdges + " cross-pkg edges · " +
3055
- new Date(map.generatedAt).toLocaleTimeString();
3056
- renderSidebar();
3057
- renderFlowList();
3058
- render();
3059
- if (initial) fit();
3357
+ function downloadBlob(blob, name) {
3358
+ var url = URL.createObjectURL(blob);
3359
+ var a = document.createElement("a");
3360
+ a.href = url; a.download = name;
3361
+ document.body.appendChild(a); a.click();
3362
+ setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0);
3060
3363
  }
3061
-
3062
- function setView(v) {
3063
- state.view = v;
3064
- ["tab-architecture","tab-modules","tab-flows","tab-insights","tab-concepts","tab-graph","tab-calls"].forEach(function (id) {
3065
- $(id).classList.toggle("active", id === "tab-" + v);
3066
- });
3067
- // Sidebar layout: flows shows step detail, others show entry inspection
3068
- render(); fit();
3364
+ function showError(msg) {
3365
+ var b = $("error-banner");
3366
+ b.textContent = "⚠ " + msg;
3367
+ b.style.display = "block";
3069
3368
  }
3369
+ function hideError() { var b = $("error-banner"); if (b) b.style.display = "none"; }
3070
3370
 
3371
+ // ====================== data indices ======================
3071
3372
  function buildAdj(map) {
3072
3373
  var adj = new Map();
3073
3374
  for (var i=0; i<map.edges.length; i++) {
@@ -3088,48 +3389,201 @@ function buildReverseAdj(map) {
3088
3389
  }
3089
3390
  return adj;
3090
3391
  }
3091
-
3092
- function selectFlow(flowId) {
3093
- state.selectedFlow = flowId;
3094
- state.selectedStep = null;
3095
- state.view = "flows";
3096
- ["tab-architecture","tab-modules","tab-flows","tab-insights","tab-concepts","tab-graph","tab-calls"].forEach(function (id) {
3097
- $(id).classList.toggle("active", id === "tab-flows");
3392
+ function buildIndices(map) {
3393
+ var modById = new Map(); (map.modules || []).forEach(function (m) { modById.set(m.id, m); });
3394
+ var clusterById = new Map(); (map.clusters || []).forEach(function (c) { clusterById.set(c.id, c); });
3395
+ var entryIds = new Set((map.entryPoints || []).map(function (e) { return e.id; }));
3396
+ var sinkIds = new Set((map.sinks || []).map(function (s) { return s.id; }));
3397
+ var callersOf = new Map(), calleesOf = new Map();
3398
+ (map.callEdges || []).forEach(function (ce) {
3399
+ if (!calleesOf.has(ce.from)) calleesOf.set(ce.from, []);
3400
+ calleesOf.get(ce.from).push(ce);
3401
+ if (!callersOf.has(ce.to)) callersOf.set(ce.to, []);
3402
+ callersOf.get(ce.to).push(ce);
3098
3403
  });
3404
+ return {
3405
+ modById: modById, clusterById: clusterById, entryIds: entryIds, sinkIds: sinkIds,
3406
+ adj: buildAdj(map), radj: buildReverseAdj(map),
3407
+ callersOf: callersOf, calleesOf: calleesOf,
3408
+ };
3409
+ }
3410
+
3411
+ // ====================== load / apply ======================
3412
+ function isHttpProtocol() { return location.protocol === "http:" || location.protocol === "https:"; }
3413
+
3414
+ function applyMap(map) {
3415
+ state.map = map;
3416
+ state.idx = buildIndices(map);
3417
+ $("root-name").textContent = map.root || "codebase";
3418
+ var when = map.generatedAt ? new Date(map.generatedAt).toLocaleTimeString() : "embedded snapshot";
3419
+ $("meta").textContent =
3420
+ map.stats.files + " files · " + map.stats.totalLoc.toLocaleString() + " LOC · " +
3421
+ map.stats.entryPoints + " entries · " + map.stats.sinkModules + " sinks · " +
3422
+ (map.clusters || []).length + " clusters · " + when;
3423
+ state.graph = null; // force rebuild of graph model on next graph render
3424
+ state.searchMatches = null;
3425
+ renderSidebar();
3099
3426
  renderFlowList();
3100
- renderStepList();
3101
3427
  render();
3102
3428
  }
3103
- function selectStep(n) {
3104
- state.selectedStep = n;
3105
- renderStepList();
3106
- render();
3429
+
3430
+ async function refreshFromServer() {
3431
+ if (!isHttpProtocol()) return false;
3432
+ try {
3433
+ var res = await fetch(JSON_PATH);
3434
+ if (!res.ok) throw new Error("http " + res.status);
3435
+ var map = await res.json();
3436
+ applyMap(map);
3437
+ return true;
3438
+ } catch (err) {
3439
+ return false;
3440
+ }
3107
3441
  }
3108
- function clearFlow() {
3109
- state.selectedFlow = null;
3110
- state.selectedStep = null;
3111
- state.focus = null;
3112
- state.selected = null;
3113
- renderFlowList();
3114
- renderStepList();
3115
- renderDetail();
3442
+
3443
+ function connectLive() {
3444
+ if (!isHttpProtocol()) return;
3445
+ try {
3446
+ var es = new EventSource("/events");
3447
+ es.onmessage = function (ev) { if (ev.data === "changed") refreshFromServer(); };
3448
+ es.onerror = function () {
3449
+ $("live-indicator").classList.add("dead");
3450
+ $("live-indicator").textContent = "● offline";
3451
+ };
3452
+ es.onopen = function () {
3453
+ $("live-indicator").classList.remove("dead");
3454
+ $("live-indicator").textContent = "● live";
3455
+ };
3456
+ } catch (err) { /* EventSource unsupported (e.g. file://) — fine, static snapshot still works */ }
3457
+ }
3458
+
3459
+ // ====================== view routing ======================
3460
+ function setView(v) {
3461
+ state.view = v;
3462
+ ["tab-graph","tab-architecture","tab-flows"].forEach(function (id) {
3463
+ $(id).classList.toggle("active", id === "tab-" + v);
3464
+ });
3465
+ svg.style.display = (v === "architecture" || v === "flows") ? "block" : "none";
3466
+ canvas.style.display = (v === "graph") ? "block" : "none";
3467
+ $("graph-controls").style.display = (v === "graph") ? "flex" : "none";
3468
+ $("flows-sidebar").style.display = (v === "flows") ? "block" : "none";
3469
+ // B3: re-measure the canvas's backing-store size whenever it becomes visible. A resize while
3470
+ // the Graph tab was hidden left canvas.width/height stale (clientWidth/height reflow correctly
3471
+ // even for display:none, but resizeCanvas() only ran on the debounced window "resize" handler
3472
+ // when the graph tab was already active) — a stale backing store distorts what's actually
3473
+ // drawn relative to what CSS-pixel hit-testing assumes, so re-sync it on every switch in.
3474
+ if (v === "graph") resizeCanvas();
3116
3475
  render();
3476
+ fit();
3477
+ }
3478
+
3479
+ function render() {
3480
+ var map = state.map; if (!map) return;
3481
+ if (state.view === "graph") {
3482
+ ensureGraphModel();
3483
+ requestDraw();
3484
+ } else {
3485
+ var W = svg.clientWidth, H = svg.clientHeight;
3486
+ svg.innerHTML = "";
3487
+ var g = el("g", { id: "world" });
3488
+ tx(g);
3489
+ svg.appendChild(g);
3490
+ if (state.view === "architecture") renderArchitecture(g, W, H);
3491
+ else renderFlowsSwimlane(g, W, H);
3492
+ }
3493
+ $("legend").innerHTML = legendHtml();
3494
+ $("hud").textContent = hudText();
3495
+ renderInspector();
3496
+ }
3497
+
3498
+ function legendHtml() {
3499
+ var s = "";
3500
+ if (state.view === "architecture" && state.map.groups) {
3501
+ state.map.groups.forEach(function (gr) {
3502
+ s += '<div class="row"><div class="dot" style="background:' + gr.color + '"></div>' + escapeHtml(gr.name) + '</div>';
3503
+ });
3504
+ } else if (state.view === "flows" && state.map.lanes) {
3505
+ state.map.lanes.forEach(function (l) {
3506
+ s += '<div class="row"><div class="dot" style="background:' + l.color + '"></div>' + l.name + '</div>';
3507
+ });
3508
+ } else if (state.view === "graph") {
3509
+ var clusters = (state.map.clusters || []).slice().sort(function (a,b) { return b.size - a.size; });
3510
+ var top = clusters.slice(0, 10);
3511
+ top.forEach(function (c) {
3512
+ var isolated = state.focus && state.focus.kind === "cluster" && state.focus.id === c.id;
3513
+ s += '<div class="row legend-row' + (isolated ? " active" : "") + '" data-cluster="' + escapeHtml(c.id) + '">' +
3514
+ '<div class="dot" style="background:' + clusterColor(c.id) + '"></div>' +
3515
+ '<div class="name">' + escapeHtml(c.label) + '</div><div class="count">' + c.size + '</div></div>';
3516
+ });
3517
+ if (clusters.length > top.length) {
3518
+ s += '<div class="row" style="color:var(--muted)">+' + (clusters.length - top.length) + ' more clusters…</div>';
3519
+ }
3520
+ s += '<div class="row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px">' +
3521
+ '<div class="dot" style="border:2px solid #ffdf5d;background:transparent"></div>entry point (ring)</div>';
3522
+ s += '<div class="row"><div class="dot" style="background:#ff7b72;border-radius:2px;transform:rotate(45deg)"></div>sink (diamond)</div>';
3523
+ s += '<div class="row" style="opacity:.5"><div class="dot" style="background:#8b949e"></div>test file (dimmed)</div>';
3524
+ s += '<div class="row"><svg width="18" height="10"><line x1="0" y1="5" x2="18" y2="5" stroke="#7ee787" stroke-width="1.6"/></svg>&nbsp;extracted</div>';
3525
+ s += '<div class="row"><svg width="18" height="10"><line x1="0" y1="5" x2="18" y2="5" stroke="#79c0ff" stroke-width="1.6" stroke-dasharray="4 3"/></svg>&nbsp;inferred</div>';
3526
+ s += '<div class="row"><svg width="18" height="10"><line x1="0" y1="5" x2="18" y2="5" stroke="#8b949e" stroke-width="1.6" stroke-dasharray="1 3"/></svg>&nbsp;ambiguous</div>';
3527
+ } else {
3528
+ LAYER_ORDER.forEach(function (l) {
3529
+ s += '<div class="row"><div class="dot" style="background:' + LAYER_COLOR[l] + '"></div>' + l + '</div>';
3530
+ });
3531
+ s += '<div class="row"><div class="dot" style="background:#ffdf5d"></div>entry point</div>';
3532
+ s += '<div class="row"><div class="dot" style="background:#ff7b72"></div>sink</div>';
3533
+ }
3534
+ return s;
3535
+ }
3536
+ function hudText() {
3537
+ if (state.view === "architecture") {
3538
+ var c = (state.map.containers || []).length;
3539
+ var gN = (state.map.groups || []).length;
3540
+ return "Architecture · " + gN + " groups, " + c + " packages · click a package · double-click a group header to focus, click header to collapse · ESC to clear";
3541
+ }
3542
+ if (state.view === "flows") {
3543
+ if (state.selectedFlow) {
3544
+ var f = state.map.flows.find(function (x) { return x.id === state.selectedFlow; });
3545
+ return f ? "FLOW: " + f.name + " — " + f.steps.length + " steps" : "";
3546
+ }
3547
+ return "Pick a flow on the right to highlight the path through the system";
3548
+ }
3549
+ if (state.view === "graph") {
3550
+ var g = state.graph;
3551
+ var shown = g ? g.nodes.length : 0;
3552
+ var collapsed = g ? g.collapsedClusters.size : 0;
3553
+ // B2: surface how many modules/symbols the current search matched — without this, a query
3554
+ // that only matches modules inside a collapsed cluster gave no feedback that anything hit.
3555
+ var matchHint = state.searchMatches
3556
+ ? " · " + state.searchMatches.size + " match" + (state.searchMatches.size === 1 ? "" : "es")
3557
+ : "";
3558
+ return "Graph · " + shown + " nodes shown · " + collapsed + " clusters collapsed" + matchHint + " · double-click a cluster to expand/collapse · drag pins · scroll = zoom";
3559
+ }
3560
+ return state.view + " view";
3117
3561
  }
3118
3562
 
3563
+ // ====================== focus (sidebar filters) ======================
3564
+ function buildFocus(kind, id, moduleIds) {
3565
+ var moduleSet = new Set(moduleIds);
3566
+ var containerSet = new Set();
3567
+ moduleSet.forEach(function (mid) {
3568
+ var m = state.idx.modById.get(mid);
3569
+ if (m) containerSet.add(containerIdOf(m));
3570
+ });
3571
+ state.focus = { kind: kind, id: id, moduleSet: moduleSet, containerSet: containerSet };
3572
+ }
3119
3573
  function focusEntry(id) {
3120
- var adj = buildAdj(state.map);
3574
+ var adj = state.idx.adj;
3121
3575
  var set = new Set([id]); var stack = [id];
3122
3576
  while (stack.length) {
3123
3577
  var x = stack.pop();
3124
3578
  var ns = adj.get(x) || [];
3125
3579
  for (var i=0; i<ns.length; i++) if (!set.has(ns[i])) { set.add(ns[i]); stack.push(ns[i]); }
3126
3580
  }
3127
- state.focus = { kind: "entry", id: id, set: set };
3581
+ buildFocus("entry", id, set);
3128
3582
  render();
3129
3583
  }
3130
3584
  function focusSink(kind) {
3131
3585
  var map = state.map;
3132
- var radj = buildReverseAdj(map);
3586
+ var radj = state.idx.radj;
3133
3587
  var seeds = map.sinks.filter(function (s) { return s.sinks.indexOf(kind) !== -1; }).map(function (s) { return s.id; });
3134
3588
  var set = new Set(seeds); var stack = seeds.slice();
3135
3589
  while (stack.length) {
@@ -3137,377 +3591,30 @@ function focusSink(kind) {
3137
3591
  var ns = radj.get(x) || [];
3138
3592
  for (var i=0; i<ns.length; i++) if (!set.has(ns[i])) { set.add(ns[i]); stack.push(ns[i]); }
3139
3593
  }
3140
- state.focus = { kind: "sink", id: kind, set: set };
3594
+ buildFocus("sink", kind, set);
3141
3595
  render();
3142
3596
  }
3143
3597
  function focusPackage(pkgId) {
3144
- var set = new Set();
3145
- for (var i=0; i<state.map.modules.length; i++) {
3146
- var m = state.map.modules[i];
3147
- if (("pkg:" + (m.package || "")) === pkgId) set.add(m.id);
3148
- }
3149
- state.focus = { kind: "package", id: pkgId, set: set };
3598
+ var ids = state.map.modules.filter(function (m) { return ("pkg:" + (m.package || "")) === pkgId; }).map(function (m) { return m.id; });
3599
+ buildFocus("package", pkgId, ids);
3150
3600
  render();
3151
3601
  }
3152
-
3153
- // ====================== Swim-lane Flows view ======================
3154
-
3155
- function layoutSwimlanes(map, W, H) {
3156
- var lanes = map.lanes || [];
3157
- if (!lanes.length) return { lanePos: new Map(), boxPos: new Map() };
3158
- var laneW = W / lanes.length;
3159
- var lanePos = new Map();
3160
- lanes.forEach(function (lane, i) {
3161
- lanePos.set(lane.id, { x: laneW * i, w: laneW, lane: lane });
3162
- });
3163
- var boxesByLane = new Map();
3164
- lanes.forEach(function (l) { boxesByLane.set(l.id, []); });
3165
- (map.boxes || []).forEach(function (b) {
3166
- if (boxesByLane.has(b.lane)) boxesByLane.get(b.lane).push(b);
3167
- });
3168
- // Sort: entry first, then alphabetical
3169
- boxesByLane.forEach(function (arr) {
3170
- arr.sort(function (a,b) {
3171
- if ((a.hasEntry?1:0) !== (b.hasEntry?1:0)) return (b.hasEntry?1:0) - (a.hasEntry?1:0);
3172
- return a.title.localeCompare(b.title);
3173
- });
3174
- });
3175
- var headerH = 36;
3176
- var boxPos = new Map();
3177
- boxesByLane.forEach(function (arr, laneId) {
3178
- var lp = lanePos.get(laneId);
3179
- if (!arr.length) return;
3180
- var availH = H - headerH - 24;
3181
- var maxBoxH = Math.min(64, Math.max(46, availH / Math.max(1, arr.length) - 10));
3182
- var boxPad = 10;
3183
- var totalH = arr.length * (maxBoxH + boxPad);
3184
- var startY = headerH + Math.max(8, (availH - totalH) / 2);
3185
- arr.forEach(function (box, i) {
3186
- var w = Math.max(140, lp.w - 28);
3187
- var x = lp.x + (lp.w - w) / 2;
3188
- boxPos.set(box.id, { x: x, y: startY + i * (maxBoxH + boxPad), w: w, h: maxBoxH, box: box, laneId: laneId });
3189
- });
3190
- });
3191
- return { lanePos: lanePos, boxPos: boxPos };
3192
- }
3193
-
3194
- function renderFlowsSwimlane(g, W, H) {
3195
- var map = state.map;
3196
- var layout = layoutSwimlanes(map, W, H);
3197
- // Lane headers + dividers
3198
- layout.lanePos.forEach(function (lp) {
3199
- g.appendChild(el("text", { class: "layer-label", x: lp.x + 14, y: 22 }, lp.lane.name));
3200
- g.appendChild(el("line", { class: "lane-divider", x1: lp.x + lp.w, y1: 0, x2: lp.x + lp.w, y2: H }));
3201
- });
3202
-
3203
- var flow = state.selectedFlow ? map.flows.find(function (f) { return f.id === state.selectedFlow; }) : null;
3204
- var inFlow = new Set();
3205
- var selectedStepIds = new Set();
3206
- if (flow) {
3207
- for (var i=0; i<flow.steps.length; i++) {
3208
- inFlow.add(flow.steps[i].from); inFlow.add(flow.steps[i].to);
3209
- if (state.selectedStep && flow.steps[i].n === state.selectedStep) {
3210
- selectedStepIds.add(flow.steps[i].from); selectedStepIds.add(flow.steps[i].to);
3211
- }
3212
- }
3213
- }
3214
-
3215
- // Boxes
3216
- layout.boxPos.forEach(function (p, id) {
3217
- var box = p.box;
3218
- var hi = flow && inFlow.has(id);
3219
- var dim = flow && !inFlow.has(id);
3220
- var isSelStep = selectedStepIds.has(id);
3221
- var node = el("g", { class: "flow-box", transform: "translate(" + p.x + "," + p.y + ")" });
3222
- var rect = el("rect", {
3223
- width: p.w, height: p.h, rx: 6, ry: 6,
3224
- fill: hi ? (isSelStep ? "#3a2c00" : "#1f1900") : "rgba(28,37,48,0.55)",
3225
- stroke: hi ? "#ffdf5d" : (box.color || "#3a4858"),
3226
- "stroke-width": hi ? (isSelStep ? 2 : 1.4) : 1,
3227
- opacity: dim ? 0.12 : 1,
3228
- });
3229
- node.appendChild(rect);
3230
- node.appendChild(el("text", { x: 12, y: 19, fill: hi ? "#ffdf5d" : "#e6edf3",
3231
- "font-size": 12, "font-weight": 600, opacity: dim ? 0.2 : 1 }, truncate(box.title, 28)));
3232
- if (box.sublabel) {
3233
- node.appendChild(el("text", { x: 12, y: 35, fill: "#8b949e", "font-size": 10,
3234
- opacity: dim ? 0.2 : 1 }, truncate(box.sublabel, 36)));
3235
- }
3236
- node.addEventListener("click", function (ev) {
3237
- ev.stopPropagation();
3238
- state.selected = { kind: "box", id: id };
3239
- renderDetail();
3240
- render();
3241
- });
3242
- g.appendChild(node);
3243
- });
3244
-
3245
- // Flow arrows + numbered step circles
3246
- if (flow) {
3247
- var arrowDefs = el("defs");
3248
- arrowDefs.innerHTML = '<marker id="arrowhead-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#ffdf5d"/></marker>';
3249
- g.appendChild(arrowDefs);
3250
-
3251
- // Count intra-package step indices per box so we can stack inline chips
3252
- var intraStackByBox = new Map();
3253
-
3254
- function chipAt(boxPos, idx, step, active) {
3255
- // Position chip inside box, stacked along the right edge near the bottom
3256
- var px = boxPos.x + boxPos.w - 18;
3257
- var py = boxPos.y + boxPos.h - 12 - idx * 20;
3258
- // Wrap if too tall: shift left and reset
3259
- if (py < boxPos.y + 36) { px = boxPos.x + boxPos.w - 18 - 22; py = boxPos.y + boxPos.h - 12; }
3260
- var chip = el("g", { class: "step-num" + (active ? " active" : ""), transform: "translate(" + px + "," + py + ")" });
3261
- chip.appendChild(el("circle", { r: 8, fill: active ? "#fff" : "#ffdf5d", stroke: "#0b0d10", "stroke-width": 1.5 }));
3262
- chip.appendChild(el("text", { y: 3, "text-anchor": "middle", "font-size": 9, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3263
- chip.addEventListener("click", function (n) { return function (ev) { ev.stopPropagation(); selectStep(n); }; }(step.n));
3264
- g.appendChild(chip);
3265
- }
3266
-
3267
- for (var si=0; si<flow.steps.length; si++) {
3268
- var step = flow.steps[si];
3269
- // Resolve box positions, falling back to boxFrom/boxTo for intra-package module-level steps
3270
- var fromKey = layout.boxPos.has(step.from) ? step.from : step.boxFrom;
3271
- var toKey = layout.boxPos.has(step.to) ? step.to : step.boxTo;
3272
- var from = layout.boxPos.get(fromKey);
3273
- var to = layout.boxPos.get(toKey);
3274
- if (!from || !to) continue;
3275
- var active = state.selectedStep === step.n;
3276
- var intra = fromKey === toKey;
3277
-
3278
- if (intra) {
3279
- // Render an inline numbered chip inside the box (no arrow)
3280
- var idx = intraStackByBox.get(fromKey) || 0;
3281
- intraStackByBox.set(fromKey, idx + 1);
3282
- chipAt(from, idx, step, active);
3283
- continue;
3284
- }
3285
-
3286
- var sameLane = from.laneId === to.laneId;
3287
- var x1, y1, x2, y2;
3288
- if (sameLane) {
3289
- if (to.y > from.y) {
3290
- x1 = from.x + from.w / 2; y1 = from.y + from.h;
3291
- x2 = to.x + to.w / 2; y2 = to.y;
3292
- } else {
3293
- x1 = from.x + from.w / 2; y1 = from.y;
3294
- x2 = to.x + to.w / 2; y2 = to.y + to.h;
3295
- }
3296
- } else if (to.x > from.x) {
3297
- x1 = from.x + from.w; y1 = from.y + from.h / 2;
3298
- x2 = to.x; y2 = to.y + to.h / 2;
3299
- } else {
3300
- x1 = from.x; y1 = from.y + from.h / 2;
3301
- x2 = to.x + to.w; y2 = to.y + to.h / 2;
3302
- }
3303
- var p = el("path", {
3304
- class: "edge flow" + (active ? " active" : ""),
3305
- d: flowPath(x1, y1, x2, y2),
3306
- "marker-end": "url(#arrowhead-flow)",
3307
- opacity: active || state.selectedStep == null ? 0.95 : 0.35,
3308
- });
3309
- g.appendChild(p);
3310
- var mx = (x1 + x2) / 2;
3311
- var my = (y1 + y2) / 2;
3312
- var circle = el("g", {
3313
- class: "step-num" + (active ? " active" : ""),
3314
- transform: "translate(" + mx + "," + my + ")"
3315
- });
3316
- circle.appendChild(el("circle", { r: 11, fill: active ? "#fff" : "#ffdf5d", stroke: "#0b0d10", "stroke-width": 2 }));
3317
- circle.appendChild(el("text", { y: 4, "text-anchor": "middle", "font-size": 11, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3318
- circle.addEventListener("click", function (n) {
3319
- return function (ev) { ev.stopPropagation(); selectStep(n); };
3320
- }(step.n));
3321
- g.appendChild(circle);
3322
- }
3323
- }
3324
- }
3325
-
3326
- function flowPath(x1, y1, x2, y2) {
3327
- // S-curve for cleaner routing
3328
- var dx = x2 - x1, dy = y2 - y1;
3329
- if (Math.abs(dx) > Math.abs(dy)) {
3330
- var c1x = x1 + dx * 0.5, c1y = y1;
3331
- var c2x = x2 - dx * 0.5, c2y = y2;
3332
- return "M " + x1 + " " + y1 + " C " + c1x + " " + c1y + ", " + c2x + " " + c2y + ", " + x2 + " " + y2;
3333
- } else {
3334
- var c1xv = x1, c1yv = y1 + dy * 0.5;
3335
- var c2xv = x2, c2yv = y2 - dy * 0.5;
3336
- return "M " + x1 + " " + y1 + " C " + c1xv + " " + c1yv + ", " + c2xv + " " + c2yv + ", " + x2 + " " + y2;
3337
- }
3338
- }
3339
-
3340
- function layoutSystem(map, W, H) {
3341
- var containers = map.containers.map(function (c) {
3342
- var mods = map.modules.filter(function (m) { return containerIdOf(m) === c.id; });
3343
- var counts = {};
3344
- for (var i=0; i<mods.length; i++) {
3345
- var l = layerOf(mods[i]); counts[l] = (counts[l] || 0) + 1;
3346
- }
3347
- var dominant = LAYER_ORDER.slice().sort(function (a,b) { return (counts[b]||0) - (counts[a]||0); })[0] || "support";
3348
- var sinks = new Set();
3349
- for (var j=0; j<mods.length; j++) (mods[j].sinks || []).forEach(function (s) { sinks.add(s); });
3350
- return Object.assign({}, c, { dominant: dominant, moduleCount: mods.length,
3351
- hasEntry: mods.some(function (m) { return !!m.entryPoint; }), sinks: Array.from(sinks) });
3352
- });
3353
- containers.sort(function (a,b) {
3354
- var d = LAYER_ORDER.indexOf(a.dominant) - LAYER_ORDER.indexOf(b.dominant);
3355
- return d !== 0 ? d : b.moduleCount - a.moduleCount;
3356
- });
3357
- var bandH = H / LAYER_ORDER.length;
3358
- var positions = new Map();
3359
- var perLayer = new Map();
3360
- containers.forEach(function (c) {
3361
- if (!perLayer.has(c.dominant)) perLayer.set(c.dominant, []);
3362
- perLayer.get(c.dominant).push(c);
3363
- });
3364
- perLayer.forEach(function (cs, layer) {
3365
- var idx = LAYER_ORDER.indexOf(layer);
3366
- var y = bandH * idx + bandH / 2;
3367
- var cols = Math.max(1, cs.length);
3368
- cs.forEach(function (c, i) {
3369
- var colW = W / cols;
3370
- var x = colW * i + colW / 2;
3371
- var w = Math.max(120, Math.min(colW * 0.85, 80 + Math.sqrt(c.moduleCount) * 26));
3372
- var h = Math.max(60, Math.min(bandH * 0.8, 50 + Math.sqrt(c.moduleCount) * 14));
3373
- positions.set(c.id, { x: x - w / 2, y: y - h / 2, w: w, h: h, container: c });
3374
- });
3375
- });
3376
- return { positions: positions, containers: containers };
3377
- }
3378
-
3379
- function layoutModules(map, W, H) {
3380
- var containers = map.containers.slice().sort(function (a,b) { return b.moduleCount - a.moduleCount; });
3381
- var n = containers.length;
3382
- var cols = Math.ceil(Math.sqrt(n * (W / H)));
3383
- var rows = Math.ceil(n / cols);
3384
- var pad = 16;
3385
- var cellW = (W - pad) / cols;
3386
- var cellH = (H - pad) / rows;
3387
- var containerPos = new Map();
3388
- containers.forEach(function (c, i) {
3389
- var cx = i % cols, cy = Math.floor(i / cols);
3390
- containerPos.set(c.id, { x: pad + cx * cellW, y: pad + cy * cellH, w: cellW - pad, h: cellH - pad, container: c });
3391
- });
3392
- var modulePos = new Map();
3393
- containers.forEach(function (c) {
3394
- var slot = containerPos.get(c.id); if (!slot) return;
3395
- var mods = map.modules.filter(function (m) { return containerIdOf(m) === c.id; });
3396
- mods.sort(function (a,b) {
3397
- if ((a.entryPoint?1:0) !== (b.entryPoint?1:0)) return (b.entryPoint?1:0) - (a.entryPoint?1:0);
3398
- var la = LAYER_ORDER.indexOf(layerOf(a)), lb = LAYER_ORDER.indexOf(layerOf(b));
3399
- if (la !== lb) return la - lb;
3400
- return b.loc - a.loc;
3401
- });
3402
- if (mods.length === 0) return;
3403
- var innerW = slot.w - 16, innerH = slot.h - 28;
3404
- var mcols = Math.max(1, Math.ceil(Math.sqrt(mods.length * Math.max(0.001, innerW / Math.max(1, innerH)))));
3405
- var mw = innerW / mcols, mh = innerH / Math.max(1, Math.ceil(mods.length / mcols));
3406
- mods.forEach(function (m, i) {
3407
- var x = slot.x + 8 + (i % mcols) * mw + mw / 2;
3408
- var y = slot.y + 22 + Math.floor(i / mcols) * mh + mh / 2;
3409
- modulePos.set(m.id, { x: x, y: y, m: m });
3410
- });
3411
- });
3412
- return { containerPos: containerPos, modulePos: modulePos, containers: containers };
3413
- }
3414
-
3415
- function el(tag, attrs, text) {
3416
- var e = document.createElementNS(SVG_NS, tag);
3417
- if (attrs) for (var k in attrs) {
3418
- if (attrs[k] == null) continue;
3419
- e.setAttribute(k, attrs[k]);
3420
- }
3421
- if (text != null) e.textContent = text;
3422
- return e;
3423
- }
3424
- function arrow(x1, y1, x2, y2, attrs) {
3425
- var dx = x2 - x1, dy = y2 - y1;
3426
- var c1x = x1 + dx * 0.25, c1y = y1 + dy * 0.5;
3427
- var c2x = x1 + dx * 0.75, c2y = y2 - dy * 0.1;
3428
- attrs = attrs || {};
3429
- var p = el("path", Object.assign({}, attrs, { d: "M" + x1 + " " + y1 + " C " + c1x + " " + c1y + ", " + c2x + " " + c2y + ", " + x2 + " " + y2 }));
3430
- return p;
3431
- }
3432
-
3433
- function render() {
3434
- var map = state.map; if (!map) return;
3435
- var W = svg.clientWidth, H = svg.clientHeight;
3436
- svg.innerHTML = "";
3437
- var g = el("g", { id: "world" });
3438
- tx(g);
3439
- svg.appendChild(g);
3440
- if (state.view === "architecture") renderArchitecture(g, W, H);
3441
- else if (state.view === "modules") renderModules(g, W, H);
3442
- else if (state.view === "insights") renderInsights(g, W, H);
3443
- else if (state.view === "concepts") renderConcepts(g, W, H);
3444
- else if (state.view === "graph") renderGraph(g, W, H);
3445
- else if (state.view === "calls") renderCalls(g, W, H);
3446
- else renderFlowsSwimlane(g, W, H);
3447
- $("legend").innerHTML = legendHtml();
3448
- $("hud").textContent = hudText();
3449
- }
3450
-
3451
- function legendHtml() {
3452
- var s = "";
3453
- if (state.view === "architecture" && state.map.groups) {
3454
- state.map.groups.forEach(function (gr) {
3455
- s += '<div class="row"><div class="dot" style="background:' + gr.color + '"></div>' + escapeHtml(gr.name) + '</div>';
3456
- });
3457
- } else if (state.view === "flows" && state.map.lanes) {
3458
- state.map.lanes.forEach(function (l) {
3459
- s += '<div class="row"><div class="dot" style="background:' + l.color + '"></div>' + l.name + '</div>';
3460
- });
3461
- } else if (state.view === "insights") {
3462
- (state.map.clusters || []).slice(0, 8).forEach(function (c) {
3463
- s += '<div class="row"><div class="dot" style="background:' + clusterColor(c.id) + '"></div>' + escapeHtml(c.label) + ' (' + c.size + ')</div>';
3464
- });
3465
- s += '<div class="row"><div class="dot" style="background:#fff;border:1px solid #888"></div>god node (larger)</div>';
3466
- s += '<div class="row"><div class="dot" style="background:#ff5c5c"></div>surprising edge</div>';
3467
- } else {
3468
- LAYER_ORDER.forEach(function (l) {
3469
- s += '<div class="row"><div class="dot" style="background:' + LAYER_COLOR[l] + '"></div>' + l + '</div>';
3470
- });
3471
- s += '<div class="row"><div class="dot" style="background:#ffdf5d"></div>entry point</div>';
3472
- s += '<div class="row"><div class="dot" style="background:#ff7b72"></div>sink</div>';
3473
- }
3474
- return s;
3475
- }
3476
- function hudText() {
3477
- if (state.view === "architecture") {
3478
- var c = (state.map.containers || []).length;
3479
- var gN = (state.map.groups || []).length;
3480
- return "Architecture · " + gN + " groups, " + c + " packages · click a package · double-click a group to focus · ESC to clear";
3481
- }
3482
- if (state.view === "flows") {
3483
- if (state.selectedFlow) {
3484
- var f = state.map.flows.find(function (x) { return x.id === state.selectedFlow; });
3485
- return f ? "FLOW: " + f.name + " — " + f.steps.length + " steps" : "";
3486
- }
3487
- return "Pick a flow on the right to highlight the path through the system";
3488
- }
3489
- if (state.view === "insights") {
3490
- var god = (state.map.hotspots && state.map.hotspots.godNodes) || [];
3491
- return "Insights · " + ((state.map.clusters || []).length) + " clusters · " + god.length + " god-nodes · " + ((state.map.surprisingConnections || []).length) + " surprising edges · hover a node for detail";
3492
- }
3493
- if (state.view === "concepts") {
3494
- return "Concepts · " + ((state.map.clusters || []).length) + " clusters as nodes · edges = cross-cluster imports · hover for counts";
3495
- }
3496
- if (state.view === "graph") {
3497
- var total = (state.map.modules || []).length;
3498
- var shown = (state.map.modules || []).filter(function (m) { return !m.isTest; }).length;
3499
- return "Knowledge graph · " + shown + " of " + total + " modules (tests hidden) · grouped by cluster · edges = imports";
3500
- }
3501
- if (state.view === "calls") {
3502
- return "Calls · symbol-level call graph around one module (god-node by default) · callers ← center → callees · hover a node for its path";
3503
- }
3504
- if (state.focus) return "focused on " + state.focus.kind + ":" + state.focus.id + " — " + state.focus.set.size + " module(s) (esc to clear)";
3505
- return state.view + " view · scroll = zoom · drag = pan · click = focus";
3602
+ function focusLayer(layer) {
3603
+ var ids = state.map.modules.filter(function (m) { return layerOf(m) === layer; }).map(function (m) { return m.id; });
3604
+ buildFocus("layer", layer, ids);
3605
+ render();
3506
3606
  }
3607
+ function focusClusterToggle(clusterId) {
3608
+ if (state.focus && state.focus.kind === "cluster" && state.focus.id === clusterId) {
3609
+ state.focus = null; render(); return;
3610
+ }
3611
+ var c = state.idx.clusterById.get(clusterId);
3612
+ buildFocus("cluster", clusterId, (c && c.members) || []);
3613
+ render();
3614
+ }
3615
+ function clearFocus() { state.focus = null; render(); }
3507
3616
 
3508
3617
  // ====================== Architecture (nested-containers) view ======================
3509
-
3510
- // Slice-and-dice treemap: divides a rect into N sub-rects weighted by item.weight.
3511
3618
  function slice(rect, items, getWeight) {
3512
3619
  if (items.length === 0) return [];
3513
3620
  var total = 0;
@@ -3530,8 +3637,6 @@ function slice(rect, items, getWeight) {
3530
3637
  }
3531
3638
  return out;
3532
3639
  }
3533
-
3534
- // Grid layout that always fits N items into a rect, deterministic columns by aspect ratio.
3535
3640
  function gridLayout(rect, items) {
3536
3641
  if (items.length === 0) return [];
3537
3642
  var aspect = rect.w / Math.max(1, rect.h);
@@ -3546,14 +3651,12 @@ function gridLayout(rect, items) {
3546
3651
  }
3547
3652
  return out;
3548
3653
  }
3549
-
3550
3654
  function layoutArchitecture(map, W, H) {
3551
3655
  var groups = (map.groups || []).slice();
3552
3656
  if (groups.length === 0) return { groupPos: new Map(), packagePos: new Map() };
3553
3657
  groups.sort(function (a, b) { return (b.moduleCount || 0) - (a.moduleCount || 0); });
3554
3658
  var pad = 18, gap = 12;
3555
3659
  var outerRect = { x: pad, y: pad, w: W - pad * 2, h: H - pad * 2 };
3556
- // Use slice-and-dice weighted by sqrt(moduleCount) so small groups stay readable.
3557
3660
  var groupSlots = slice(outerRect, groups, function (g) { return Math.sqrt(Math.max(1, g.moduleCount || 1)); });
3558
3661
  var groupPos = new Map();
3559
3662
  var packagePos = new Map();
@@ -3568,7 +3671,6 @@ function layoutArchitecture(map, W, H) {
3568
3671
  if (memberContainers.length === 0) continue;
3569
3672
  var headerH = 34;
3570
3673
  var pkgRect = { x: inset.x + 8, y: inset.y + headerH, w: Math.max(40, inset.w - 16), h: Math.max(40, inset.h - headerH - 8) };
3571
- // Grid layout — always fits everything inside the group
3572
3674
  var pkgSlots = gridLayout(pkgRect, memberContainers);
3573
3675
  for (var k=0; k<pkgSlots.length; k++) {
3574
3676
  var ps = pkgSlots[k];
@@ -3584,7 +3686,6 @@ function showTooltip(html, ev) {
3584
3686
  var tt = $("tooltip");
3585
3687
  tt.innerHTML = html;
3586
3688
  tt.style.display = "block";
3587
- // Position relative to viewport, clamped to viz
3588
3689
  var viz = $("viz").getBoundingClientRect();
3589
3690
  var x = (ev.clientX - viz.left) + 14;
3590
3691
  var y = (ev.clientY - viz.top) + 14;
@@ -3593,7 +3694,6 @@ function showTooltip(html, ev) {
3593
3694
  if (y + ttH > viz.height) y = viz.height - ttH - 6;
3594
3695
  tt.style.left = x + "px"; tt.style.top = y + "px";
3595
3696
  }
3596
-
3597
3697
  function tooltipPackageHtml(container, groupName) {
3598
3698
  var top = (container.topFiles || []).slice(0, 5);
3599
3699
  var html = '<div class="tt-title">' + escapeHtml(container.name) + '</div>';
@@ -3610,7 +3710,6 @@ function tooltipPackageHtml(container, groupName) {
3610
3710
  }
3611
3711
  return html;
3612
3712
  }
3613
-
3614
3713
  function tooltipEdgeHtml(edge) {
3615
3714
  var labels = (edge.labels || [edge.label]).join(", ");
3616
3715
  var samples = (edge.symbolSamples || []).slice(0, 5).map(function (s) { return '<code>' + escapeHtml(s) + '</code>'; }).join(", ");
@@ -3621,15 +3720,119 @@ function tooltipEdgeHtml(edge) {
3621
3720
  if (samples) html += '<div class="tt-section">Top symbols</div><div>' + samples + '</div>';
3622
3721
  return html;
3623
3722
  }
3723
+ function tooltipModuleHtml(m) {
3724
+ var html = '<div class="tt-title">' + escapeHtml(m.path) + '</div>';
3725
+ html += '<div class="tt-desc">' + escapeHtml(m.package || "-") + ' · ' + escapeHtml(m.layer || "-") + ' · ' + (m.loc||0) + ' loc' + (m.cluster ? ' · ' + escapeHtml(m.cluster) : '') + '</div>';
3726
+ return html;
3727
+ }
3728
+
3729
+ // Convert a mouse event's client coordinates into SVG world coordinates (accounts for
3730
+ // the current pan/zoom, unlike comparing screen px directly against world dimensions — defect 10).
3731
+ function svgToWorld(ev) {
3732
+ var rect = svg.getBoundingClientRect();
3733
+ var sx = ev.clientX - rect.left, sy = ev.clientY - rect.top;
3734
+ return { x: (sx - state.pan.x) / state.zoom, y: (sy - state.pan.y) / state.zoom };
3735
+ }
3624
3736
 
3625
3737
  function renderArchitecture(g, W, H) {
3626
3738
  var map = state.map;
3627
3739
  var layout = layoutArchitecture(map, W, H);
3628
- state.lastArchLayout = layout;
3629
3740
  var groups = map.groups || [];
3630
3741
  var groupById = {}; for (var ig=0; ig<groups.length; ig++) groupById[groups[ig].id] = groups[ig];
3631
3742
 
3632
- // 1) Group rectangles (containers)
3743
+ // B5 fix: edges (and their wide invisible .edge-hit hover targets) used to be appended AFTER
3744
+ // the group/package rects, which put them on top in paint order and let .edge-hit intercept
3745
+ // clicks meant for the package underneath it. SVG stacks later-appended siblings above earlier
3746
+ // ones, so we now append every edge/edge-hit FIRST — package/group rects then render on top and
3747
+ // win pointer-events wherever they overlap an edge; edge hover still works on exposed segments.
3748
+ var collapsedSet = new Set(Object.keys(state.collapsedGroups).filter(function (k) { return state.collapsedGroups[k]; }));
3749
+ var groupOfContainer = {}; for (var ic=0; ic<map.containers.length; ic++) groupOfContainer[map.containers[ic].id] = map.containers[ic].groupId;
3750
+
3751
+ var crossGroupAgg = new Map();
3752
+ var edges = map.containerEdges || [];
3753
+ var defs = el("defs");
3754
+ defs.innerHTML = '<marker id="arrow-cross" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#7ee787"/></marker>' +
3755
+ '<marker id="arrow-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#ffdf5d"/></marker>';
3756
+ g.appendChild(defs);
3757
+
3758
+ for (var ei=0; ei<edges.length; ei++) {
3759
+ var edge = edges[ei];
3760
+ var fromG = groupOfContainer[edge.from];
3761
+ var toG = groupOfContainer[edge.to];
3762
+ var fromCollapsed = collapsedSet.has(fromG);
3763
+ var toCollapsed = collapsedSet.has(toG);
3764
+ if (fromCollapsed || toCollapsed) {
3765
+ var k2 = fromG + "→" + toG;
3766
+ if (!crossGroupAgg.has(k2)) crossGroupAgg.set(k2, { from: fromG, to: toG, count: 0, callCount: 0, labels: new Set() });
3767
+ var ag = crossGroupAgg.get(k2);
3768
+ ag.count += edge.count;
3769
+ ag.callCount += (edge.callCount || 0);
3770
+ ag.labels.add(edge.label);
3771
+ continue;
3772
+ }
3773
+ var fromPos = layout.packagePos.get(edge.from);
3774
+ var toPos = layout.packagePos.get(edge.to);
3775
+ if (!fromPos || !toPos) continue;
3776
+ var x1, y1, x2, y2;
3777
+ var fcx = fromPos.x + fromPos.w/2, fcy = fromPos.y + fromPos.h/2;
3778
+ var tcx = toPos.x + toPos.w/2, tcy = toPos.y + toPos.h/2;
3779
+ if (Math.abs(tcx - fcx) >= Math.abs(tcy - fcy)) {
3780
+ x1 = tcx > fcx ? fromPos.x + fromPos.w : fromPos.x;
3781
+ y1 = fcy;
3782
+ x2 = tcx > fcx ? toPos.x : toPos.x + toPos.w;
3783
+ y2 = tcy;
3784
+ } else {
3785
+ x1 = fcx;
3786
+ y1 = tcy > fcy ? fromPos.y + fromPos.h : fromPos.y;
3787
+ x2 = tcx;
3788
+ y2 = tcy > fcy ? toPos.y : toPos.y + toPos.h;
3789
+ }
3790
+ var sw = Math.min(4, 0.8 + Math.log2(edge.count + 1));
3791
+ var path = el("path", { class: "edge cross", d: flowPath(x1, y1, x2, y2), "stroke-width": sw, "marker-end": "url(#arrow-cross)" });
3792
+ g.appendChild(path);
3793
+ var hit = el("path", { class: "edge-hit", d: flowPath(x1, y1, x2, y2) });
3794
+ g.appendChild(hit);
3795
+ hit.addEventListener("mouseenter", function (e) { return function (ev) { showTooltip(tooltipEdgeHtml(e), ev); }; }(edge));
3796
+ hit.addEventListener("mousemove", function (e) { return function (ev) { showTooltip(tooltipEdgeHtml(e), ev); }; }(edge));
3797
+ hit.addEventListener("mouseleave", hideTooltip);
3798
+ var dist = Math.hypot(x2 - x1, y2 - y1);
3799
+ if (dist >= 80 && edge.label) {
3800
+ var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3801
+ var labelText = edge.label;
3802
+ var chipW = labelText.length * 6.2 + 12;
3803
+ var labelClass = "edge-label-text " + labelText.toLowerCase().replace(/\\s+/g, "-");
3804
+ var chip = el("g", { transform: "translate(" + mx + "," + my + ")" });
3805
+ chip.appendChild(el("rect", { class: "edge-label-bg", x: -chipW/2, y: -9, width: chipW, height: 18, rx: 4, ry: 4 }));
3806
+ chip.appendChild(el("text", { class: labelClass, x: 0, y: 3, "text-anchor": "middle" }, labelText));
3807
+ g.appendChild(chip);
3808
+ }
3809
+ }
3810
+
3811
+ crossGroupAgg.forEach(function (agg) {
3812
+ var fromGP = layout.groupPos.get(agg.from), toGP = layout.groupPos.get(agg.to);
3813
+ if (!fromGP || !toGP) return;
3814
+ var fcx = fromGP.x + fromGP.w/2, fcy = fromGP.y + fromGP.h/2;
3815
+ var tcx = toGP.x + toGP.w/2, tcy = toGP.y + toGP.h/2;
3816
+ var x1, y1, x2, y2;
3817
+ if (Math.abs(tcx - fcx) >= Math.abs(tcy - fcy)) {
3818
+ x1 = tcx > fcx ? fromGP.x + fromGP.w : fromGP.x; y1 = fcy;
3819
+ x2 = tcx > fcx ? toGP.x : toGP.x + toGP.w; y2 = tcy;
3820
+ } else {
3821
+ x1 = fcx; y1 = tcy > fcy ? fromGP.y + fromGP.h : fromGP.y;
3822
+ x2 = tcx; y2 = tcy > fcy ? toGP.y : toGP.y + toGP.h;
3823
+ }
3824
+ g.appendChild(el("path", { class: "edge cross", d: flowPath(x1, y1, x2, y2),
3825
+ "stroke-width": Math.min(6, 1.5 + Math.log2(agg.count + 1)), "marker-end": "url(#arrow-cross)" }));
3826
+ var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3827
+ var labelText = (agg.from.replace(/^group:/, "") + " → " + agg.to.replace(/^group:/, "") + " · " + agg.count);
3828
+ var chipW = labelText.length * 6.2 + 12;
3829
+ var chip = el("g", { transform: "translate(" + mx + "," + my + ")" });
3830
+ chip.appendChild(el("rect", { class: "edge-label-bg", x: -chipW/2, y: -9, width: chipW, height: 18, rx: 4, ry: 4 }));
3831
+ chip.appendChild(el("text", { class: "edge-label-text", x: 0, y: 3, "text-anchor": "middle" }, labelText));
3832
+ g.appendChild(chip);
3833
+ });
3834
+
3835
+ // Groups and packages render last (on top of all edges/edge-hit paths — see the B5 note above).
3633
3836
  layout.groupPos.forEach(function (gp, id) {
3634
3837
  var grp = gp.group;
3635
3838
  var color = grp.color || "#8b949e";
@@ -3640,56 +3843,54 @@ function renderArchitecture(g, W, H) {
3640
3843
  stroke: color, fill: "rgba(28,37,48,0.32)",
3641
3844
  });
3642
3845
  g.appendChild(rect);
3643
- // Header strip
3644
3846
  g.appendChild(el("rect", { class: "group-header",
3645
3847
  x: gp.x, y: gp.y, width: gp.w, height: 30, rx: 12, ry: 12 }));
3646
3848
  g.appendChild(el("text", { class: "group-name", x: gp.x + 14, y: gp.y + 20, fill: color }, grp.name));
3647
3849
  var sub = grp.members.length + " pkgs · " + (grp.moduleCount || 0) + " modules" + (grp.source === "curated" ? " · curated" : "");
3648
3850
  g.appendChild(el("text", { class: "group-sub", x: gp.x + gp.w - 14, y: gp.y + 20, "text-anchor": "end" }, sub));
3649
- // Click on header area toggles collapse; double-click sets focus
3650
- rect.addEventListener("dblclick", function (ev) {
3851
+ rect.addEventListener("dblclick", function (gp2, id2) { return function (ev) {
3651
3852
  ev.stopPropagation();
3652
3853
  var set = new Set();
3653
3854
  for (var i=0; i<map.containers.length; i++) {
3654
3855
  var c = map.containers[i];
3655
- if (c.groupId === id) {
3656
- set.add(c.id);
3856
+ if (c.groupId === id2) {
3657
3857
  var mods = map.modules.filter(function (m) { return containerIdOf(m) === c.id; });
3658
3858
  for (var k=0; k<mods.length; k++) set.add(mods[k].id);
3659
3859
  }
3660
3860
  }
3661
- state.focus = { kind: "group", id: id, set: set };
3861
+ buildFocus("group", id2, set);
3662
3862
  render();
3663
- });
3664
- // Click on header (top 30px) toggles collapse
3665
- rect.addEventListener("click", function (ev) {
3666
- var rectBox = rect.getBoundingClientRect();
3667
- if (ev.clientY - rectBox.top <= 30) {
3863
+ }; }(gp, id));
3864
+ // Header collapse hit-test: compare in WORLD coordinates (defect 10), not raw screen px,
3865
+ // so it stays correct at any zoom level.
3866
+ rect.addEventListener("click", function (gp2, id2) { return function (ev) {
3867
+ var world = svgToWorld(ev);
3868
+ if (world.y - gp2.y <= 30) {
3668
3869
  ev.stopPropagation();
3669
- state.collapsedGroups[id] = !state.collapsedGroups[id];
3870
+ state.collapsedGroups[id2] = !state.collapsedGroups[id2];
3670
3871
  render();
3671
3872
  }
3672
- });
3873
+ }; }(gp, id));
3673
3874
  });
3674
3875
 
3675
- // 2) Package rectangles inside expanded groups
3676
3876
  layout.packagePos.forEach(function (pp, id) {
3677
3877
  var c = pp.container;
3678
3878
  var grp = groupById[pp.groupId] || { color: "#3a4858" };
3679
3879
  var sel = state.selected && state.selected.kind === "box" && state.selected.id === id;
3680
- var dim = state.focus && !state.focus.set.has(id);
3880
+ // Fixed defect 6: dim by containerSet (packages), not a module-id set.
3881
+ var dim = state.focus && !state.focus.containerSet.has(id);
3681
3882
  var rect = el("rect", {
3682
3883
  class: "pkg-rect" + (sel ? " selected" : "") + (dim ? " dim" : ""),
3683
3884
  x: pp.x, y: pp.y, width: pp.w, height: pp.h, rx: 8, ry: 8,
3684
3885
  stroke: grp.color || "#3a4858",
3685
3886
  });
3887
+ rect.appendChild(el("title", null, c.name + " — " + c.moduleCount + " modules"));
3686
3888
  g.appendChild(rect);
3687
3889
  g.appendChild(el("text", { class: "pkg-title", x: pp.x + 10, y: pp.y + 18 },
3688
3890
  truncate(c.name, Math.max(8, Math.floor(pp.w / 7.5)))));
3689
3891
  var entryCount = (c.topFiles || []).filter(function (t) { return /entry|HTTP/.test(t.reason); }).length;
3690
3892
  var sub = (c.moduleCount || 0) + " modules" + (entryCount ? " · " + entryCount + " entry" : "");
3691
3893
  g.appendChild(el("text", { class: "pkg-sub", x: pp.x + 10, y: pp.y + 32 }, sub));
3692
- // Show 1-3 top file paths inline if there's vertical space
3693
3894
  if (pp.h >= 76) {
3694
3895
  var top = (c.topFiles || []).slice(0, Math.min(3, Math.floor((pp.h - 40) / 14)));
3695
3896
  for (var t=0; t<top.length; t++) {
@@ -3700,386 +3901,765 @@ function renderArchitecture(g, W, H) {
3700
3901
  }
3701
3902
  rect.addEventListener("click", function (ev) {
3702
3903
  ev.stopPropagation();
3703
- state.selected = { kind: "box", id: id };
3704
- renderDetail();
3705
- render();
3706
- });
3707
- rect.addEventListener("mouseenter", function (ev) {
3708
- showTooltip(tooltipPackageHtml(c, grp.name), ev);
3709
- });
3710
- rect.addEventListener("mousemove", function (ev) {
3711
- showTooltip(tooltipPackageHtml(c, grp.name), ev);
3904
+ selectNode("box", id);
3712
3905
  });
3906
+ rect.addEventListener("mouseenter", function (ev) { showTooltip(tooltipPackageHtml(c, grp.name), ev); });
3907
+ rect.addEventListener("mousemove", function (ev) { showTooltip(tooltipPackageHtml(c, grp.name), ev); });
3713
3908
  rect.addEventListener("mouseleave", hideTooltip);
3714
3909
  });
3715
3910
 
3716
- // 3) Build collapsed cross-group edges if any group is collapsed
3717
- var collapsedSet = new Set(Object.keys(state.collapsedGroups).filter(function (k) { return state.collapsedGroups[k]; }));
3718
- // Build container → group lookup
3719
- var groupOfContainer = {}; for (var ic=0; ic<map.containers.length; ic++) groupOfContainer[map.containers[ic].id] = map.containers[ic].groupId;
3720
-
3721
- // 4) Cross-package + cross-group edges with labeled chips
3722
- var crossGroupAgg = new Map(); // "fromG→toG" -> agg
3723
- var edges = map.containerEdges || [];
3724
- var defs = el("defs");
3725
- defs.innerHTML = '<marker id="arrow-cross" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#7ee787"/></marker>' +
3726
- '<marker id="arrow-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#ffdf5d"/></marker>';
3727
- g.appendChild(defs);
3911
+ if (state.overlayFlow && state.selectedFlow) overlayFlowOnArchitecture(g, layout);
3912
+ }
3728
3913
 
3729
- for (var ei=0; ei<edges.length; ei++) {
3730
- var edge = edges[ei];
3731
- var fromG = groupOfContainer[edge.from];
3732
- var toG = groupOfContainer[edge.to];
3733
- var fromCollapsed = collapsedSet.has(fromG);
3734
- var toCollapsed = collapsedSet.has(toG);
3735
- if (fromCollapsed || toCollapsed) {
3736
- var k = fromG + "→" + toG;
3737
- if (!crossGroupAgg.has(k)) crossGroupAgg.set(k, { from: fromG, to: toG, count: 0, callCount: 0, labels: new Set() });
3738
- var ag = crossGroupAgg.get(k);
3739
- ag.count += edge.count;
3740
- ag.callCount += (edge.callCount || 0);
3741
- ag.labels.add(edge.label);
3914
+ function overlayFlowOnArchitecture(g, layout) {
3915
+ var map = state.map;
3916
+ var flow = map.flows.find(function (f) { return f.id === state.selectedFlow; });
3917
+ if (!flow) return;
3918
+ var intraStack = new Map();
3919
+ for (var si=0; si<flow.steps.length; si++) {
3920
+ var step = flow.steps[si];
3921
+ var fromKey = layout.packagePos.has(step.from) ? step.from : step.boxFrom;
3922
+ var toKey = layout.packagePos.has(step.to) ? step.to : step.boxTo;
3923
+ var from = layout.packagePos.get(fromKey);
3924
+ var to = layout.packagePos.get(toKey);
3925
+ if (!from || !to) continue;
3926
+ if (fromKey === toKey) {
3927
+ var idx = intraStack.get(fromKey) || 0;
3928
+ intraStack.set(fromKey, idx + 1);
3929
+ var px = from.x + from.w - 16, py = from.y + from.h - 12 - idx * 18;
3930
+ if (py < from.y + 36) { px = from.x + from.w - 16 - 20; py = from.y + from.h - 12; }
3931
+ var chip = el("g", { transform: "translate(" + px + "," + py + ")" });
3932
+ chip.appendChild(el("circle", { r: 7, fill: "#ffdf5d", stroke: "#0b0d10", "stroke-width": 1.5 }));
3933
+ chip.appendChild(el("text", { y: 3, "text-anchor": "middle", "font-size": 9, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3934
+ g.appendChild(chip);
3742
3935
  continue;
3743
3936
  }
3744
- var fromPos = layout.packagePos.get(edge.from);
3745
- var toPos = layout.packagePos.get(edge.to);
3746
- if (!fromPos || !toPos) continue;
3747
- var x1, y1, x2, y2;
3748
- // Pick anchor points on box edges nearest to the other box
3749
- var fcx = fromPos.x + fromPos.w/2, fcy = fromPos.y + fromPos.h/2;
3750
- var tcx = toPos.x + toPos.w/2, tcy = toPos.y + toPos.h/2;
3751
- if (Math.abs(tcx - fcx) >= Math.abs(tcy - fcy)) {
3752
- // Horizontal-dominant: use right/left edges
3753
- x1 = tcx > fcx ? fromPos.x + fromPos.w : fromPos.x;
3754
- y1 = fcy;
3755
- x2 = tcx > fcx ? toPos.x : toPos.x + toPos.w;
3756
- y2 = tcy;
3757
- } else {
3758
- x1 = fcx;
3759
- y1 = tcy > fcy ? fromPos.y + fromPos.h : fromPos.y;
3760
- x2 = tcx;
3761
- y2 = tcy > fcy ? toPos.y : toPos.y + toPos.h;
3762
- }
3763
- var sw = Math.min(4, 0.8 + Math.log2(edge.count + 1));
3764
- var path = el("path", {
3765
- class: "edge cross",
3766
- d: flowPath(x1, y1, x2, y2),
3767
- "stroke-width": sw,
3768
- "marker-end": "url(#arrow-cross)",
3937
+ var x1 = from.x + from.w / 2, y1 = from.y + from.h / 2;
3938
+ var x2 = to.x + to.w / 2, y2 = to.y + to.h / 2;
3939
+ g.appendChild(el("path", { class: "edge flow", d: flowPath(x1, y1, x2, y2), "stroke-width": 2, "marker-end": "url(#arrow-flow)" }));
3940
+ var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3941
+ var chip2 = el("g", { transform: "translate(" + mx + "," + my + ")" });
3942
+ chip2.appendChild(el("circle", { r: 10, fill: "#ffdf5d", stroke: "#0b0d10", "stroke-width": 2 }));
3943
+ chip2.appendChild(el("text", { y: 4, "text-anchor": "middle", "font-size": 10, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3944
+ g.appendChild(chip2);
3945
+ }
3946
+ }
3947
+
3948
+ // ====================== Flows (swimlane) view ======================
3949
+ // Packs each lane into as many COLUMNS as needed to keep every box readable (defect 11: a single
3950
+ // column with 22 boxes overflowed the viewport with no way to reach the rest).
3951
+ function layoutSwimlanes(map, W, H) {
3952
+ var lanes = map.lanes || [];
3953
+ if (!lanes.length) return { lanePos: new Map(), boxPos: new Map() };
3954
+ var laneW = W / lanes.length;
3955
+ var lanePos = new Map();
3956
+ lanes.forEach(function (lane, i) {
3957
+ lanePos.set(lane.id, { x: laneW * i, w: laneW, lane: lane });
3958
+ });
3959
+ var boxesByLane = new Map();
3960
+ lanes.forEach(function (l) { boxesByLane.set(l.id, []); });
3961
+ (map.boxes || []).forEach(function (b) {
3962
+ if (boxesByLane.has(b.lane)) boxesByLane.get(b.lane).push(b);
3963
+ });
3964
+ boxesByLane.forEach(function (arr) {
3965
+ arr.sort(function (a,b) {
3966
+ if ((a.hasEntry?1:0) !== (b.hasEntry?1:0)) return (b.hasEntry?1:0) - (a.hasEntry?1:0);
3967
+ return a.title.localeCompare(b.title);
3769
3968
  });
3770
- g.appendChild(path);
3771
- // Wide invisible hit area for hover
3772
- var hit = el("path", {
3773
- class: "edge-hit", d: flowPath(x1, y1, x2, y2),
3969
+ });
3970
+ var headerH = 36;
3971
+ var boxPos = new Map();
3972
+ boxesByLane.forEach(function (arr, laneId) {
3973
+ var lp = lanePos.get(laneId);
3974
+ if (!arr.length) return;
3975
+ var availH = H - headerH - 24;
3976
+ var minBoxH = 46, boxPad = 10;
3977
+ var maxRows = Math.max(1, Math.floor(availH / (minBoxH + boxPad)));
3978
+ var cols = Math.max(1, Math.ceil(arr.length / maxRows));
3979
+ var rows = Math.ceil(arr.length / cols);
3980
+ var colW = (lp.w - 16) / cols;
3981
+ var boxH = Math.min(64, Math.max(minBoxH, availH / rows - boxPad));
3982
+ var totalH = rows * (boxH + boxPad);
3983
+ var startY = headerH + Math.max(8, (availH - totalH) / 2);
3984
+ arr.forEach(function (box, i) {
3985
+ var col = Math.floor(i / rows), row = i % rows;
3986
+ // B6 fix: a hard 110px floor could exceed colW once many columns were needed, so a box's
3987
+ // width could overrun into the next column (or, at the last lane column, the next lane) —
3988
+ // clamp to colW so boxes/columns/lanes never overlap, degrading readability before geometry.
3989
+ var w = Math.max(1, Math.min(110, colW - 14));
3990
+ var x = lp.x + 8 + col * colW + (colW - w) / 2;
3991
+ boxPos.set(box.id, { x: x, y: startY + row * (boxH + boxPad), w: w, h: boxH, box: box, laneId: laneId });
3774
3992
  });
3775
- g.appendChild(hit);
3776
- hit.addEventListener("mouseenter", function (e) { return function (ev) { showTooltip(tooltipEdgeHtml(e), ev); }; }(edge));
3777
- hit.addEventListener("mousemove", function (e) { return function (ev) { showTooltip(tooltipEdgeHtml(e), ev); }; }(edge));
3778
- hit.addEventListener("mouseleave", hideTooltip);
3779
- // Label chip at midpoint (skip when edge is too short)
3780
- var dist = Math.hypot(x2 - x1, y2 - y1);
3781
- if (dist >= 80 && edge.label) {
3782
- var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3783
- var labelText = edge.label;
3784
- var chipW = labelText.length * 6.2 + 12;
3785
- var labelClass = "edge-label-text " + labelText.toLowerCase().replace(/\s+/g, "-");
3786
- var chip = el("g", { transform: "translate(" + mx + "," + my + ")" });
3787
- chip.appendChild(el("rect", { class: "edge-label-bg", x: -chipW/2, y: -9, width: chipW, height: 18, rx: 4, ry: 4 }));
3788
- chip.appendChild(el("text", { class: labelClass, x: 0, y: 3, "text-anchor": "middle" }, labelText));
3993
+ });
3994
+ return { lanePos: lanePos, boxPos: boxPos };
3995
+ }
3996
+
3997
+ function renderFlowsSwimlane(g, W, H) {
3998
+ var map = state.map;
3999
+ var layout = layoutSwimlanes(map, W, H);
4000
+ layout.lanePos.forEach(function (lp) {
4001
+ // B6 fix: lane names were drawn full-length with no width limit, so a wide name (e.g.
4002
+ // "APPLICATION") ran straight into the next lane's label ("APPLICATIONDOMAIN"). Truncate to
4003
+ // what actually fits the lane's own width (uppercase + 1.5px letter-spacing ~9px/char).
4004
+ var maxChars = Math.max(3, Math.floor((lp.w - 20) / 9));
4005
+ g.appendChild(el("text", { class: "layer-label", x: lp.x + 14, y: 22 }, truncate(lp.lane.name, maxChars)));
4006
+ g.appendChild(el("line", { class: "lane-divider", x1: lp.x + lp.w, y1: 0, x2: lp.x + lp.w, y2: H }));
4007
+ });
4008
+
4009
+ var flow = state.selectedFlow ? map.flows.find(function (f) { return f.id === state.selectedFlow; }) : null;
4010
+ var inFlow = new Set();
4011
+ var selectedStepIds = new Set();
4012
+ if (flow) {
4013
+ for (var i=0; i<flow.steps.length; i++) {
4014
+ inFlow.add(flow.steps[i].from); inFlow.add(flow.steps[i].to);
4015
+ if (state.selectedStep && flow.steps[i].n === state.selectedStep) {
4016
+ selectedStepIds.add(flow.steps[i].from); selectedStepIds.add(flow.steps[i].to);
4017
+ }
4018
+ }
4019
+ }
4020
+
4021
+ layout.boxPos.forEach(function (p, id) {
4022
+ var box = p.box;
4023
+ var hi = flow && inFlow.has(id);
4024
+ var dim = flow && !inFlow.has(id);
4025
+ var isSelStep = selectedStepIds.has(id);
4026
+ var node = el("g", { class: "flow-box", transform: "translate(" + p.x + "," + p.y + ")" });
4027
+ var rect = el("rect", {
4028
+ width: p.w, height: p.h, rx: 6, ry: 6,
4029
+ fill: hi ? (isSelStep ? "#3a2c00" : "#1f1900") : "rgba(28,37,48,0.55)",
4030
+ stroke: hi ? "#ffdf5d" : (box.color || "#3a4858"),
4031
+ "stroke-width": hi ? (isSelStep ? 2 : 1.4) : 1,
4032
+ opacity: dim ? 0.12 : 1,
4033
+ });
4034
+ node.appendChild(rect);
4035
+ node.appendChild(el("text", { x: 12, y: 19, fill: hi ? "#ffdf5d" : "#e6edf3",
4036
+ "font-size": 12, "font-weight": 600, opacity: dim ? 0.2 : 1 }, truncate(box.title, 28)));
4037
+ if (box.sublabel) {
4038
+ node.appendChild(el("text", { x: 12, y: 35, fill: "#8b949e", "font-size": 10,
4039
+ opacity: dim ? 0.2 : 1 }, truncate(box.sublabel, 36)));
4040
+ }
4041
+ node.addEventListener("click", function (ev) { ev.stopPropagation(); selectNode("box", id); });
4042
+ g.appendChild(node);
4043
+ });
4044
+
4045
+ if (flow) {
4046
+ var arrowDefs = el("defs");
4047
+ arrowDefs.innerHTML = '<marker id="arrowhead-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#ffdf5d"/></marker>';
4048
+ g.appendChild(arrowDefs);
4049
+ var intraStackByBox = new Map();
4050
+ function chipAt(boxPos, idx, step, active) {
4051
+ var px = boxPos.x + boxPos.w - 18;
4052
+ var py = boxPos.y + boxPos.h - 12 - idx * 20;
4053
+ if (py < boxPos.y + 36) { px = boxPos.x + boxPos.w - 18 - 22; py = boxPos.y + boxPos.h - 12; }
4054
+ var chip = el("g", { class: "step-num" + (active ? " active" : ""), transform: "translate(" + px + "," + py + ")" });
4055
+ chip.appendChild(el("circle", { r: 8, fill: active ? "#fff" : "#ffdf5d", stroke: "#0b0d10", "stroke-width": 1.5 }));
4056
+ chip.appendChild(el("text", { y: 3, "text-anchor": "middle", "font-size": 9, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
4057
+ chip.addEventListener("click", function (n) { return function (ev) { ev.stopPropagation(); selectStep(n); }; }(step.n));
3789
4058
  g.appendChild(chip);
3790
4059
  }
4060
+ for (var si=0; si<flow.steps.length; si++) {
4061
+ var step = flow.steps[si];
4062
+ var fromKey = layout.boxPos.has(step.from) ? step.from : step.boxFrom;
4063
+ var toKey = layout.boxPos.has(step.to) ? step.to : step.boxTo;
4064
+ var from = layout.boxPos.get(fromKey);
4065
+ var to = layout.boxPos.get(toKey);
4066
+ if (!from || !to) continue;
4067
+ var active = state.selectedStep === step.n;
4068
+ var intra = fromKey === toKey;
4069
+ if (intra) {
4070
+ var idx2 = intraStackByBox.get(fromKey) || 0;
4071
+ intraStackByBox.set(fromKey, idx2 + 1);
4072
+ chipAt(from, idx2, step, active);
4073
+ continue;
4074
+ }
4075
+ var sameLane = from.laneId === to.laneId;
4076
+ var x1, y1, x2, y2;
4077
+ if (sameLane) {
4078
+ if (to.y > from.y) { x1 = from.x + from.w / 2; y1 = from.y + from.h; x2 = to.x + to.w / 2; y2 = to.y; }
4079
+ else { x1 = from.x + from.w / 2; y1 = from.y; x2 = to.x + to.w / 2; y2 = to.y + to.h; }
4080
+ } else if (to.x > from.x) {
4081
+ x1 = from.x + from.w; y1 = from.y + from.h / 2; x2 = to.x; y2 = to.y + to.h / 2;
4082
+ } else {
4083
+ x1 = from.x; y1 = from.y + from.h / 2; x2 = to.x + to.w; y2 = to.y + to.h / 2;
4084
+ }
4085
+ var p = el("path", { class: "edge flow" + (active ? " active" : ""), d: flowPath(x1, y1, x2, y2),
4086
+ "marker-end": "url(#arrowhead-flow)", opacity: active || state.selectedStep == null ? 0.95 : 0.35 });
4087
+ g.appendChild(p);
4088
+ var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
4089
+ var circle = el("g", { class: "step-num" + (active ? " active" : ""), transform: "translate(" + mx + "," + my + ")" });
4090
+ circle.appendChild(el("circle", { r: 11, fill: active ? "#fff" : "#ffdf5d", stroke: "#0b0d10", "stroke-width": 2 }));
4091
+ circle.appendChild(el("text", { y: 4, "text-anchor": "middle", "font-size": 11, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
4092
+ circle.addEventListener("click", function (n) { return function (ev) { ev.stopPropagation(); selectStep(n); }; }(step.n));
4093
+ g.appendChild(circle);
4094
+ }
3791
4095
  }
4096
+ }
3792
4097
 
3793
- // 5) Collapsed group → group aggregate edges
3794
- crossGroupAgg.forEach(function (agg) {
3795
- var fromGP = layout.groupPos.get(agg.from), toGP = layout.groupPos.get(agg.to);
3796
- if (!fromGP || !toGP) return;
3797
- var fcx = fromGP.x + fromGP.w/2, fcy = fromGP.y + fromGP.h/2;
3798
- var tcx = toGP.x + toGP.w/2, tcy = toGP.y + toGP.h/2;
3799
- var x1, y1, x2, y2;
3800
- if (Math.abs(tcx - fcx) >= Math.abs(tcy - fcy)) {
3801
- x1 = tcx > fcx ? fromGP.x + fromGP.w : fromGP.x; y1 = fcy;
3802
- x2 = tcx > fcx ? toGP.x : toGP.x + toGP.w; y2 = tcy;
3803
- } else {
3804
- x1 = fcx; y1 = tcy > fcy ? fromGP.y + fromGP.h : fromGP.y;
3805
- x2 = tcx; y2 = tcy > fcy ? toGP.y : toGP.y + toGP.h;
3806
- }
3807
- g.appendChild(el("path", { class: "edge cross", d: flowPath(x1, y1, x2, y2),
3808
- "stroke-width": Math.min(6, 1.5 + Math.log2(agg.count + 1)),
3809
- "marker-end": "url(#arrow-cross)" }));
3810
- var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3811
- var labelText = (agg.from.replace(/^group:/, "") + " → " + agg.to.replace(/^group:/, "") + " · " + agg.count);
3812
- var chipW = labelText.length * 6.2 + 12;
3813
- var chip = el("g", { transform: "translate(" + mx + "," + my + ")" });
3814
- chip.appendChild(el("rect", { class: "edge-label-bg", x: -chipW/2, y: -9, width: chipW, height: 18, rx: 4, ry: 4 }));
3815
- chip.appendChild(el("text", { class: "edge-label-text", x: 0, y: 3, "text-anchor": "middle" }, labelText));
3816
- g.appendChild(chip);
3817
- });
4098
+ function selectFlow(flowId) {
4099
+ state.selectedFlow = flowId;
4100
+ state.selectedStep = null;
4101
+ setView("flows");
4102
+ renderFlowList();
4103
+ renderStepList();
4104
+ }
4105
+ function selectStep(n) {
4106
+ state.selectedStep = n;
4107
+ renderStepList();
4108
+ render();
4109
+ }
4110
+ function clearFlow() {
4111
+ state.selectedFlow = null;
4112
+ state.selectedStep = null;
4113
+ state.selected = null;
4114
+ clearFocus();
4115
+ renderFlowList();
4116
+ renderStepList();
4117
+ }
4118
+
4119
+ // ====================== Graph view (canvas 2D, force-directed) ======================
3818
4120
 
3819
- // 6) Optional yellow flow overlay
3820
- if (state.overlayFlow && state.selectedFlow) {
3821
- overlayFlowOnArchitecture(g, layout);
4121
+ // Hand-written Barnes-Hut quadtree for O(n log n) repulsion between nodes.
4122
+ function Quad(x0, y0, x1, y1) {
4123
+ this.x0 = x0; this.y0 = y0; this.x1 = x1; this.y1 = y1;
4124
+ this.mass = 0; this.cx = 0; this.cy = 0;
4125
+ this.node = null; this.children = null;
4126
+ }
4127
+ Quad.prototype._childFor = function (n) {
4128
+ var mx = (this.x0 + this.x1) / 2, my = (this.y0 + this.y1) / 2;
4129
+ var idx = (n.x < mx ? 0 : 1) + (n.y < my ? 0 : 2);
4130
+ return this.children[idx];
4131
+ };
4132
+ Quad.prototype.insert = function (n, depth) {
4133
+ depth = depth || 0;
4134
+ if (this.node === null && this.children === null) { this.node = n; return; }
4135
+ if (depth > 20) { // degenerate / coincident points — approximate rather than recurse forever
4136
+ this.mass += 1; this.cx = (this.cx * (this.mass - 1) + n.x) / this.mass; this.cy = (this.cy * (this.mass - 1) + n.y) / this.mass;
4137
+ return;
4138
+ }
4139
+ if (this.children === null) {
4140
+ var mx = (this.x0 + this.x1) / 2, my = (this.y0 + this.y1) / 2;
4141
+ this.children = [
4142
+ new Quad(this.x0, this.y0, mx, my), new Quad(mx, this.y0, this.x1, my),
4143
+ new Quad(this.x0, my, mx, this.y1), new Quad(mx, my, this.x1, this.y1),
4144
+ ];
4145
+ var old = this.node; this.node = null;
4146
+ this._childFor(old).insert(old, depth + 1);
4147
+ }
4148
+ this._childFor(n).insert(n, depth + 1);
4149
+ };
4150
+ Quad.prototype.computeMass = function () {
4151
+ if (this.node) { this.mass = 1; this.cx = this.node.x; this.cy = this.node.y; return; }
4152
+ if (!this.children) return;
4153
+ var m = 0, sx = 0, sy = 0;
4154
+ for (var i=0; i<4; i++) {
4155
+ var c = this.children[i]; c.computeMass();
4156
+ m += c.mass; sx += c.cx * c.mass; sy += c.cy * c.mass;
3822
4157
  }
4158
+ if (m > 0) { this.mass = m; this.cx = sx / m; this.cy = sy / m; }
4159
+ };
4160
+ Quad.prototype.applyForce = function (n, theta, strength, out) {
4161
+ if (this.mass === 0 || this.node === n) return;
4162
+ var dx = this.cx - n.x, dy = this.cy - n.y;
4163
+ var distSq = dx * dx + dy * dy;
4164
+ if (distSq < 0.0001) distSq = 0.0001;
4165
+ var size = this.x1 - this.x0;
4166
+ if (this.node || (size * size) / distSq < theta * theta) {
4167
+ var dist = Math.sqrt(distSq);
4168
+ var f = strength * this.mass / distSq;
4169
+ out.x -= (dx / dist) * f; out.y -= (dy / dist) * f;
4170
+ return;
4171
+ }
4172
+ for (var i=0; i<4; i++) this.children[i].applyForce(n, theta, strength, out);
4173
+ };
4174
+ function buildQuadtree(nodes) {
4175
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
4176
+ for (var i=0; i<nodes.length; i++) {
4177
+ var n = nodes[i];
4178
+ if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x;
4179
+ if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y;
4180
+ }
4181
+ if (!isFinite(minX)) { minX = -1; maxX = 1; minY = -1; maxY = 1; }
4182
+ var pad = Math.max(1, Math.max(maxX - minX, maxY - minY)) * 0.1;
4183
+ var root = new Quad(minX - pad, minY - pad, maxX + pad, maxY + pad);
4184
+ for (var j=0; j<nodes.length; j++) root.insert(nodes[j], 0);
4185
+ root.computeMass();
4186
+ return root;
3823
4187
  }
3824
4188
 
3825
- function overlayFlowOnArchitecture(g, layout) {
3826
- var map = state.map;
3827
- var flow = map.flows.find(function (f) { return f.id === state.selectedFlow; });
3828
- if (!flow) return;
3829
- var intraStack = new Map();
3830
- for (var si=0; si<flow.steps.length; si++) {
3831
- var step = flow.steps[si];
3832
- var fromKey = layout.packagePos.has(step.from) ? step.from : step.boxFrom;
3833
- var toKey = layout.packagePos.has(step.to) ? step.to : step.boxTo;
3834
- var from = layout.packagePos.get(fromKey);
3835
- var to = layout.packagePos.get(toKey);
3836
- if (!from || !to) continue;
3837
- if (fromKey === toKey) {
3838
- // Intra-package chip
3839
- var idx = intraStack.get(fromKey) || 0;
3840
- intraStack.set(fromKey, idx + 1);
3841
- var px = from.x + from.w - 16, py = from.y + from.h - 12 - idx * 18;
3842
- if (py < from.y + 36) { px = from.x + from.w - 16 - 20; py = from.y + from.h - 12; }
3843
- var chip = el("g", { transform: "translate(" + px + "," + py + ")" });
3844
- chip.appendChild(el("circle", { r: 7, fill: "#ffdf5d", stroke: "#0b0d10", "stroke-width": 1.5 }));
3845
- chip.appendChild(el("text", { y: 3, "text-anchor": "middle", "font-size": 9, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3846
- g.appendChild(chip);
3847
- continue;
3848
- }
3849
- var x1 = from.x + from.w / 2, y1 = from.y + from.h / 2;
3850
- var x2 = to.x + to.w / 2, y2 = to.y + to.h / 2;
3851
- g.appendChild(el("path", { class: "edge flow", d: flowPath(x1, y1, x2, y2),
3852
- "stroke-width": 2, "marker-end": "url(#arrow-flow)" }));
3853
- var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
3854
- var chip2 = el("g", { transform: "translate(" + mx + "," + my + ")" });
3855
- chip2.appendChild(el("circle", { r: 10, fill: "#ffdf5d", stroke: "#0b0d10", "stroke-width": 2 }));
3856
- chip2.appendChild(el("text", { y: 4, "text-anchor": "middle", "font-size": 10, "font-weight": 700, fill: "#0b0d10" }, String(step.n)));
3857
- g.appendChild(chip2);
3858
- }
4189
+ function resolveGraphEndpoint(mid) {
4190
+ var m = state.idx.modById.get(mid);
4191
+ var cid = m ? m.cluster : null;
4192
+ if (cid && state.graph.collapsedClusters.has(cid)) return cid;
4193
+ return mid;
3859
4194
  }
3860
4195
 
3861
- function renderSystem(g, W, H) {
3862
- var map = state.map;
3863
- var layout = layoutSystem(map, W, H);
3864
- var bandH = H / LAYER_ORDER.length;
3865
- for (var i=0; i<LAYER_ORDER.length; i++) {
3866
- var l = LAYER_ORDER[i];
3867
- g.appendChild(el("text", { class:"layer-label", x: 10, y: bandH * i + 14 }, l));
3868
- g.appendChild(el("line", { class:"layer-band", x1: 0, x2: W, y1: bandH * (i+1), y2: bandH * (i+1) }));
3869
- }
3870
- for (var ei=0; ei<map.containerEdges.length; ei++) {
3871
- var ed = map.containerEdges[ei];
3872
- var a = layout.positions.get(ed.from), b = layout.positions.get(ed.to);
3873
- if (!a || !b) continue;
3874
- var ax = a.x + a.w / 2, ay = a.y + a.h / 2;
3875
- var bx = b.x + b.w / 2, by = b.y + b.h / 2;
3876
- var hi = state.focus && state.focus.kind === "package" && (state.focus.id === ed.from || state.focus.id === ed.to);
3877
- var sw = Math.min(6, 1 + Math.log2(ed.count + 1));
3878
- g.appendChild(arrow(ax, ay, bx, by, { class: "edge cross" + (hi ? " hi" : ""), "stroke-width": sw }));
4196
+ function moduleRadius(m) {
4197
+ if (state.sizeByDegree) {
4198
+ var deg = (state.graph.degree.get(m.id) || 0);
4199
+ return Math.max(2, Math.min(14, 2 + Math.sqrt(deg + 1) * 2.2));
3879
4200
  }
3880
- layout.positions.forEach(function (p, id) {
3881
- var c = p.container;
3882
- var hi = (state.focus && state.focus.kind === "package" && state.focus.id === id);
3883
- var rect = el("rect", { class: "container-rect" + (hi ? " hi" : ""),
3884
- x: p.x, y: p.y, width: p.w, height: p.h, rx: 10, ry: 10 });
3885
- rect.style.cursor = "pointer";
3886
- rect.addEventListener("click", function (ev) { ev.stopPropagation(); focusPackage(id); });
3887
- g.appendChild(rect);
3888
- g.appendChild(el("text", { class: "container-label", x: p.x + 10, y: p.y + 16 }, c.name));
3889
- g.appendChild(el("text", { class: "container-label", x: p.x + 10, y: p.y + 32, fill: LAYER_COLOR[c.dominant] },
3890
- c.dominant + " · " + c.moduleCount + "m" + (c.hasEntry ? " · entry" : "") + (c.sinks.length ? " · " + c.sinks.length + " sinks" : "")));
3891
- });
4201
+ return Math.max(2, Math.min(12, 1.6 + Math.sqrt((m.loc || 1) / 30)));
4202
+ }
4203
+ function clusterRadius(c) {
4204
+ return Math.max(10, Math.min(46, 6 + Math.sqrt(c.size) * 3.4));
3892
4205
  }
3893
4206
 
3894
- function renderModules(g, W, H) {
4207
+ // Build the (mostly) static per-map graph model: cluster seed centroids, degree table, sink/entry
4208
+ // sets. Positions live in a durable Map so toggling expand/collapse doesn't jump nodes around.
4209
+ function ensureGraphModel() {
4210
+ if (state.graph) return;
3895
4211
  var map = state.map;
3896
- var layout = layoutModules(map, W, H);
3897
- layout.containerPos.forEach(function (p, cid) {
3898
- var hi = (state.focus && state.focus.kind === "package" && state.focus.id === cid);
3899
- var r = el("rect", { class: "container-rect" + (hi ? " hi" : ""),
3900
- x: p.x, y: p.y, width: p.w, height: p.h, rx: 8, ry: 8 });
3901
- g.appendChild(r);
3902
- g.appendChild(el("text", { class: "container-label", x: p.x + 8, y: p.y + 14 }, p.container.name));
4212
+ var clusters = (map.clusters || []).slice().sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
4213
+ var clusterPos = new Map();
4214
+ clusters.forEach(function (c, i) {
4215
+ var ang = i * GOLD;
4216
+ var rad = 90 * Math.sqrt(i + 1);
4217
+ clusterPos.set(c.id, { x: Math.cos(ang) * rad, y: Math.sin(ang) * rad });
3903
4218
  });
3904
- for (var i=0; i<map.edges.length; i++) {
3905
- var e = map.edges[i]; if (e.kind !== "import") continue;
3906
- var a = layout.modulePos.get(e.from), b = layout.modulePos.get(e.to);
3907
- if (!a || !b) continue;
3908
- var focused = state.focus && state.focus.set.has(e.from) && state.focus.set.has(e.to);
3909
- var dim = state.focus && !focused;
3910
- var cross = containerIdOf(a.m) !== containerIdOf(b.m);
3911
- g.appendChild(arrow(a.x, a.y, b.x, b.y, { class: "edge" + (cross ? " cross" : "") + (dim ? " dim" : "") + (focused ? " hi" : ""), "stroke-width": cross ? 1.4 : 0.8 }));
3912
- }
3913
- layout.modulePos.forEach(function (p, id) {
3914
- var m = p.m;
3915
- var dim = state.focus && !state.focus.set.has(id);
3916
- var color = m.entryPoint ? "#ffdf5d" : (m.sinks && m.sinks.length > 0 ? "#ff7b72" : LAYER_COLOR[layerOf(m)]);
3917
- var r = Math.max(2.5, Math.min(10, 2 + Math.sqrt(Math.max(1, m.loc / 25))));
3918
- var node = el("g", { class: "module-node", transform: "translate(" + p.x + "," + p.y + ")" });
3919
- if (dim) node.style.opacity = 0.15;
3920
- node.appendChild(el("circle", { r: r, fill: color }));
3921
- if (m.entryPoint) node.appendChild(el("circle", { r: r + 3, fill: "none", stroke: "#ffdf5d", "stroke-width": 1 }));
3922
- g.appendChild(node);
4219
+ var degree = new Map();
4220
+ (map.edges || []).forEach(function (e) {
4221
+ if (e.kind !== "import" && e.kind !== "re-export") return;
4222
+ degree.set(e.from, (degree.get(e.from) || 0) + 1);
4223
+ degree.set(e.to, (degree.get(e.to) || 0) + 1);
3923
4224
  });
4225
+ state.graph = {
4226
+ clusters: clusters,
4227
+ clusterPos: clusterPos,
4228
+ degree: degree,
4229
+ collapsedClusters: new Set(clusters.map(function (c) { return c.id; })), // default: all collapsed
4230
+ pos: new Map(), // node id -> {x,y,vx,vy}
4231
+ pinned: new Set(),
4232
+ nodes: [],
4233
+ nodeIndex: new Map(),
4234
+ edges: [],
4235
+ running: false,
4236
+ alpha: 1,
4237
+ ticks: 0,
4238
+ dragId: null,
4239
+ // B3: the initial camera fit() runs once, right when clusters are still at their tight seed
4240
+ // positions; repulsion then spreads the force layout out over the next ~300 ticks, so the
4241
+ // camera can end up framing empty space by the time it settles. settleFitDone/userTouchedCamera
4242
+ // let simTick re-fit exactly once when the sim first settles, but only if the user hasn't
4243
+ // already taken control of the camera (panned/zoomed) in the meantime.
4244
+ settleFitDone: false,
4245
+ userTouchedCamera: false,
4246
+ };
4247
+ rebuildVisibleGraph();
3924
4248
  }
3925
4249
 
3926
- // ====================== Insights view (clusters · god-nodes · surprising connections) ======================
3927
- function clusterColor(cid) {
3928
- if (!cid) return "#8b949e";
3929
- var h = 0; for (var i=0; i<cid.length; i++) h = (h * 31 + cid.charCodeAt(i)) % 360;
3930
- return "hsl(" + h + ",58%,62%)";
4250
+ function graphSeedPos(id, seedFn) {
4251
+ var pos = state.graph.pos;
4252
+ if (!pos.has(id)) pos.set(id, Object.assign({ vx: 0, vy: 0 }, seedFn()));
4253
+ return pos.get(id);
3931
4254
  }
3932
- function renderInsights(g, W, H) {
3933
- var map = state.map;
3934
- var layout = layoutModules(map, W, H);
3935
- var god = (map.hotspots && map.hotspots.godNodes) || [];
3936
- var godIds = new Set(god.map(function (x) { return x.id; }));
3937
- var godBy = {}; god.forEach(function (x) { godBy[x.id] = x; });
3938
- layout.containerPos.forEach(function (p) {
3939
- g.appendChild(el("rect", { class: "container-rect", x: p.x, y: p.y, width: p.w, height: p.h, rx: 8, ry: 8 }));
3940
- g.appendChild(el("text", { class: "container-label", x: p.x + 8, y: p.y + 14 }, p.container.name));
4255
+
4256
+ function rebuildVisibleGraph() {
4257
+ var g = state.graph, map = state.map;
4258
+ var collapsed = g.collapsedClusters;
4259
+ var nodes = [];
4260
+ g.clusters.forEach(function (c) {
4261
+ var centroid = g.clusterPos.get(c.id) || { x: 0, y: 0 };
4262
+ if (collapsed.has(c.id)) {
4263
+ var pos = graphSeedPos(c.id, function () { return { x: centroid.x, y: centroid.y }; });
4264
+ nodes.push({ id: c.id, kind: "cluster", cluster: c.id, r: clusterRadius(c), pos: pos, isTest: false, isEntry: false, isSink: false, label: c.label, size: c.size });
4265
+ } else {
4266
+ var members = c.members.slice().sort();
4267
+ members.forEach(function (mid, mi) {
4268
+ var m = state.idx.modById.get(mid); if (!m) return;
4269
+ var pos = graphSeedPos(mid, function () {
4270
+ var ang = mi * GOLD, rad = 10 + Math.sqrt(mi) * 12;
4271
+ return { x: centroid.x + Math.cos(ang) * rad, y: centroid.y + Math.sin(ang) * rad };
4272
+ });
4273
+ nodes.push({
4274
+ id: mid, kind: "module", cluster: c.id, m: m, r: moduleRadius(m), pos: pos,
4275
+ isTest: !!m.isTest, isEntry: state.idx.entryIds.has(mid), isSink: state.idx.sinkIds.has(mid),
4276
+ });
4277
+ });
4278
+ }
3941
4279
  });
3942
- // Surprising connections as hot dashed edges (only these — keeps the view legible).
3943
- ((map.surprisingConnections) || []).forEach(function (x) {
3944
- var a = layout.modulePos.get(x.from), b = layout.modulePos.get(x.to);
3945
- if (!a || !b) return;
3946
- var p = arrow(a.x, a.y, b.x, b.y, { class: "edge", stroke: "#ff5c5c", "stroke-width": 1.6, "stroke-dasharray": "4 3", opacity: 0.85 });
3947
- p.appendChild(el("title", null, "surprising: " + x.reason + " (score " + x.score + ")"));
3948
- g.appendChild(p);
4280
+ g.nodes = nodes;
4281
+ g.nodeIndex = new Map(nodes.map(function (n) { return [n.id, n]; }));
4282
+ g.nodeIdxOf = new Map(nodes.map(function (n, i) { return [n.id, i]; }));
4283
+
4284
+ var edgeAgg = new Map();
4285
+ (map.edges || []).forEach(function (e) {
4286
+ if (e.kind !== "import" && e.kind !== "re-export") return;
4287
+ var a = resolveGraphEndpoint(e.from), b = resolveGraphEndpoint(e.to);
4288
+ if (a === b || !g.nodeIndex.has(a) || !g.nodeIndex.has(b)) return;
4289
+ var key = a + "→" + b;
4290
+ var agg = edgeAgg.get(key);
4291
+ if (!agg) { agg = { from: a, to: b, count: 0, conf: { EXTRACTED: 0, INFERRED: 0, AMBIGUOUS: 0 } }; edgeAgg.set(key, agg); }
4292
+ agg.count++;
4293
+ agg.conf[e.confidence] = (agg.conf[e.confidence] || 0) + 1;
3949
4294
  });
3950
- // Module nodes colored by cluster; god-nodes enlarged + outlined.
3951
- layout.modulePos.forEach(function (p, id) {
3952
- var m = p.m;
3953
- var isGod = godIds.has(id);
3954
- var r = isGod ? Math.max(6, Math.min(16, 5 + Math.sqrt((m.loc || 1) / 20))) : Math.max(2, Math.min(8, 1.5 + Math.sqrt(Math.max(1, (m.loc || 1) / 30))));
3955
- var node = el("g", { class: "module-node", transform: "translate(" + p.x + "," + p.y + ")" });
3956
- var c = el("circle", { r: r, fill: clusterColor(m.cluster), stroke: isGod ? "#ffffff" : "none", "stroke-width": isGod ? 1.5 : 0 });
3957
- node.appendChild(c);
3958
- node.appendChild(el("title", null, m.path + (isGod ? " — god node (in " + godBy[id].inDegree + " · out " + godBy[id].outDegree + ")" : (m.cluster ? " — cluster " + m.cluster : ""))));
3959
- g.appendChild(node);
4295
+ g.edges = Array.from(edgeAgg.values()).map(function (a) {
4296
+ var confidence = a.conf.EXTRACTED ? "EXTRACTED" : a.conf.INFERRED ? "INFERRED" : "AMBIGUOUS";
4297
+ return { from: a.from, to: a.to, count: a.count, confidence: confidence };
3960
4298
  });
4299
+ restartSim();
4300
+ // The HUD line reports node/collapse counts, so every visible-graph rebuild must refresh it —
4301
+ // expand/collapse arrives via requestDraw (canvas only), which never touches the DOM status bar.
4302
+ if (state.view === "graph") { $("hud").textContent = hudText(); $("legend").innerHTML = legendHtml(); }
3961
4303
  }
3962
4304
 
3963
- // ===================== Knowledge-graph family (Concepts / Graph / Calls) =====================
3964
- // Deterministic radial placement of clusters around the canvas center (largest first).
3965
- function clusterCentroids(map, W, H) {
3966
- var clusters = (map.clusters || []).slice().sort(function (a, b) { return b.size - a.size || (a.id < b.id ? -1 : 1); });
3967
- var cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.40;
3968
- var pos = new Map();
3969
- var n = clusters.length || 1;
3970
- clusters.forEach(function (c, i) {
3971
- var ang = (i / n) * Math.PI * 2 - Math.PI / 2;
3972
- var ring = R * (0.5 + 0.5 * ((i % 3) / 2));
3973
- pos.set(c.id, { x: cx + Math.cos(ang) * ring, y: cy + Math.sin(ang) * ring, c: c });
3974
- });
3975
- return { clusters: clusters, pos: pos, cx: cx, cy: cy };
4305
+ function toggleCluster(clusterId) {
4306
+ var g = state.graph;
4307
+ if (g.collapsedClusters.has(clusterId)) g.collapsedClusters.delete(clusterId);
4308
+ else g.collapsedClusters.add(clusterId);
4309
+ rebuildVisibleGraph();
4310
+ requestDraw();
3976
4311
  }
3977
- // Cross-cluster import/re-export weights — the edges of the concept graph.
3978
- function crossClusterEdges(map) {
3979
- var cl = {}; (map.modules || []).forEach(function (m) { cl[m.id] = m.cluster; });
3980
- var w = {};
3981
- (map.edges || []).forEach(function (e) {
3982
- if (e.kind !== "import" && e.kind !== "re-export") return;
3983
- var a = cl[e.from], b = cl[e.to];
3984
- if (!a || !b || a === b) return;
3985
- var key = a < b ? a + "\t" + b : b + "\t" + a;
3986
- w[key] = (w[key] || 0) + 1;
3987
- });
3988
- return Object.keys(w).sort().map(function (k) { var p = k.split("\t"); return { a: p[0], b: p[1], weight: w[k] }; });
4312
+ // B1 fix: rebuild the Set wholesale rather than mutating collapsedClusters in place. A fresh
4313
+ // Set can never be left partially iterated/mutated (the class of bug that made "expand all"
4314
+ // expand exactly one cluster and then stall on every later click), and guards against
4315
+ // state.graph not existing yet (button clicked before the graph model has been built).
4316
+ function expandAllClusters() {
4317
+ var g = state.graph; if (!g) return;
4318
+ g.collapsedClusters = new Set();
4319
+ rebuildVisibleGraph();
4320
+ requestDraw();
4321
+ }
4322
+ function collapseAllClusters() {
4323
+ var g = state.graph; if (!g) return;
4324
+ g.collapsedClusters = new Set(g.clusters.map(function (c) { return c.id; }));
4325
+ rebuildVisibleGraph();
4326
+ requestDraw();
3989
4327
  }
3990
4328
 
3991
- // Concepts — cluster-level graph: nodes = clusters (sized by member count), edges = cross-cluster imports.
3992
- function renderConcepts(g, W, H) {
3993
- var map = state.map;
3994
- var L = clusterCentroids(map, W, H);
3995
- var edges = crossClusterEdges(map);
3996
- var maxW = edges.reduce(function (mx, e) { return Math.max(mx, e.weight); }, 1);
3997
- edges.forEach(function (e) {
3998
- var a = L.pos.get(e.a), b = L.pos.get(e.b); if (!a || !b) return;
3999
- var ln = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "#3a4658", "stroke-width": 0.6 + 3.6 * (e.weight / maxW), opacity: 0.5 });
4000
- ln.appendChild(el("title", null, e.a + " ↔ " + e.b + " — " + e.weight + " imports"));
4001
- g.appendChild(ln);
4002
- });
4003
- L.clusters.forEach(function (c) {
4004
- var p = L.pos.get(c.id); if (!p) return;
4005
- var r = Math.max(9, Math.min(48, 6 + Math.sqrt(c.size) * 3));
4006
- var node = el("g", { class: "module-node", transform: "translate(" + p.x + "," + p.y + ")" });
4007
- node.appendChild(el("circle", { r: r, fill: clusterColor(c.id), opacity: 0.85, stroke: "#0d1117", "stroke-width": 1.5 }));
4008
- node.appendChild(el("text", { "text-anchor": "middle", y: 4, fill: "#0d1117", "font-size": Math.max(9, Math.min(14, r / 2)), "font-weight": 700 }, String(c.size)));
4009
- node.appendChild(el("text", { "text-anchor": "middle", y: r + 13, fill: "#c9d1d9", "font-size": 11 }, (c.label || c.id).slice(0, 30)));
4010
- node.appendChild(el("title", null, (c.label || c.id) + " — " + c.size + " modules"));
4011
- g.appendChild(node);
4329
+ function restartSim() {
4330
+ var g = state.graph;
4331
+ g.alpha = 1; g.ticks = 0;
4332
+ if (!g.running) { g.running = true; requestAnimationFrame(simTick); }
4333
+ }
4334
+
4335
+ function simTick() {
4336
+ var g = state.graph;
4337
+ if (!g || !g.running) return;
4338
+ var nodes = g.nodes;
4339
+ if (nodes.length === 0) { g.running = false; return; }
4340
+ var flat = nodes.map(function (n) { return { x: n.pos.x, y: n.pos.y }; });
4341
+ var qt = buildQuadtree(flat);
4342
+ var REPULSE = 600, THETA = 0.9;
4343
+ var fx = new Float64Array(nodes.length), fy = new Float64Array(nodes.length);
4344
+ for (var i=0; i<nodes.length; i++) {
4345
+ var out = { x: 0, y: 0 };
4346
+ qt.applyForce(flat[i], THETA, REPULSE, out);
4347
+ fx[i] = out.x; fy[i] = out.y;
4348
+ }
4349
+ g.edges.forEach(function (e) {
4350
+ var a = g.nodeIndex.get(e.from), b = g.nodeIndex.get(e.to);
4351
+ if (!a || !b) return;
4352
+ var ai = g.nodeIdxOf.get(e.from), bi = g.nodeIdxOf.get(e.to);
4353
+ var dx = b.pos.x - a.pos.x, dy = b.pos.y - a.pos.y;
4354
+ var dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
4355
+ var L = 60 + a.r + b.r;
4356
+ var k = 0.015;
4357
+ var f = k * (dist - L);
4358
+ var ux = dx / dist, uy = dy / dist;
4359
+ fx[ai] += ux * f; fy[ai] += uy * f;
4360
+ fx[bi] -= ux * f; fy[bi] -= uy * f;
4012
4361
  });
4362
+ var alpha = g.alpha;
4363
+ for (var j=0; j<nodes.length; j++) {
4364
+ var n = nodes[j];
4365
+ fx[j] -= n.pos.x * 0.006; fy[j] -= n.pos.y * 0.006; // mild centering gravity
4366
+ if (state.graph.pinned.has(n.id) || n.id === g.dragId) { n.pos.vx = 0; n.pos.vy = 0; continue; }
4367
+ n.pos.vx = (n.pos.vx + fx[j] * alpha) * 0.80;
4368
+ n.pos.vy = (n.pos.vy + fy[j] * alpha) * 0.80;
4369
+ n.pos.x += n.pos.vx;
4370
+ n.pos.y += n.pos.vy;
4371
+ }
4372
+ g.alpha *= 0.985;
4373
+ g.ticks++;
4374
+ if (g.alpha < 0.008 || g.ticks > 300) {
4375
+ g.running = false;
4376
+ // B3: keep the camera framing the layout it actually settled into (see settleFitDone note
4377
+ // in ensureGraphModel) rather than whatever the pre-settle seed layout looked like.
4378
+ if (!g.settleFitDone && !g.userTouchedCamera) {
4379
+ g.settleFitDone = true;
4380
+ fitGraph();
4381
+ }
4382
+ }
4383
+ requestDraw();
4384
+ if (g.running) requestAnimationFrame(simTick);
4013
4385
  }
4014
4386
 
4015
- // Graph — node-link of every (non-test) module, grouped around its cluster centroid, with import edges.
4016
- function renderGraph(g, W, H) {
4017
- var map = state.map;
4018
- var L = clusterCentroids(map, W, H);
4019
- var fallback = (map.clusters && map.clusters[0] && map.clusters[0].id) || null;
4020
- var idx = {}, pos = new Map();
4021
- (map.modules || []).forEach(function (m) {
4022
- if (m.isTest) return;
4023
- var c = m.cluster || fallback;
4024
- var cen = L.pos.get(c) || { x: W / 2, y: H / 2 };
4025
- var k = idx[c] = (idx[c] || 0); idx[c] = k + 1;
4026
- var ang = k * 2.399963229; // golden angle — deterministic spiral, no RNG
4027
- var rad = 5 + Math.sqrt(k) * 7;
4028
- pos.set(m.id, { x: cen.x + Math.cos(ang) * rad, y: cen.y + Math.sin(ang) * rad, m: m });
4387
+ // ---- canvas drawing ----
4388
+ var drawPending = false;
4389
+ function requestDraw() {
4390
+ if (drawPending) return;
4391
+ drawPending = true;
4392
+ requestAnimationFrame(function () { drawPending = false; if (state.view === "graph") drawGraph(); });
4393
+ }
4394
+ // B3: read devicePixelRatio once per resize and cache it on state.dpr, instead of every caller
4395
+ // re-reading window.devicePixelRatio independently. resizeCanvas() (which sizes the backing
4396
+ // store) and drawGraph() (which scales the 2D context to match) must always agree on exactly
4397
+ // the same ratio — caching removes any chance of them observing different values if the OS
4398
+ // reports a changed ratio (e.g. the window moves to a different-DPR display) between the two
4399
+ // calls. All layout/hit-test math (graphNodeAt, drawGraph's W/H) stays in CSS pixels regardless
4400
+ // (canvas.clientWidth/clientHeight, never canvas.width/height), so dpr only ever affects backing
4401
+ // store crispness, never coordinate math.
4402
+ function dpr() { return state.dpr || (state.dpr = window.devicePixelRatio || 1); }
4403
+ function resizeCanvas() {
4404
+ var r = window.devicePixelRatio || 1;
4405
+ state.dpr = r;
4406
+ var w = canvas.clientWidth || 800, h = canvas.clientHeight || 600;
4407
+ canvas.width = Math.max(1, Math.round(w * r));
4408
+ canvas.height = Math.max(1, Math.round(h * r));
4409
+ }
4410
+
4411
+ function nodeMatchesSearch(n) {
4412
+ if (!state.searchMatches) return true;
4413
+ if (n.kind === "module") return state.searchMatches.has(n.id);
4414
+ var c = state.idx.clusterById.get(n.cluster || n.id);
4415
+ return c && c.members.some(function (mid) { return state.searchMatches.has(mid); });
4416
+ }
4417
+ function nodeMatchesFocus(n) {
4418
+ if (!state.focus) return true;
4419
+ if (n.kind === "module") return state.focus.moduleSet.has(n.id);
4420
+ var c = state.idx.clusterById.get(n.cluster || n.id);
4421
+ return c && c.members.some(function (mid) { return state.focus.moduleSet.has(mid); });
4422
+ }
4423
+ function nodeOpacity(n) {
4424
+ var o = 1;
4425
+ if (n.kind === "module" && n.isTest && !state.showTests) o *= 0.28;
4426
+ if (!nodeMatchesSearch(n)) o *= 0.15;
4427
+ if (!nodeMatchesFocus(n)) o *= 0.15;
4428
+ return o;
4429
+ }
4430
+ function confidenceVisible(conf) { return !!state.confidence[conf]; }
4431
+ function setLineDashFor(ctx, conf) {
4432
+ if (conf === "INFERRED") ctx.setLineDash([6, 4]);
4433
+ else if (conf === "AMBIGUOUS") ctx.setLineDash([1, 4]);
4434
+ else ctx.setLineDash([]);
4435
+ }
4436
+
4437
+ function drawGraph() {
4438
+ if (!canvas || !state.graph) return;
4439
+ var ctx = canvas.getContext("2d");
4440
+ var r = dpr();
4441
+ var W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
4442
+ ctx.setTransform(r, 0, 0, r, 0, 0);
4443
+ ctx.clearRect(0, 0, W, H);
4444
+ ctx.save();
4445
+ ctx.translate(W / 2 + state.gcam.pan.x, H / 2 + state.gcam.pan.y);
4446
+ ctx.scale(state.gcam.zoom, state.gcam.zoom);
4447
+
4448
+ var g = state.graph;
4449
+ // edges
4450
+ g.edges.forEach(function (e) {
4451
+ if (!confidenceVisible(e.confidence)) return;
4452
+ var a = g.nodeIndex.get(e.from), b = g.nodeIndex.get(e.to);
4453
+ if (!a || !b) return;
4454
+ var op = Math.min(nodeOpacity(a), nodeOpacity(b));
4455
+ ctx.globalAlpha = 0.55 * op;
4456
+ ctx.strokeStyle = "#3a4858";
4457
+ ctx.lineWidth = Math.min(3, 0.5 + Math.log2(e.count + 1) * 0.5) / state.gcam.zoom;
4458
+ setLineDashFor(ctx, e.confidence);
4459
+ ctx.beginPath();
4460
+ ctx.moveTo(a.pos.x, a.pos.y);
4461
+ ctx.lineTo(b.pos.x, b.pos.y);
4462
+ ctx.stroke();
4463
+ // arrowhead
4464
+ var dx = b.pos.x - a.pos.x, dy = b.pos.y - a.pos.y;
4465
+ var dist = Math.sqrt(dx*dx+dy*dy) || 1;
4466
+ var ux = dx/dist, uy = dy/dist;
4467
+ var ex = b.pos.x - ux * (b.r + 2), ey = b.pos.y - uy * (b.r + 2);
4468
+ var ah = 5 / state.gcam.zoom;
4469
+ ctx.setLineDash([]);
4470
+ ctx.beginPath();
4471
+ ctx.moveTo(ex, ey);
4472
+ ctx.lineTo(ex - ux*ah - uy*ah*0.6, ey - uy*ah + ux*ah*0.6);
4473
+ ctx.lineTo(ex - ux*ah + uy*ah*0.6, ey - uy*ah - ux*ah*0.6);
4474
+ ctx.closePath();
4475
+ ctx.fillStyle = "#3a4858";
4476
+ ctx.fill();
4029
4477
  });
4030
- (map.edges || []).forEach(function (e) {
4031
- if (e.kind !== "import" && e.kind !== "re-export") return;
4032
- var a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) return;
4033
- g.appendChild(el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "#2a3340", "stroke-width": 0.4, opacity: 0.35 }));
4478
+ ctx.setLineDash([]);
4479
+
4480
+ if (state.highlightSurprising) {
4481
+ ctx.globalAlpha = 0.9;
4482
+ ctx.strokeStyle = "#ff5c5c";
4483
+ ctx.lineWidth = 2 / state.gcam.zoom;
4484
+ (state.map.surprisingConnections || []).forEach(function (sc) {
4485
+ var a = g.nodeIndex.get(resolveGraphEndpoint(sc.from)), b = g.nodeIndex.get(resolveGraphEndpoint(sc.to));
4486
+ if (!a || !b || a === b) return;
4487
+ ctx.beginPath(); ctx.moveTo(a.pos.x, a.pos.y); ctx.lineTo(b.pos.x, b.pos.y); ctx.stroke();
4488
+ });
4489
+ }
4490
+
4491
+ // nodes
4492
+ g.nodes.forEach(function (n) {
4493
+ var op = nodeOpacity(n);
4494
+ ctx.globalAlpha = op;
4495
+ var color = clusterColor(n.cluster || n.id);
4496
+ var selected = state.selected && state.selected.kind !== "box" && state.selected.id === n.id;
4497
+ var hovered = state.hovered === n.id;
4498
+ if (n.isSink) {
4499
+ var s = n.r * 0.9;
4500
+ ctx.save(); ctx.translate(n.pos.x, n.pos.y); ctx.rotate(Math.PI/4);
4501
+ ctx.fillStyle = color; ctx.fillRect(-s/2, -s/2, s, s);
4502
+ ctx.restore();
4503
+ } else {
4504
+ ctx.beginPath();
4505
+ ctx.arc(n.pos.x, n.pos.y, n.r, 0, Math.PI * 2);
4506
+ ctx.fillStyle = color;
4507
+ ctx.fill();
4508
+ }
4509
+ if (n.isEntry) {
4510
+ ctx.globalAlpha = op;
4511
+ ctx.strokeStyle = "#ffdf5d";
4512
+ ctx.lineWidth = 1.6 / state.gcam.zoom;
4513
+ ctx.beginPath(); ctx.arc(n.pos.x, n.pos.y, n.r + 3, 0, Math.PI * 2); ctx.stroke();
4514
+ }
4515
+ if (selected || hovered) {
4516
+ ctx.strokeStyle = selected ? "#ffffff" : "#c9d1d9";
4517
+ ctx.lineWidth = (selected ? 2.4 : 1.4) / state.gcam.zoom;
4518
+ ctx.beginPath(); ctx.arc(n.pos.x, n.pos.y, n.r + 1.5, 0, Math.PI * 2); ctx.stroke();
4519
+ }
4520
+ var showLabel = n.kind === "cluster" || selected || hovered || state.gcam.zoom > 2.2;
4521
+ if (showLabel && op > 0.4) {
4522
+ ctx.globalAlpha = Math.min(1, op + 0.2);
4523
+ ctx.fillStyle = "#e6edf3";
4524
+ ctx.font = (n.kind === "cluster" ? "600 " : "") + Math.max(9, Math.min(12, n.r)) + "px -apple-system,sans-serif";
4525
+ var label = n.kind === "cluster" ? (n.label + " (" + n.size + ")") : n.m.path.split("/").pop();
4526
+ ctx.fillText(label, n.pos.x + n.r + 3, n.pos.y + 3);
4527
+ }
4034
4528
  });
4035
- pos.forEach(function (p) {
4036
- var m = p.m;
4037
- var r = Math.max(1.6, Math.min(7, 1.4 + Math.sqrt((m.loc || 1) / 40)));
4038
- var node = el("g", { class: "module-node", transform: "translate(" + p.x + "," + p.y + ")" });
4039
- node.appendChild(el("circle", { r: r, fill: clusterColor(m.cluster), opacity: 0.9 }));
4040
- node.appendChild(el("title", null, m.path + (m.cluster ? " — " + m.cluster : "")));
4041
- g.appendChild(node);
4529
+ ctx.restore();
4530
+ ctx.globalAlpha = 1;
4531
+ }
4532
+
4533
+ function graphNodeAt(clientX, clientY) {
4534
+ var rect = canvas.getBoundingClientRect();
4535
+ var W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
4536
+ var sx = clientX - rect.left, sy = clientY - rect.top;
4537
+ var wx = (sx - (W / 2 + state.gcam.pan.x)) / state.gcam.zoom;
4538
+ var wy = (sy - (H / 2 + state.gcam.pan.y)) / state.gcam.zoom;
4539
+ var g = state.graph; if (!g) return null;
4540
+ var best = null, bestD = Infinity;
4541
+ for (var i=0; i<g.nodes.length; i++) {
4542
+ var n = g.nodes[i];
4543
+ var dx = n.pos.x - wx, dy = n.pos.y - wy;
4544
+ var d = Math.sqrt(dx*dx+dy*dy);
4545
+ if (d <= Math.max(4, n.r) + 2 && d < bestD) { best = n; bestD = d; }
4546
+ }
4547
+ return best;
4548
+ }
4549
+
4550
+ function fitGraph() {
4551
+ var g = state.graph; if (!g || g.nodes.length === 0) return;
4552
+ var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
4553
+ g.nodes.forEach(function (n) {
4554
+ minX = Math.min(minX, n.pos.x - n.r); maxX = Math.max(maxX, n.pos.x + n.r);
4555
+ minY = Math.min(minY, n.pos.y - n.r); maxY = Math.max(maxY, n.pos.y + n.r);
4042
4556
  });
4557
+ var W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
4558
+ var bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
4559
+ var zoom = Math.max(0.05, Math.min(4, Math.min(W / bw, H / bh) * 0.9));
4560
+ var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
4561
+ state.gcam.zoom = zoom;
4562
+ state.gcam.pan = { x: -cx * zoom, y: -cy * zoom };
4043
4563
  }
4044
4564
 
4045
- // Calls — egocentric symbol call graph: callers on the left, callees on the right, edges labelled by symbol.
4046
- function renderCalls(g, W, H) {
4565
+ // ====================== Inspector ======================
4566
+ function selectNode(kind, id) {
4567
+ state.selected = { kind: kind, id: id };
4568
+ if (kind === "module" && state.view === "graph" && state.graph) {
4569
+ var m = state.idx.modById.get(id);
4570
+ if (m && m.cluster && state.graph.collapsedClusters.has(m.cluster)) {
4571
+ state.graph.collapsedClusters.delete(m.cluster);
4572
+ rebuildVisibleGraph();
4573
+ }
4574
+ }
4575
+ renderInspector();
4576
+ if (state.view === "graph") requestDraw(); else render();
4577
+ }
4578
+
4579
+ function linkBtn(label, kind, id) {
4580
+ return '<span class="ins-link" data-kind="' + escapeHtml(kind) + '" data-id="' + escapeHtml(id) + '">' + escapeHtml(label) + '</span>';
4581
+ }
4582
+
4583
+ function renderInspector() {
4584
+ var box = $("inspector");
4585
+ if (!state.selected) { box.innerHTML = '<div style="color:var(--muted)">Click a node, cluster, or package to inspect it.</div>'; return; }
4047
4586
  var map = state.map;
4048
- var ces = map.callEdges || [];
4049
- var modById = {}; (map.modules || []).forEach(function (m) { modById[m.id] = m; });
4050
- var center = (state.selected && modById[state.selected]) ? state.selected : null;
4051
- if (!center) { var god = map.hotspots && map.hotspots.godNodes && map.hotspots.godNodes[0]; center = god ? god.id : (ces[0] && ces[0].from) || null; }
4052
- if (!center) { g.appendChild(el("text", { x: W / 2, y: H / 2, "text-anchor": "middle", fill: "#8b949e" }, "No call edges — click a module in another view.")); return; }
4053
- var callees = ces.filter(function (e) { return e.from === center; }).slice(0, 24);
4054
- var callers = ces.filter(function (e) { return e.to === center; }).slice(0, 24);
4055
- var cx = W / 2, cy = H / 2;
4056
- function placeColumn(list, side) {
4057
- var x = cx + side * Math.min(W * 0.36, 360);
4058
- var n = list.length || 1;
4059
- list.forEach(function (e, i) {
4060
- var y = (H * (i + 1)) / (n + 1);
4061
- var other = side > 0 ? e.to : e.from;
4062
- g.appendChild(side > 0
4063
- ? arrow(cx + 9, cy, x - 6, y, { fill: "none", stroke: "#4f8cc9", "stroke-width": 1.1, opacity: 0.8 })
4064
- : arrow(x + 6, y, cx - 9, cy, { fill: "none", stroke: "#c98a4f", "stroke-width": 1.1, opacity: 0.8 }));
4065
- g.appendChild(el("text", { x: (x + cx) / 2, y: (y + cy) / 2 - 3, "text-anchor": "middle", fill: "#8b949e", "font-size": 9 }, e.symbol || ""));
4066
- var node = el("g", { class: "module-node", transform: "translate(" + x + "," + y + ")" });
4067
- node.appendChild(el("circle", { r: 5, fill: clusterColor((modById[other] || {}).cluster) }));
4068
- node.appendChild(el("text", { "text-anchor": side > 0 ? "start" : "end", x: side > 0 ? 9 : -9, y: 3, fill: "#c9d1d9", "font-size": 10 }, (other.split("/").pop())));
4069
- node.appendChild(el("title", null, other));
4070
- g.appendChild(node);
4071
- });
4587
+ if (state.selected.kind === "module") {
4588
+ var m = state.idx.modById.get(state.selected.id);
4589
+ if (!m) { box.innerHTML = '<div style="color:var(--muted)">Module not found.</div>'; return; }
4590
+ var cl = state.idx.clusterById.get(m.cluster);
4591
+ var importers = state.idx.radj.get(m.id) || [];
4592
+ var imports = state.idx.adj.get(m.id) || [];
4593
+ var callers = state.idx.callersOf.get(m.id) || [];
4594
+ var callees = state.idx.calleesOf.get(m.id) || [];
4595
+ var html = '<h3>' + escapeHtml(m.path) + '</h3>';
4596
+ html += '<div class="pill">' + escapeHtml(m.package || "-") + '</div><div class="pill">' + escapeHtml(m.layer || "-") + '</div>' +
4597
+ (cl ? '<div class="pill">' + escapeHtml(cl.label) + '</div>' : '') + (m.isTest ? '<div class="pill">test</div>' : '') +
4598
+ (m.entryPoint ? '<div class="pill">entry: ' + escapeHtml(m.entryPoint.kind) + '</div>' : '');
4599
+ html += '<div class="kv">' + (m.loc || 0) + ' loc · ' + (m.size || 0) + ' bytes</div>';
4600
+ if (m.symbols && m.symbols.length) {
4601
+ html += '<h4>Symbols (' + m.symbols.length + ')</h4><pre>' + m.symbols.slice(0, 40).map(function (s) {
4602
+ return (s.exported ? "export " : "") + s.kind + " " + s.name + " :" + s.line;
4603
+ }).join("\\n") + '</pre>';
4604
+ }
4605
+ if (m.sinks && m.sinks.length) html += '<h4>Sinks</h4><div class="kv">' + m.sinks.map(escapeHtml).join(", ") + '</div>';
4606
+ html += '<h4>Importers (' + importers.length + ')</h4><div class="kv">' + (importers.length ? importers.slice(0, 20).map(function (id) { return linkBtn(id, "module", id); }).join(" ") : "none") + '</div>';
4607
+ html += '<h4>Imports (' + imports.length + ')</h4><div class="kv">' + (imports.length ? imports.slice(0, 20).map(function (id) { return linkBtn(id, "module", id); }).join(" ") : "none") + '</div>';
4608
+ html += '<h4>Callers (' + callers.length + ')</h4><div class="kv">' + (callers.length ? callers.slice(0, 20).map(function (ce) { return linkBtn(ce.from + " ." + ce.symbol + "()", "module", ce.from); }).join(" ") : "none") + '</div>';
4609
+ html += '<h4>Callees (' + callees.length + ')</h4><div class="kv">' + (callees.length ? callees.slice(0, 20).map(function (ce) { return linkBtn(ce.to + " ." + ce.symbol + "()", "module", ce.to); }).join(" ") : "none") + '</div>';
4610
+ box.innerHTML = html;
4611
+ } else if (state.selected.kind === "cluster") {
4612
+ var c = state.idx.clusterById.get(state.selected.id);
4613
+ if (!c) { box.innerHTML = '<div style="color:var(--muted)">Cluster not found.</div>'; return; }
4614
+ var html2 = '<h3>' + escapeHtml(c.label) + '</h3>';
4615
+ html2 += '<div class="pill">' + c.size + ' modules</div><div class="pill">' + escapeHtml(c.dominantPackage || "-") + '</div><div class="pill">' + escapeHtml(c.dominantLayer) + '</div>';
4616
+ html2 += '<div class="kv"><b>Packages:</b> ' + (c.packages || []).map(escapeHtml).join(", ") + '</div>';
4617
+ html2 += '<h4>Members</h4><div class="kv">' + c.members.slice(0, 60).map(function (id) { return linkBtn(id, "module", id); }).join(" ") + '</div>';
4618
+ box.innerHTML = html2;
4619
+ } else if (state.selected.kind === "box") {
4620
+ var id2 = state.selected.id;
4621
+ var flowBox = (map.boxes || []).find(function (x) { return x.id === id2; });
4622
+ if (flowBox) {
4623
+ var html3 = '<h3>' + escapeHtml(flowBox.title) + '</h3><div class="pill">' + escapeHtml(flowBox.lane) + '</div>' +
4624
+ (flowBox.kind ? '<div class="pill">' + escapeHtml(flowBox.kind) + '</div>' : '') +
4625
+ (flowBox.sublabel ? '<div class="kv">' + escapeHtml(flowBox.sublabel) + '</div>' : '');
4626
+ if (flowBox.kind === "package") {
4627
+ var inE = map.containerEdges.filter(function (e) { return e.to === id2; });
4628
+ var outE = map.containerEdges.filter(function (e) { return e.from === id2; });
4629
+ html3 += '<h4>Depends on</h4>' + (outE.length ? outE.map(function (e) { return '<div class="kv">→ ' + escapeHtml(e.to.replace(/^pkg:/, "")) + ' (' + e.count + ')</div>'; }).join("") : '<div class="kv">nothing</div>');
4630
+ html3 += '<h4>Depended on by</h4>' + (inE.length ? inE.map(function (e) { return '<div class="kv">← ' + escapeHtml(e.from.replace(/^pkg:/, "")) + ' (' + e.count + ')</div>'; }).join("") : '<div class="kv">nothing</div>');
4631
+ }
4632
+ box.innerHTML = html3;
4633
+ return;
4634
+ }
4635
+ var container = (map.containers || []).find(function (x) { return x.id === id2; });
4636
+ if (container) {
4637
+ var mods = map.modules.filter(function (m) { return containerIdOf(m) === id2; });
4638
+ var inE2 = map.containerEdges.filter(function (e) { return e.to === id2; });
4639
+ var outE2 = map.containerEdges.filter(function (e) { return e.from === id2; });
4640
+ var html4 = '<h3>' + escapeHtml(container.name) + '</h3><div class="kv">' + mods.length + ' modules</div>';
4641
+ html4 += '<h4>Depends on</h4>' + (outE2.length ? outE2.map(function (e) { return '<div class="kv">→ ' + escapeHtml(e.to.replace(/^pkg:/, "")) + ' <span style="color:var(--muted)">(' + e.count + ')</span></div>'; }).join("") : '<div class="kv">nothing</div>');
4642
+ html4 += '<h4>Depended on by</h4>' + (inE2.length ? inE2.map(function (e) { return '<div class="kv">← ' + escapeHtml(e.from.replace(/^pkg:/, "")) + ' <span style="color:var(--muted)">(' + e.count + ')</span></div>'; }).join("") : '<div class="kv">nothing</div>');
4643
+ var entryMods = mods.filter(function (m) { return !!m.entryPoint; });
4644
+ if (entryMods.length) {
4645
+ html4 += '<h4>Entry points</h4>';
4646
+ entryMods.forEach(function (m) { html4 += '<div class="kv">' + linkBtn(m.entryPoint.name || m.path, "module", m.id) + ' <span style="color:var(--muted)">(' + m.entryPoint.kind + ')</span></div>'; });
4647
+ }
4648
+ box.innerHTML = html4;
4649
+ return;
4650
+ }
4651
+ box.innerHTML = '<div style="color:var(--muted)">Nothing selected.</div>';
4072
4652
  }
4073
- placeColumn(callers, -1);
4074
- placeColumn(callees, 1);
4075
- var cnode = el("g", { transform: "translate(" + cx + "," + cy + ")" });
4076
- cnode.appendChild(el("circle", { r: 9, fill: clusterColor((modById[center] || {}).cluster), stroke: "#fff", "stroke-width": 2 }));
4077
- cnode.appendChild(el("text", { "text-anchor": "middle", y: -14, fill: "#fff", "font-size": 11, "font-weight": 700 }, center.split("/").pop()));
4078
- g.appendChild(cnode);
4079
4653
  }
4654
+ // Delegated click handling for inspector cross-links (module ids contain "/" so can't be inline onclick attrs safely).
4655
+ document.addEventListener("click", function (ev) {
4656
+ var t = ev.target;
4657
+ if (t && t.classList && t.classList.contains("ins-link")) {
4658
+ selectNode(t.getAttribute("data-kind"), t.getAttribute("data-id"));
4659
+ }
4660
+ });
4080
4661
 
4081
4662
  // ====================== Sidebars ======================
4082
-
4083
4663
  function renderSidebar() {
4084
4664
  var map = state.map;
4085
4665
  var ents = $("entry-list"); ents.innerHTML = "";
@@ -4102,7 +4682,7 @@ function renderSidebar() {
4102
4682
  row.innerHTML = '<div class="dot" style="background:var(--entry)"></div>' +
4103
4683
  '<div class="name" title="' + escapeHtml(e.path) + '"><div>' + escapeHtml(label) + '</div>' +
4104
4684
  '<div style="color:var(--muted); font-size:10px">' + escapeHtml(sub) + '</div></div>';
4105
- row.onclick = function () { selectFlow("flow:" + e.id); };
4685
+ row.onclick = function (eid) { return function () { selectFlow("flow:" + eid); }; }(e.id);
4106
4686
  ents.appendChild(row);
4107
4687
  });
4108
4688
  });
@@ -4116,7 +4696,7 @@ function renderSidebar() {
4116
4696
  Object.keys(sinkCounts).sort(function (a,b) { return sinkCounts[b] - sinkCounts[a]; }).forEach(function (k) {
4117
4697
  var row = document.createElement("div"); row.className = "row";
4118
4698
  row.innerHTML = '<div class="dot" style="background:var(--sink)"></div><div class="name">' + escapeHtml(k) + '</div><div class="count">' + sinkCounts[k] + '</div>';
4119
- row.onclick = function () { focusSink(k); };
4699
+ row.onclick = function (kind) { return function () { focusSink(kind); }; }(k);
4120
4700
  sl.appendChild(row);
4121
4701
  });
4122
4702
 
@@ -4124,15 +4704,24 @@ function renderSidebar() {
4124
4704
  LAYER_ORDER.forEach(function (l) {
4125
4705
  var n = map.stats.layers[l] || 0; if (!n) return;
4126
4706
  var row = document.createElement("div"); row.className = "row";
4707
+ var active = state.focus && state.focus.kind === "layer" && state.focus.id === l;
4708
+ row.className += active ? " active" : "";
4127
4709
  row.innerHTML = '<div class="dot" style="background:' + LAYER_COLOR[l] + '"></div><div class="name">' + escapeHtml(l) + '</div><div class="count">' + n + '</div>';
4710
+ row.onclick = function (layer) { return function () {
4711
+ if (state.focus && state.focus.kind === "layer" && state.focus.id === layer) clearFocus(); else focusLayer(layer);
4712
+ }; }(l);
4128
4713
  ll.appendChild(row);
4129
4714
  });
4130
4715
 
4131
4716
  var pl = $("pkg-list"); pl.innerHTML = "";
4132
4717
  map.containers.slice().sort(function (a,b) { return b.moduleCount - a.moduleCount; }).forEach(function (c) {
4133
4718
  var row = document.createElement("div"); row.className = "row";
4719
+ var active = state.focus && state.focus.kind === "package" && state.focus.id === c.id;
4720
+ row.className += active ? " active" : "";
4134
4721
  row.innerHTML = '<div class="dot" style="background:#3a4858"></div><div class="name">' + escapeHtml(c.name) + '</div><div class="count">' + c.moduleCount + '</div>';
4135
- row.onclick = function () { focusPackage(c.id); };
4722
+ row.onclick = function (cid) { return function () {
4723
+ if (state.focus && state.focus.kind === "package" && state.focus.id === cid) clearFocus(); else focusPackage(cid);
4724
+ }; }(c.id);
4136
4725
  pl.appendChild(row);
4137
4726
  });
4138
4727
  }
@@ -4144,11 +4733,8 @@ function renderFlowList() {
4144
4733
  list.innerHTML = '<div style="color:var(--muted); padding:8px 0">No flows detected. Add entry points (bin / HTTP routes) or curate .planning/codebase/FLOWS.json.</div>';
4145
4734
  return;
4146
4735
  }
4147
- // Order: CLI first, then HTTP, then library
4148
4736
  var kindRank = { cli: 0, http: 1, library: 2 };
4149
- var sorted = map.flows.slice().sort(function (a,b) {
4150
- return (kindRank[a.entryKind]||3) - (kindRank[b.entryKind]||3);
4151
- });
4737
+ var sorted = map.flows.slice().sort(function (a,b) { return (kindRank[a.entryKind]||3) - (kindRank[b.entryKind]||3); });
4152
4738
  sorted.forEach(function (f) {
4153
4739
  var item = document.createElement("div");
4154
4740
  item.className = "flow-item" + (state.selectedFlow === f.id ? " active" : "");
@@ -4172,13 +4758,12 @@ function renderStepList() {
4172
4758
  item.className = "step-item" + (state.selectedStep === step.n ? " active" : "");
4173
4759
  var fromLabel = step.from.replace(/^pkg:/, "").replace(/^sink:/, "").replace(/^actor:/, "");
4174
4760
  var toLabel = step.to.replace(/^pkg:/, "").replace(/^sink:/, "").replace(/^actor:/, "");
4175
- // Split off trailing code snippet if present
4176
4761
  var descHtml = "";
4177
4762
  if (step.description) {
4178
4763
  var snippet = null; var desc = step.description;
4179
- var sm = desc.match(/^(.*?)\\s*\\\`([^\\\`]+)\\\`\\s*$/);
4764
+ var sm = desc.match(/^(.*?)\\s*\\x60([^\\x60]+)\\x60\\s*$/);
4180
4765
  if (sm && sm[2].length > 12 && /[(){};=]/.test(sm[2])) { desc = sm[1]; snippet = sm[2]; }
4181
- descHtml = '<div class="desc">' + escapeHtml(desc).replace(/\\\`([^\\\`]+)\\\`/g, function (_, t) { return '<code>' + t + '</code>'; }) + '</div>';
4766
+ descHtml = '<div class="desc">' + escapeHtml(desc).replace(/\\x60([^\\x60]+)\\x60/g, function (_, t) { return '<code>' + t + '</code>'; }) + '</div>';
4182
4767
  if (snippet) descHtml += '<div class="snippet">' + escapeHtml(snippet) + '</div>';
4183
4768
  }
4184
4769
  var fileRef = "";
@@ -4200,43 +4785,64 @@ function renderStepList() {
4200
4785
  });
4201
4786
  }
4202
4787
 
4203
- function renderDetail() {
4204
- var elD = $("detail");
4205
- if (!state.selected) { elD.style.display = "none"; return; }
4206
- var map = state.map;
4207
- elD.style.display = "block";
4208
- if (state.selected.kind === "box") {
4209
- var id = state.selected.id;
4210
- var box = (map.boxes || []).find(function (x) { return x.id === id; });
4211
- if (!box) { elD.style.display = "none"; return; }
4212
- var html = '<h3>' + escapeHtml(box.title) + '</h3>' +
4213
- '<div class="pill">' + escapeHtml(box.lane) + '</div>' +
4214
- (box.kind ? '<div class="pill">' + escapeHtml(box.kind) + '</div>' : '') +
4215
- (box.sublabel ? '<div class="kv">' + escapeHtml(box.sublabel) + '</div>' : '');
4216
- if (box.kind === "package") {
4217
- var c = map.containers.find(function (x) { return x.id === id; });
4218
- var mods = map.modules.filter(function (m) { return containerIdOf(m) === id; });
4219
- var inE = map.containerEdges.filter(function (e) { return e.to === id; });
4220
- var outE = map.containerEdges.filter(function (e) { return e.from === id; });
4221
- html += '<h4 style="margin:10px 0 4px;font-size:11px;color:var(--muted)">DEPENDS ON</h4>' +
4222
- (outE.length ? outE.map(function (e) { return '<div class="kv">→ ' + escapeHtml(e.to.replace(/^pkg:/,"")) + ' <span style="color:var(--muted)">(' + e.count + ')</span></div>'; }).join("") : '<div class="kv" style="color:var(--muted)">nothing</div>') +
4223
- '<h4 style="margin:10px 0 4px;font-size:11px;color:var(--muted)">DEPENDED ON BY</h4>' +
4224
- (inE.length ? inE.map(function (e) { return '<div class="kv">← ' + escapeHtml(e.from.replace(/^pkg:/,"")) + ' <span style="color:var(--muted)">(' + e.count + ')</span></div>'; }).join("") : '<div class="kv" style="color:var(--muted)">nothing</div>');
4225
- var entryMods = mods.filter(function (m) { return !!m.entryPoint; });
4226
- if (entryMods.length) {
4227
- html += '<h4 style="margin:10px 0 4px;font-size:11px;color:var(--muted)">ENTRY POINTS</h4>';
4228
- entryMods.forEach(function (m) {
4229
- html += '<div class="kv">' + escapeHtml(m.entryPoint.name || m.path) + ' <span style="color:var(--muted)">(' + m.entryPoint.kind + ')</span></div>';
4230
- });
4231
- }
4788
+ // ====================== Search (defect 1: #q was rendered but never wired up) ======================
4789
+ function applySearch(q) {
4790
+ state.filter = q;
4791
+ if (!q) { state.searchMatches = null; }
4792
+ else {
4793
+ var needle = q.toLowerCase();
4794
+ var set = new Set();
4795
+ (state.map.modules || []).forEach(function (m) {
4796
+ if (m.path.toLowerCase().indexOf(needle) !== -1) set.add(m.id);
4797
+ });
4798
+ (state.map.symbolIndex || []).forEach(function (s) {
4799
+ if (s.name.toLowerCase().indexOf(needle) !== -1) set.add(s.file);
4800
+ });
4801
+ state.searchMatches = set;
4802
+ // B2 fix: a match hiding inside a still-collapsed cluster supernode was easy to miss (the
4803
+ // supernode only changes shade, the matching module itself never appears) — auto-expand any
4804
+ // collapsed cluster that contains a hit so the actual matching module node is visible.
4805
+ if (state.graph && set.size) {
4806
+ var g = state.graph, expandedAny = false;
4807
+ set.forEach(function (mid) {
4808
+ var m = state.idx.modById.get(mid);
4809
+ if (m && m.cluster && g.collapsedClusters.has(m.cluster)) {
4810
+ g.collapsedClusters.delete(m.cluster);
4811
+ expandedAny = true;
4812
+ }
4813
+ });
4814
+ if (expandedAny) rebuildVisibleGraph();
4232
4815
  }
4233
- elD.innerHTML = html;
4234
- return;
4235
4816
  }
4817
+ // The match-count hint must refresh even when no cluster expanded (all matches already
4818
+ // visible, zero matches, or query cleared) — rebuildVisibleGraph is not guaranteed to run.
4819
+ if (state.view === "graph") { $("hud").textContent = hudText(); requestDraw(); }
4820
+ }
4821
+ function searchTopHit() {
4822
+ if (!state.searchMatches || state.searchMatches.size === 0) return null;
4823
+ return state.searchMatches.values().next().value;
4236
4824
  }
4237
4825
 
4238
4826
  // ====================== Camera ======================
4239
- function fit() { state.zoom = 1; state.pan = { x: 0, y: 0 }; render(); }
4827
+ function fit() {
4828
+ if (state.view === "graph") {
4829
+ if (state.graph) fitGraph();
4830
+ requestDraw();
4831
+ return;
4832
+ }
4833
+ var world = svg.querySelector("#world");
4834
+ if (!world) { state.zoom = 1; state.pan = { x: 0, y: 0 }; return; }
4835
+ try {
4836
+ var bbox = world.getBBox();
4837
+ if (bbox.width > 0 && bbox.height > 0) {
4838
+ var W = svg.clientWidth, H = svg.clientHeight;
4839
+ var zoom = Math.max(0.1, Math.min(3, Math.min(W / bbox.width, H / bbox.height) * 0.94));
4840
+ state.zoom = zoom;
4841
+ state.pan = { x: -bbox.x * zoom + (W - bbox.width * zoom) / 2, y: -bbox.y * zoom + (H - bbox.height * zoom) / 2 };
4842
+ } else { state.zoom = 1; state.pan = { x: 0, y: 0 }; }
4843
+ } catch (err) { state.zoom = 1; state.pan = { x: 0, y: 0 }; }
4844
+ tx(world);
4845
+ }
4240
4846
 
4241
4847
  svg.addEventListener("mousedown", function (e) {
4242
4848
  state.drag = { x: e.clientX, y: e.clientY, pan: { x: state.pan.x, y: state.pan.y } };
@@ -4260,52 +4866,181 @@ svg.addEventListener("wheel", function (e) {
4260
4866
  var g = svg.querySelector("#world"); if (g) tx(g);
4261
4867
  }, { passive: false });
4262
4868
 
4869
+ // Canvas (Graph) interactions
4870
+ canvas.addEventListener("mousedown", function (e) {
4871
+ var hit = graphNodeAt(e.clientX, e.clientY);
4872
+ if (hit) {
4873
+ state.graph.dragId = hit.id;
4874
+ state.graph.pinned.add(hit.id);
4875
+ state.gdrag = null;
4876
+ } else {
4877
+ state.gdrag = { x: e.clientX, y: e.clientY, pan: { x: state.gcam.pan.x, y: state.gcam.pan.y } };
4878
+ if (state.graph) state.graph.userTouchedCamera = true; // B3: don't auto re-fit once the user pans
4879
+ }
4880
+ });
4881
+ window.addEventListener("mousemove", function (e) {
4882
+ if (state.view !== "graph" || !state.graph) return;
4883
+ if (state.graph.dragId) {
4884
+ var rect = canvas.getBoundingClientRect();
4885
+ var W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
4886
+ var wx = (e.clientX - rect.left - (W / 2 + state.gcam.pan.x)) / state.gcam.zoom;
4887
+ var wy = (e.clientY - rect.top - (H / 2 + state.gcam.pan.y)) / state.gcam.zoom;
4888
+ var n = state.graph.nodeIndex.get(state.graph.dragId);
4889
+ if (n) { n.pos.x = wx; n.pos.y = wy; n.pos.vx = 0; n.pos.vy = 0; }
4890
+ state.graph.alpha = Math.max(state.graph.alpha, 0.3);
4891
+ if (!state.graph.running) { state.graph.running = true; requestAnimationFrame(simTick); }
4892
+ requestDraw();
4893
+ } else if (state.gdrag) {
4894
+ state.gcam.pan.x = state.gdrag.pan.x + (e.clientX - state.gdrag.x);
4895
+ state.gcam.pan.y = state.gdrag.pan.y + (e.clientY - state.gdrag.y);
4896
+ requestDraw();
4897
+ } else {
4898
+ var hit = graphNodeAt(e.clientX, e.clientY);
4899
+ var newHover = hit ? hit.id : null;
4900
+ if (newHover !== state.hovered) { state.hovered = newHover; requestDraw(); }
4901
+ if (hit) showTooltip(hit.kind === "module" ? tooltipModuleHtml(hit.m) : ('<div class="tt-title">' + escapeHtml(hit.label) + '</div><div class="tt-desc">' + hit.size + ' modules</div>'), e);
4902
+ else hideTooltip();
4903
+ }
4904
+ });
4905
+ window.addEventListener("mouseup", function () {
4906
+ if (state.graph) state.graph.dragId = null;
4907
+ state.gdrag = null;
4908
+ });
4909
+ canvas.addEventListener("click", function (e) {
4910
+ if (state.gdrag) return;
4911
+ var hit = graphNodeAt(e.clientX, e.clientY);
4912
+ if (hit) selectNode(hit.kind, hit.id);
4913
+ });
4914
+ canvas.addEventListener("dblclick", function (e) {
4915
+ var hit = graphNodeAt(e.clientX, e.clientY);
4916
+ if (hit && hit.kind === "cluster") { toggleCluster(hit.id); return; }
4917
+ if (hit && hit.kind === "module" && hit.cluster) {
4918
+ // Double-clicking an expanded member re-collapses its cluster back to a supernode.
4919
+ toggleCluster(hit.cluster);
4920
+ return;
4921
+ }
4922
+ if (hit) {
4923
+ state.gcam.pan = { x: -hit.pos.x * state.gcam.zoom, y: -hit.pos.y * state.gcam.zoom };
4924
+ if (state.graph) state.graph.userTouchedCamera = true;
4925
+ requestDraw();
4926
+ }
4927
+ });
4928
+ canvas.addEventListener("wheel", function (e) {
4929
+ e.preventDefault();
4930
+ var factor = Math.exp(-e.deltaY * 0.001);
4931
+ var rect = canvas.getBoundingClientRect();
4932
+ var W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
4933
+ var cx = e.clientX - rect.left - W / 2, cy = e.clientY - rect.top - H / 2;
4934
+ var newZoom = Math.max(0.05, Math.min(6, state.gcam.zoom * factor));
4935
+ state.gcam.pan.x = cx - ((cx - state.gcam.pan.x) * (newZoom / state.gcam.zoom));
4936
+ state.gcam.pan.y = cy - ((cy - state.gcam.pan.y) * (newZoom / state.gcam.zoom));
4937
+ state.gcam.zoom = newZoom;
4938
+ if (state.graph) state.graph.userTouchedCamera = true; // B3: don't auto re-fit once the user zooms
4939
+ requestDraw();
4940
+ }, { passive: false });
4941
+
4263
4942
  window.addEventListener("keydown", function (e) {
4264
4943
  if (e.target.tagName === "INPUT") return;
4265
4944
  if (e.key === "f") fit();
4266
- if (e.key === "Escape") clearFlow();
4945
+ if (e.key === "Escape") { clearFlow(); render(); }
4946
+ if (e.key === "g") setView("graph");
4267
4947
  if (e.key === "a") setView("architecture");
4268
- if (e.key === "m") setView("modules");
4269
4948
  if (e.key === "F") setView("flows");
4270
- if (e.key === "i") setView("insights");
4271
- if (e.key === "c") setView("concepts");
4272
- if (e.key === "g") setView("graph");
4273
- if (e.key === "C") setView("calls");
4274
4949
  });
4275
4950
 
4951
+ $("tab-graph").onclick = function () { setView("graph"); };
4276
4952
  $("tab-architecture").onclick = function () { setView("architecture"); };
4277
- $("tab-modules").onclick = function () { setView("modules"); };
4278
4953
  $("tab-flows").onclick = function () { setView("flows"); };
4279
- $("tab-insights").onclick = function () { setView("insights"); };
4280
- $("tab-concepts").onclick = function () { setView("concepts"); };
4281
- $("tab-graph").onclick = function () { setView("graph"); };
4282
- $("tab-calls").onclick = function () { setView("calls"); };
4283
4954
  $("fit").onclick = fit;
4284
- $("clear").onclick = function () { clearFlow(); state.collapsedGroups = {}; state.selected = null; render(); };
4285
- $("reload").onclick = function () { load(false); };
4955
+ $("clear").onclick = function () { clearFlow(); state.collapsedGroups = {}; render(); };
4956
+ $("reload").onclick = function () { refreshFromServer(); };
4957
+ $("export-png").onclick = function () {
4958
+ if (state.view !== "graph") { showError("PNG export is only available on the Graph view."); return; }
4959
+ canvas.toBlob(function (blob) { if (blob) downloadBlob(blob, "codebase-graph.png"); }, "image/png");
4960
+ };
4961
+ $("export-svg").onclick = function () {
4962
+ if (state.view !== "graph" || !state.graph) { showError("SVG export is only available on the Graph view."); return; }
4963
+ var g = state.graph;
4964
+ var parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="-800 -600 1600 1200">',
4965
+ '<rect x="-800" y="-600" width="1600" height="1200" fill="#0b0d10"/>'];
4966
+ g.edges.forEach(function (e) {
4967
+ var a = g.nodeIndex.get(e.from), b = g.nodeIndex.get(e.to);
4968
+ if (!a || !b) return;
4969
+ parts.push('<line x1="' + a.pos.x.toFixed(1) + '" y1="' + a.pos.y.toFixed(1) + '" x2="' + b.pos.x.toFixed(1) + '" y2="' + b.pos.y.toFixed(1) + '" stroke="#3a4858" stroke-width="0.6" opacity="0.5"/>');
4970
+ });
4971
+ g.nodes.forEach(function (n) {
4972
+ var color = clusterColor(n.cluster || n.id);
4973
+ parts.push('<circle cx="' + n.pos.x.toFixed(1) + '" cy="' + n.pos.y.toFixed(1) + '" r="' + n.r.toFixed(1) + '" fill="' + color + '"/>');
4974
+ });
4975
+ parts.push('</svg>');
4976
+ downloadBlob(new Blob([parts.join("\\n")], { type: "image/svg+xml" }), "codebase-graph.svg");
4977
+ };
4978
+ $("expand-all").onclick = expandAllClusters;
4979
+ $("collapse-all").onclick = collapseAllClusters;
4286
4980
  var overlayCb = $("overlay-flow-cb");
4287
4981
  if (overlayCb) overlayCb.addEventListener("change", function (ev) { state.overlayFlow = ev.target.checked; render(); });
4982
+ var testsCb = $("show-tests-cb");
4983
+ if (testsCb) testsCb.addEventListener("change", function (ev) { state.showTests = ev.target.checked; requestDraw(); });
4984
+ var surprisingCb = $("surprising-cb");
4985
+ if (surprisingCb) surprisingCb.addEventListener("change", function (ev) { state.highlightSurprising = ev.target.checked; requestDraw(); });
4986
+ var degreeCb = $("degree-cb");
4987
+ if (degreeCb) degreeCb.addEventListener("change", function (ev) {
4988
+ state.sizeByDegree = ev.target.checked;
4989
+ if (state.graph) { state.graph.nodes.forEach(function (n) { if (n.kind === "module") n.r = moduleRadius(n.m); }); }
4990
+ requestDraw();
4991
+ });
4992
+ ["EXTRACTED","INFERRED","AMBIGUOUS"].forEach(function (conf) {
4993
+ var cb = $("conf-" + conf.toLowerCase());
4994
+ if (cb) cb.addEventListener("change", function (ev) { state.confidence[conf] = ev.target.checked; requestDraw(); });
4995
+ });
4996
+ $("q").addEventListener("input", function (ev) { applySearch(ev.target.value); });
4997
+ $("q").addEventListener("keydown", function (ev) {
4998
+ if (ev.key !== "Enter") return;
4999
+ var hit = searchTopHit();
5000
+ if (!hit) return;
5001
+ selectNode("module", hit);
5002
+ if (state.view === "graph" && state.graph) {
5003
+ var n = state.graph.nodeIndex.get(hit);
5004
+ if (n) { state.gcam.pan = { x: -n.pos.x * state.gcam.zoom, y: -n.pos.y * state.gcam.zoom }; requestDraw(); }
5005
+ }
5006
+ });
5007
+ // Legend cluster-row filter clicks (delegated — legend HTML is rebuilt on every render()).
5008
+ $("legend").addEventListener("click", function (ev) {
5009
+ var row = ev.target.closest ? ev.target.closest(".legend-row") : null;
5010
+ if (row) focusClusterToggle(row.getAttribute("data-cluster"));
5011
+ });
4288
5012
 
4289
- function connectLive() {
5013
+ // Debounced resize (defect 8: uncapped resize forced a full teardown+rebuild on every pixel).
5014
+ var resizeTimer = null;
5015
+ window.addEventListener("resize", function () {
5016
+ clearTimeout(resizeTimer);
5017
+ resizeTimer = setTimeout(function () {
5018
+ if (state.view === "graph") { resizeCanvas(); requestDraw(); }
5019
+ else render();
5020
+ }, 120);
5021
+ });
5022
+
5023
+ function init() {
4290
5024
  try {
4291
- var es = new EventSource("/events");
4292
- es.onmessage = function (ev) { if (ev.data === "changed") load(false); };
4293
- es.onerror = function () {
4294
- $("live-indicator").classList.add("dead");
4295
- $("live-indicator").textContent = "● offline";
4296
- };
4297
- es.onopen = function () {
4298
- $("live-indicator").classList.remove("dead");
4299
- $("live-indicator").textContent = "● live";
4300
- };
4301
- } catch (err) {}
5025
+ var raw = $("map-data").textContent;
5026
+ var embeddedMap = JSON.parse(raw);
5027
+ resizeCanvas();
5028
+ applyMap(embeddedMap);
5029
+ setView("graph");
5030
+ hideError();
5031
+ } catch (err) {
5032
+ showError("Failed to initialize the map viewer: " + (err && err.message ? err.message : err));
5033
+ return;
5034
+ }
5035
+ refreshFromServer().catch(function () {});
5036
+ connectLive();
4302
5037
  }
5038
+ init();
4303
5039
 
4304
- window.addEventListener("resize", function () { render(); });
4305
- load(true).then(connectLive);
4306
5040
  </script>
4307
5041
  </body>
4308
- </html>`;
5042
+ </html>
5043
+ `;
4309
5044
  }
4310
5045
 
4311
5046
  // GRAPH_REPORT.md — graphify's signature skimmable narrative. Fully deterministic (no LLM, no Date).
@@ -4316,7 +5051,10 @@ function visRenderReport(map) {
4316
5051
  L.push(`# ${map.root} — Codebase Graph Report`, "");
4317
5052
  L.push("_Generated by `draht-tools map-graph`. Do not edit; regenerated each build._", "");
4318
5053
  L.push("## Overview");
4319
- L.push(`${s.files} modules · ${(s.totalLoc || 0).toLocaleString()} LOC · ${s.packages} packages · ${s.edges} import edges · ${s.entryPoints} entry points · ${(map.clusters || []).length} clusters.`);
5054
+ // WP2 noise policy: `modules` is code-only; non-code files scanned but excluded from the
5055
+ // graph are surfaced here so the count doesn't look like the repo was under-scanned.
5056
+ const assetsTotal = (map.assets && map.assets.total) || 0;
5057
+ L.push(`${s.files} code modules${assetsTotal ? ` (+${assetsTotal} non-code files)` : ""} · ${(s.totalLoc || 0).toLocaleString()} LOC · ${s.packages} packages · ${s.edges} import edges · ${s.entryPoints} entry points · ${(map.clusters || []).length} clusters.`);
4320
5058
  L.push(`Languages: ${langs || "n/a"}.`, "");
4321
5059
  L.push("## Key concepts (structural neighborhoods)", "");
4322
5060
  L.push("> Clusters are import-topology neighborhoods, not semantic bounded contexts — confirm before treating them as such.", "");
@@ -4381,41 +5119,58 @@ function visWriteOutputs(root, opts = {}) {
4381
5119
  // (non-quiet) build we always refresh it — otherwise template/visualization changes never reach
4382
5120
  // MAP.html when the underlying graph data is unchanged. The JSON/report stay gated above so the
4383
5121
  // committed data never churns. The post-commit hook uses --quiet and skips the HTML entirely.
4384
- if (!opts.quiet) fs.writeFileSync(htmlPath, visRenderHtml(jsonPath), "utf-8");
5122
+ if (!opts.quiet) fs.writeFileSync(htmlPath, visRenderHtml(jsonPath, map), "utf-8");
4385
5123
  return { jsonPath, htmlPath, reportPath, map, unchanged };
4386
5124
  }
4387
5125
 
4388
5126
  // --- map-graph ---
5127
+ // Whole-repo enforcement (WP6, defect 22): the graph ALWAYS maps the whole repo (from
5128
+ // findRepoRoot), regardless of cwd or a `dir` arg — a partial graph from a subdirectory silently
5129
+ // missing most of the codebase is worse than an argument being ignored with a clear note.
4389
5130
  commands["map-graph"] = function (...args) {
4390
5131
  let dir = null, quiet = false;
4391
5132
  for (const a of args) {
4392
5133
  if (a === "--quiet" || a === "-q") quiet = true;
4393
5134
  else if (!String(a).startsWith("-") && !dir) dir = a;
4394
5135
  }
4395
- const cwd = dir ? path.resolve(dir) : process.cwd();
4396
- const { jsonPath, htmlPath, reportPath, map } = visWriteOutputs(cwd, { quiet });
5136
+ const repoRoot = findRepoRoot(process.cwd());
5137
+ if (dir && path.resolve(dir) !== repoRoot) {
5138
+ console.log(`note: map-graph always maps the whole repo (${repoRoot}); '${dir}' ignored for the graph`);
5139
+ }
5140
+ const { jsonPath, htmlPath, reportPath, map } = visWriteOutputs(repoRoot, { quiet });
4397
5141
  if (quiet) { // fast refresh for hooks: MAP.json + GRAPH_REPORT.md, no HTML render
4398
- console.log(`map-graph: ${map.stats.files} modules · schemaVersion ${map.schemaVersion} · ${map.buildMs}ms → ${path.relative(cwd, jsonPath)}`);
5142
+ console.log(`map-graph: ${map.stats.files} modules · schemaVersion ${map.schemaVersion} · ${map.buildMs}ms → ${jsonPath}`);
4399
5143
  return;
4400
5144
  }
4401
5145
  console.log(banner("MAP-GRAPH"));
4402
5146
  console.log(`\nWrote:\n ${jsonPath}\n ${htmlPath}\n ${reportPath}`);
4403
5147
  console.log(`\nIndexed ${map.stats.files} modules · ${map.stats.totalLoc.toLocaleString()} LOC · ${map.stats.edges} edges · ${map.clusters.length} clusters in ${map.buildMs}ms`);
4404
- console.log(`\nRead the report: ${path.relative(cwd, reportPath)} · Serve live: draht-tools map-serve`);
5148
+ console.log(`\nRead the report: ${reportPath} · Serve live: draht-tools map-serve`);
4405
5149
  };
4406
5150
 
4407
5151
  // --- map-serve ---
5152
+ // Extensions the map-serve watcher treats as "code" (schema-relevant) — anything else (docs,
5153
+ // images, lockfiles, …) never triggers a rebuild (defect 16). Derived from VIS_LANG_BY_EXT so it
5154
+ // never drifts from the languages the graph actually parses.
5155
+ const VIS_WATCH_EXTS = new Set(
5156
+ Object.entries(VIS_LANG_BY_EXT).filter(([, lang]) => VIS_CODE_LANGS.has(lang)).map(([ext]) => ext),
5157
+ );
5158
+ // Workspace/package manifests: don't carry a "code" extension but change the dependency graph.
5159
+ const VIS_WATCH_MANIFESTS = new Set(["package.json", "pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"]);
5160
+
4408
5161
  commands["map-serve"] = function (...args) {
4409
5162
  const http = require("node:http");
4410
- const cwd = process.cwd();
5163
+ const cwd = findRepoRoot(process.cwd()); // WP6: serving from a subdir still serves the repo map
4411
5164
  let port = 4878;
4412
- let openBrowser = true;
5165
+ let openBrowser = false; // defect 15: never auto-open unless the caller explicitly opts in
4413
5166
  for (let i = 0; i < args.length; i++) {
4414
5167
  const a = args[i];
4415
5168
  if (a === "--port" || a === "-p") port = parseInt(args[++i], 10) || port;
4416
- else if (a === "--no-open") openBrowser = false;
5169
+ else if (a === "--open") openBrowser = true;
5170
+ else if (a === "--no-open") openBrowser = false; // accepted for back-compat; already the default
4417
5171
  else if (/^\d+$/.test(a)) port = parseInt(a, 10);
4418
5172
  }
5173
+ const basePort = port;
4419
5174
 
4420
5175
  const outDir = path.join(cwd, PLANNING_DIR, "codebase");
4421
5176
  const jsonPath = path.join(outDir, "MAP.json");
@@ -4424,7 +5179,7 @@ commands["map-serve"] = function (...args) {
4424
5179
  // Initial generation
4425
5180
  let lastBuild = visWriteOutputs(cwd);
4426
5181
  console.log(banner("MAP-SERVE"));
4427
- console.log(`\nServing ${path.relative(cwd, outDir)}/ on http://localhost:${port}`);
5182
+ console.log(`\nServing ${path.relative(cwd, outDir)}/`);
4428
5183
  console.log(`Indexed ${lastBuild.map.stats.files} modules in ${lastBuild.map.buildMs}ms`);
4429
5184
 
4430
5185
  const clients = new Set();
@@ -4442,76 +5197,135 @@ commands["map-serve"] = function (...args) {
4442
5197
  } catch (err) {
4443
5198
  console.error("regen failed:", err.message);
4444
5199
  }
4445
- }, 250);
5200
+ }, 400); // defect 16: was 250ms — widened so a burst of saves (format-on-save, git checkout) coalesces
4446
5201
  };
4447
5202
 
4448
5203
  // File watcher (recursive — supported on darwin/win32; fallback walks top-level dirs on linux)
4449
- const watchTargets = [cwd];
4450
5204
  const watchers = [];
4451
- const tryWatch = (target) => {
5205
+ const tryWatch = (target, recursive) => {
4452
5206
  try {
4453
- const w = fs.watch(target, { recursive: true }, (_evt, fname) => {
5207
+ const w = fs.watch(target, { recursive: recursive }, (_evt, fname) => {
4454
5208
  if (!fname) return;
4455
- const f = fname.toString();
4456
- if (VIS_DEFAULT_IGNORES.has(f.split(path.sep)[0])) return;
5209
+ // Defect 16: normalize to posix separators BEFORE any segment/substring check — the
5210
+ // previous checks below (`/node_modules/`, `/.git/`, …) silently never matched on win32,
5211
+ // where fs.watch reports paths with backslashes.
5212
+ const f = fname.toString().split(path.sep).join("/");
5213
+ const firstSeg = f.split("/")[0];
5214
+ if (VIS_DEFAULT_IGNORES.has(firstSeg)) return;
4457
5215
  if (f.includes("/node_modules/") || f.includes("/.git/")) return;
4458
- if (f.includes("/.planning/")) return;
5216
+ if (f === ".planning" || f.startsWith(".planning/") || f.includes("/.planning/")) return;
5217
+ // Defect 16: only code-language files (+ workspace manifests) trigger a rebuild — a
5218
+ // touched README or a stray editor swapfile used to rebuild the whole graph.
5219
+ const base = f.split("/").pop() || "";
5220
+ const dot = base.lastIndexOf(".");
5221
+ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : "";
5222
+ if (!VIS_WATCH_MANIFESTS.has(base) && !VIS_WATCH_EXTS.has(ext)) return;
4459
5223
  regen();
4460
5224
  });
4461
5225
  watchers.push(w);
4462
5226
  return true;
4463
5227
  } catch { return false; }
4464
5228
  };
4465
- if (!tryWatch(cwd)) {
4466
- // Fallback: watch top-level dirs non-recursively
5229
+ if (!tryWatch(cwd, true)) {
5230
+ // Fallback: watch the root and each top-level dir non-recursively — recursive fs.watch
5231
+ // threw once already, so retrying it per-directory would fail identically.
5232
+ tryWatch(cwd, false);
4467
5233
  for (const e of fs.readdirSync(cwd, { withFileTypes: true })) {
4468
5234
  if (!e.isDirectory()) continue;
4469
5235
  if (VIS_DEFAULT_IGNORES.has(e.name)) continue;
4470
- tryWatch(path.join(cwd, e.name));
5236
+ tryWatch(path.join(cwd, e.name), false);
4471
5237
  }
5238
+ if (watchers.length === 0) console.error("map-serve: file watching unavailable — live reload disabled (use the reload button).");
4472
5239
  }
4473
5240
 
4474
- const server = http.createServer((req, res) => {
5241
+ const requestHandler = (req, res) => {
4475
5242
  const url = req.url.split("?")[0];
4476
- if (url === "/" || url === "/index.html" || url === "/MAP.html") {
4477
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
4478
- res.end(fs.readFileSync(htmlPath));
4479
- } else if (url === "/MAP.json") {
4480
- res.writeHead(200, { "content-type": "application/json; charset=utf-8",
4481
- "cache-control": "no-store" });
4482
- res.end(fs.readFileSync(jsonPath));
4483
- } else if (url === "/events") {
4484
- res.writeHead(200, {
4485
- "content-type": "text/event-stream",
4486
- "cache-control": "no-cache",
4487
- connection: "keep-alive",
4488
- });
4489
- res.write("retry: 2000\n\n");
4490
- clients.add(res);
4491
- req.on("close", () => clients.delete(res));
4492
- } else if (url === "/health") {
4493
- res.writeHead(200, { "content-type": "application/json" });
4494
- res.end(JSON.stringify({ ok: true, stats: lastBuild.map.stats }));
4495
- } else {
4496
- res.writeHead(404); res.end("not found");
5243
+ try {
5244
+ if (url === "/" || url === "/index.html" || url === "/MAP.html") {
5245
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
5246
+ res.end(fs.readFileSync(htmlPath));
5247
+ } else if (url === "/MAP.json") {
5248
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
5249
+ res.end(fs.readFileSync(jsonPath));
5250
+ } else if (url === "/events") {
5251
+ // Cap held-open SSE connections. Must be a non-200 BEFORE any event-stream bytes:
5252
+ // a cleanly closed 200 stream makes EventSource reconnect on its retry interval forever
5253
+ // (a self-inflicted request storm), while a 503 fails the connection permanently and
5254
+ // flips the page's live indicator to offline — the honest signal.
5255
+ if (clients.size >= 64) { res.writeHead(503, { "content-type": "text/plain" }); res.end("too many live viewers"); return; }
5256
+ res.writeHead(200, {
5257
+ "content-type": "text/event-stream",
5258
+ "cache-control": "no-cache",
5259
+ connection: "keep-alive",
5260
+ });
5261
+ res.write("retry: 2000\n\n");
5262
+ clients.add(res);
5263
+ req.on("close", () => clients.delete(res));
5264
+ } else if (url === "/health") {
5265
+ res.writeHead(200, { "content-type": "application/json" });
5266
+ res.end(JSON.stringify({ ok: true, stats: lastBuild.map.stats }));
5267
+ } else {
5268
+ res.writeHead(404); res.end("not found");
5269
+ }
5270
+ } catch (err) {
5271
+ // A regen was in flight and the file was mid-write, or something else went wrong reading
5272
+ // disk — surface a clean 503 instead of an unhandled exception taking the server down.
5273
+ console.error("map-serve: request failed:", err.message);
5274
+ try { res.writeHead(503, { "content-type": "text/plain" }); res.end("map-serve: temporarily unavailable, retrying may help (map rebuild in flight)"); } catch { /* empty */ }
4497
5275
  }
4498
- });
5276
+ };
4499
5277
 
4500
- server.listen(port, () => {
4501
- const url = `http://localhost:${port}`;
4502
- console.log(`\n→ Open ${url}`);
4503
- console.log(` Live updates via SSE; edits to source files regenerate the map.`);
4504
- console.log(` Ctrl-C to stop.`);
4505
- if (openBrowser) {
4506
- const opener = process.platform === "darwin" ? "open"
4507
- : process.platform === "win32" ? "start" : "xdg-open";
4508
- try { execSync(`${opener} ${url}`, { stdio: "ignore" }); } catch { /* empty */ }
5278
+ // Keep SSE connections alive through idle proxies/browsers with a periodic comment line —
5279
+ // EventSource ignores lines starting with ":", so this never fires a message event.
5280
+ const pingTimer = setInterval(() => {
5281
+ for (const c of clients) {
5282
+ try { c.write(": ping\n\n"); } catch { /* empty */ }
4509
5283
  }
4510
- });
5284
+ }, 25000);
5285
+
5286
+ // Defect 13: EADDRINUSE used to crash the process. Try the next few ports instead.
5287
+ // Each attempt gets its OWN http.Server instance — reusing one instance across retries leaks a
5288
+ // stale "listening" once-listener per failed attempt, and they all fire together (with their
5289
+ // stale candidatePort closures) the moment a later attempt finally succeeds.
5290
+ let server = null;
5291
+ let boundPort = null;
5292
+ const tryListen = (candidatePort, attemptsLeft) => {
5293
+ const s = http.createServer(requestHandler);
5294
+ s.once("error", (err) => {
5295
+ try { s.close(); } catch { /* empty */ }
5296
+ if (err.code === "EADDRINUSE" && attemptsLeft > 0) {
5297
+ console.log(`port ${candidatePort} is in use, trying ${candidatePort + 1}…`);
5298
+ tryListen(candidatePort + 1, attemptsLeft - 1);
5299
+ } else if (err.code === "EADDRINUSE") {
5300
+ console.error(`map-serve: ports ${basePort}-${basePort + 10} are all in use. Pass --port <n> to pick a free one.`);
5301
+ process.exit(1);
5302
+ } else {
5303
+ console.error("map-serve: failed to start:", err.message);
5304
+ process.exit(1);
5305
+ }
5306
+ });
5307
+ // Loopback only: the map leaks repo structure and rationale comments — never expose it on the LAN.
5308
+ s.listen(candidatePort, "127.0.0.1", () => {
5309
+ server = s;
5310
+ boundPort = candidatePort;
5311
+ const url = `http://localhost:${boundPort}`;
5312
+ if (boundPort !== basePort) console.log(`(port ${basePort} was busy — bound to ${boundPort} instead)`);
5313
+ console.log(`\n→ Open ${url}`);
5314
+ console.log(` Live updates via SSE; edits to source files regenerate the map.`);
5315
+ console.log(` Ctrl-C to stop.`);
5316
+ if (openBrowser) {
5317
+ const opener = process.platform === "darwin" ? "open"
5318
+ : process.platform === "win32" ? "start" : "xdg-open";
5319
+ try { execSync(`${opener} ${url}`, { stdio: "ignore" }); } catch { /* empty */ }
5320
+ }
5321
+ });
5322
+ };
5323
+ tryListen(port, 10);
4511
5324
 
4512
5325
  const shutdown = () => {
5326
+ clearInterval(pingTimer);
4513
5327
  for (const w of watchers) try { w.close(); } catch { /* empty */ }
4514
- try { server.close(); } catch { /* empty */ }
5328
+ try { if (server) server.close(); } catch { /* empty */ }
4515
5329
  process.exit(0);
4516
5330
  };
4517
5331
  process.on("SIGINT", shutdown);
@@ -4523,7 +5337,8 @@ commands["map-serve"] = function (...args) {
4523
5337
  // ============================================================================
4524
5338
 
4525
5339
  function graphLoadMap() {
4526
- const jsonPath = path.join(process.cwd(), PLANNING_DIR, "codebase", "MAP.json");
5340
+ // WP6: resolve from the repo root, not process.cwd(), so graph-* commands work from any subdir.
5341
+ const jsonPath = path.join(findRepoRoot(process.cwd()), PLANNING_DIR, "codebase", "MAP.json");
4527
5342
  try { return { map: JSON.parse(fs.readFileSync(jsonPath, "utf-8")), jsonPath }; }
4528
5343
  catch { return { map: null, jsonPath }; }
4529
5344
  }
@@ -4797,12 +5612,17 @@ Usage: draht-tools <command> [args]
4797
5612
 
4798
5613
  Project Setup:
4799
5614
  init Check preconditions, create .planning/
4800
- map-codebase [dir] Analyze existing codebase (docs)
5615
+ map-codebase [dir] Analyze existing codebase (docs). [dir] only scopes the narrative
5616
+ doc scan — the codebase graph always covers the whole repo.
4801
5617
  map-graph [dir] [--quiet] Generate living codebase map (MAP.json + MAP.html + GRAPH_REPORT.md)
5618
+ ALWAYS maps the whole repo (from the nearest .git/workspace root);
5619
+ [dir] is ignored for the graph itself (a note is printed).
4802
5620
  --quiet: refresh MAP.json + report only (no HTML); for hooks
4803
5621
  Override functional groups via .planning/codebase/GROUPS.json
4804
5622
  Override flows via .planning/codebase/FLOWS.json
4805
- map-serve [port] Serve MAP.html with live reload on file changes
5623
+ map-serve [port] [--open] Serve MAP.html (whole repo) with live reload on file changes
5624
+ --port/-p <n>: listen port (default 4878; auto-bumps if busy)
5625
+ --open: open the browser automatically (off by default)
4806
5626
 
4807
5627
  Knowledge Graph (query MAP.json instead of grepping — run map-graph first):
4808
5628
  graph-context <file...> Where a file sits: pkg, layer, cluster, importers/imports, sinks, rationale