archtracker-mcp 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
5
  import { watch } from "fs";
6
- import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
7
- import { join as join6 } from "path";
6
+ import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
7
+ import { join as join8 } from "path";
8
8
 
9
9
  // src/analyzer/analyze.ts
10
10
  import { resolve as resolve3 } from "path";
@@ -1088,6 +1088,13 @@ var java = {
1088
1088
  if (projectFiles.has(full)) return full;
1089
1089
  }
1090
1090
  }
1091
+ for (let i = 1; i < segments.length; i++) {
1092
+ const filePath = segments.slice(i).join("/") + ".java";
1093
+ for (const srcRoot of ["", "src/main/java/", "src/", "app/src/main/java/"]) {
1094
+ const full = join3(rootDir, srcRoot, filePath);
1095
+ if (projectFiles.has(full)) return full;
1096
+ }
1097
+ }
1091
1098
  return null;
1092
1099
  },
1093
1100
  defaultExclude: ["build", "target", "\\.gradle", "\\.idea"]
@@ -1142,12 +1149,18 @@ var php = {
1142
1149
  importPatterns: [
1143
1150
  // require/include/require_once/include_once 'path'
1144
1151
  { regex: /\b(?:require|include)(?:_once)?\s+['"]([^'"]+)['"]/gm },
1152
+ // require_once __DIR__ . '/path' (common PHP pattern)
1153
+ { regex: /\b(?:require|include)(?:_once)?\s+__DIR__\s*\.\s*['"]([^'"]+)['"]/gm },
1145
1154
  // Bug #9 fix: use Namespace\Class — skip `function` and `const` keywords
1146
1155
  { regex: /^use\s+(?:function\s+|const\s+)?([\w\\]+)/gm }
1147
1156
  ],
1148
1157
  resolveImport(importPath, sourceFile, rootDir, projectFiles) {
1149
- if (importPath.includes("/") || importPath.endsWith(".php")) {
1150
- const withExt = importPath.endsWith(".php") ? importPath : importPath + ".php";
1158
+ let normalizedPath = importPath;
1159
+ if (normalizedPath.startsWith("/")) {
1160
+ normalizedPath = normalizedPath.slice(1);
1161
+ }
1162
+ if (normalizedPath.includes("/") || normalizedPath.endsWith(".php")) {
1163
+ const withExt = normalizedPath.endsWith(".php") ? normalizedPath : normalizedPath + ".php";
1151
1164
  const fromSource = resolve2(dirname(sourceFile), withExt);
1152
1165
  if (projectFiles.has(fromSource)) return fromSource;
1153
1166
  const fromRoot2 = join3(rootDir, withExt);
@@ -1163,15 +1176,46 @@ var php = {
1163
1176
  },
1164
1177
  defaultExclude: ["vendor"]
1165
1178
  };
1179
+ var SWIFT_SKIP_FILES = /* @__PURE__ */ new Set(["Package", "main", "AppDelegate", "SceneDelegate"]);
1166
1180
  var swift = {
1167
1181
  id: "swift",
1168
1182
  extensions: [".swift"],
1169
1183
  commentStyle: "c-style",
1170
- importPatterns: [
1171
- // Bug #10 fix: import ModuleName and @testable import ModuleName
1172
- { regex: /^(?:@testable\s+)?import\s+(?:class\s+|struct\s+|enum\s+|protocol\s+|func\s+|var\s+|let\s+|typealias\s+)?(\w+)/gm }
1173
- ],
1174
- resolveImport(importPath, sourceFile, rootDir, projectFiles) {
1184
+ importPatterns: [],
1185
+ // handled by extractImports
1186
+ extractImports(content, filePath, _rootDir, projectFiles) {
1187
+ const imports = [];
1188
+ const moduleRegex = /^(?:@testable\s+)?import\s+(?:class\s+|struct\s+|enum\s+|protocol\s+|func\s+|var\s+|let\s+|typealias\s+)?(\w+)/gm;
1189
+ let match;
1190
+ while ((match = moduleRegex.exec(content)) !== null) {
1191
+ imports.push(match[1]);
1192
+ }
1193
+ const typeMap = /* @__PURE__ */ new Map();
1194
+ for (const f of projectFiles) {
1195
+ if (f === filePath || !f.endsWith(".swift")) continue;
1196
+ const basename = f.split("/").pop().replace(/\.swift$/, "");
1197
+ if (!basename || SWIFT_SKIP_FILES.has(basename)) continue;
1198
+ typeMap.set(basename, f);
1199
+ }
1200
+ if (typeMap.size > 0) {
1201
+ const escaped = [...typeMap.keys()].map(
1202
+ (n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1203
+ );
1204
+ const combined = new RegExp(`\\b(${escaped.join("|")})\\b`, "g");
1205
+ const matched = /* @__PURE__ */ new Set();
1206
+ while ((match = combined.exec(content)) !== null) {
1207
+ const typeName = match[1];
1208
+ const targetPath = typeMap.get(typeName);
1209
+ if (targetPath && !matched.has(targetPath)) {
1210
+ matched.add(targetPath);
1211
+ imports.push(targetPath);
1212
+ }
1213
+ }
1214
+ }
1215
+ return imports;
1216
+ },
1217
+ resolveImport(importPath, _sourceFile, rootDir, projectFiles) {
1218
+ if (projectFiles.has(importPath)) return importPath;
1175
1219
  const spmDir = join3(rootDir, "Sources", importPath);
1176
1220
  for (const f of projectFiles) {
1177
1221
  if (f.startsWith(spmDir + "/") && f.endsWith(".swift")) return f;
@@ -1196,7 +1240,8 @@ var kotlin = {
1196
1240
  if (cleanPath.endsWith(".")) {
1197
1241
  cleanPath = cleanPath.slice(0, -1);
1198
1242
  }
1199
- const filePath = cleanPath.replace(/\./g, "/");
1243
+ const segments = cleanPath.split(".");
1244
+ const filePath = segments.join("/");
1200
1245
  for (const ext of [".kt", ".kts"]) {
1201
1246
  for (const srcRoot of [
1202
1247
  "",
@@ -1210,6 +1255,22 @@ var kotlin = {
1210
1255
  if (projectFiles.has(full)) return full;
1211
1256
  }
1212
1257
  }
1258
+ for (let i = 1; i < segments.length; i++) {
1259
+ const suffixPath = segments.slice(i).join("/");
1260
+ for (const ext of [".kt", ".kts"]) {
1261
+ for (const srcRoot of [
1262
+ "",
1263
+ "src/main/kotlin/",
1264
+ "src/main/java/",
1265
+ "src/",
1266
+ "app/src/main/kotlin/",
1267
+ "app/src/main/java/"
1268
+ ]) {
1269
+ const full = join3(rootDir, srcRoot, suffixPath + ext);
1270
+ if (projectFiles.has(full)) return full;
1271
+ }
1272
+ }
1273
+ }
1213
1274
  return null;
1214
1275
  },
1215
1276
  defaultExclude: ["build", "\\.gradle", "\\.idea"]
@@ -1295,8 +1356,10 @@ var dart = {
1295
1356
  const prefix = `package:${ownPackage}/`;
1296
1357
  if (!importPath.startsWith(prefix)) return null;
1297
1358
  const relPath = importPath.slice(prefix.length);
1298
- const full = join3(rootDir, "lib", relPath);
1299
- if (projectFiles.has(full)) return full;
1359
+ const libPath = join3(rootDir, "lib", relPath);
1360
+ if (projectFiles.has(libPath)) return libPath;
1361
+ const rootPath = join3(rootDir, relPath);
1362
+ if (projectFiles.has(rootPath)) return rootPath;
1300
1363
  return null;
1301
1364
  }
1302
1365
  const resolved = resolve2(dirname(sourceFile), importPath);
@@ -1359,6 +1422,15 @@ var scala = {
1359
1422
  }
1360
1423
  }
1361
1424
  }
1425
+ for (let i = 1; i < segments.length; i++) {
1426
+ const suffixPath = segments.slice(i).join("/");
1427
+ for (const ext of [".scala", ".sc"]) {
1428
+ for (const srcRoot of ["", "src/main/scala/", "src/", "app/"]) {
1429
+ const full = join3(rootDir, srcRoot, suffixPath + ext);
1430
+ if (projectFiles.has(full)) return full;
1431
+ }
1432
+ }
1433
+ }
1362
1434
  return null;
1363
1435
  },
1364
1436
  defaultExclude: ["target", "\\.bsp", "\\.metals", "\\.bloop"]
@@ -1546,6 +1618,11 @@ var en = {
1546
1618
  "analyze.snapshotSaved": "\nSnapshot saved alongside analysis.",
1547
1619
  // CI
1548
1620
  "ci.generated": "GitHub Actions workflow generated: {path}",
1621
+ // Layers
1622
+ "layers.alreadyExists": "layers.json already exists. Edit it manually to modify.",
1623
+ "layers.created": "Created .archtracker/layers.json \u2014 edit it to configure your layers.",
1624
+ "layers.notFound": "No .archtracker/layers.json found. Run 'archtracker layers init' to create one.",
1625
+ "layers.header": "Configured layers ({count}):",
1549
1626
  // Web viewer
1550
1627
  "web.starting": "Starting architecture viewer...",
1551
1628
  "web.listening": "Architecture graph available at: http://localhost:{port}",
@@ -1634,6 +1711,11 @@ var ja = {
1634
1711
  "analyze.snapshotSaved": "\n\u5206\u6790\u3068\u540C\u6642\u306B\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F\u3002",
1635
1712
  // CI
1636
1713
  "ci.generated": "GitHub Actions \u30EF\u30FC\u30AF\u30D5\u30ED\u30FC\u3092\u751F\u6210\u3057\u307E\u3057\u305F: {path}",
1714
+ // Layers
1715
+ "layers.alreadyExists": "layers.json \u306F\u65E2\u306B\u5B58\u5728\u3057\u307E\u3059\u3002\u76F4\u63A5\u7DE8\u96C6\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
1716
+ "layers.created": ".archtracker/layers.json \u3092\u4F5C\u6210\u3057\u307E\u3057\u305F\u3002\u30EC\u30A4\u30E4\u30FC\u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
1717
+ "layers.notFound": ".archtracker/layers.json \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002'archtracker layers init' \u3067\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
1718
+ "layers.header": "\u8A2D\u5B9A\u6E08\u307F\u30EC\u30A4\u30E4\u30FC ({count}\u4EF6):",
1637
1719
  // Web viewer
1638
1720
  "web.starting": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30D3\u30E5\u30FC\u30A2\u30FC\u3092\u8D77\u52D5\u4E2D...",
1639
1721
  "web.listening": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30B0\u30E9\u30D5: http://localhost:{port}",
@@ -1710,13 +1792,396 @@ function formatAnalysisReport(graph, options = {}) {
1710
1792
  return lines.join("\n");
1711
1793
  }
1712
1794
 
1795
+ // src/analyzer/multi-layer.ts
1796
+ import { resolve as resolve4, join as join4 } from "path";
1797
+ import { readFileSync as readFileSync2 } from "fs";
1798
+ var LAYER_COLORS = [
1799
+ "#58a6ff",
1800
+ "#3fb950",
1801
+ "#d2a8ff",
1802
+ "#f0883e",
1803
+ "#79c0ff",
1804
+ "#56d4dd",
1805
+ "#db61a2",
1806
+ "#f778ba",
1807
+ "#ffa657",
1808
+ "#7ee787"
1809
+ ];
1810
+ async function analyzeMultiLayer(projectRoot, layerDefs) {
1811
+ const layers = {};
1812
+ const layerMetadata = [];
1813
+ for (let idx = 0; idx < layerDefs.length; idx++) {
1814
+ const def = layerDefs[idx];
1815
+ const targetDir = resolve4(projectRoot, def.targetDir);
1816
+ const graph = await analyzeProject(targetDir, {
1817
+ exclude: def.exclude,
1818
+ language: def.language
1819
+ });
1820
+ const language = def.language ?? await detectLanguage(targetDir) ?? "javascript";
1821
+ layers[def.name] = graph;
1822
+ layerMetadata.push({
1823
+ name: def.name,
1824
+ originalRootDir: graph.rootDir,
1825
+ language,
1826
+ color: def.color ?? LAYER_COLORS[idx % LAYER_COLORS.length],
1827
+ description: def.description,
1828
+ fileCount: graph.totalFiles,
1829
+ edgeCount: graph.totalEdges
1830
+ });
1831
+ }
1832
+ const merged = mergeLayerGraphs(projectRoot, layers);
1833
+ return { layers, layerMetadata, merged };
1834
+ }
1835
+ function detectCrossLayerConnections(layers, layerDefs) {
1836
+ const MIN_NAME_LENGTH = 6;
1837
+ const MIN_SCORE_THRESHOLD = 10;
1838
+ const layerIdentifiers = /* @__PURE__ */ new Map();
1839
+ for (const [layerName, graph] of Object.entries(layers)) {
1840
+ const identifiers = /* @__PURE__ */ new Map();
1841
+ for (const filePath of Object.keys(graph.files)) {
1842
+ const basename = filePath.split("/").pop();
1843
+ const nameNoExt = basename.replace(/\.[^.]+$/, "");
1844
+ if (nameNoExt.length < MIN_NAME_LENGTH || GENERIC_BASENAMES.has(nameNoExt.toLowerCase())) continue;
1845
+ identifiers.set(nameNoExt, filePath);
1846
+ }
1847
+ layerIdentifiers.set(layerName, identifiers);
1848
+ }
1849
+ const nameLayerCount = /* @__PURE__ */ new Map();
1850
+ for (const [, ids] of layerIdentifiers) {
1851
+ for (const name of ids.keys()) {
1852
+ nameLayerCount.set(name, (nameLayerCount.get(name) ?? 0) + 1);
1853
+ }
1854
+ }
1855
+ const pairBest = /* @__PURE__ */ new Map();
1856
+ function tryAdd(pairKey, conn, score) {
1857
+ if (score < MIN_SCORE_THRESHOLD) return;
1858
+ const existing = pairBest.get(pairKey);
1859
+ if (!existing || score > existing.score) {
1860
+ pairBest.set(pairKey, { conn, score });
1861
+ }
1862
+ }
1863
+ function isSelfDefined(content, name) {
1864
+ const defPatterns = [
1865
+ new RegExp(`\\b(?:class|struct|enum|interface|protocol|type|object)\\s+${escapeRegex(name)}\\b`),
1866
+ new RegExp(`\\b(?:def|func|fun|fn)\\s+${escapeRegex(name)}\\b`),
1867
+ new RegExp(`\\b${escapeRegex(name)}\\s*=\\s*(?:class|struct|type|interface)\\b`)
1868
+ ];
1869
+ return defPatterns.some((re) => re.test(content));
1870
+ }
1871
+ function isLocalImportOnly(content, name) {
1872
+ const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, "g");
1873
+ const lines = content.split("\n");
1874
+ let crossLayerRef = false;
1875
+ for (const line of lines) {
1876
+ if (!regex.test(line)) continue;
1877
+ regex.lastIndex = 0;
1878
+ const isLocalImport = /^\s*(?:from\s+[.'"]|import\s+[.'"]|require\s*\(\s*['"][.\/]|#include\s*")/.test(line);
1879
+ if (!isLocalImport) {
1880
+ crossLayerRef = true;
1881
+ break;
1882
+ }
1883
+ }
1884
+ return !crossLayerRef;
1885
+ }
1886
+ for (const [sourceLayer, graph] of Object.entries(layers)) {
1887
+ const ownNames = layerIdentifiers.get(sourceLayer) ?? /* @__PURE__ */ new Map();
1888
+ for (const filePath of Object.keys(graph.files)) {
1889
+ const absPath = join4(graph.rootDir, filePath);
1890
+ let content;
1891
+ try {
1892
+ content = readFileSync2(absPath, "utf-8");
1893
+ } catch {
1894
+ continue;
1895
+ }
1896
+ for (const [targetLayer, targetIds] of layerIdentifiers) {
1897
+ if (targetLayer === sourceLayer) continue;
1898
+ for (const [targetName, targetFile] of targetIds) {
1899
+ if (ownNames.has(targetName)) continue;
1900
+ if ((nameLayerCount.get(targetName) ?? 0) > 1) continue;
1901
+ if (!content.includes(targetName)) continue;
1902
+ const regex = new RegExp(`\\b${escapeRegex(targetName)}\\b`);
1903
+ if (!regex.test(content)) continue;
1904
+ if (isSelfDefined(content, targetName)) continue;
1905
+ if (isLocalImportOnly(content, targetName)) continue;
1906
+ const pairKey = `${sourceLayer}\u2192${targetLayer}`;
1907
+ const isPascalCase = /^[A-Z][a-z]/.test(targetName);
1908
+ const baseScore = targetName.length + (isPascalCase ? 5 : 0);
1909
+ tryAdd(pairKey, {
1910
+ fromLayer: sourceLayer,
1911
+ fromFile: filePath,
1912
+ toLayer: targetLayer,
1913
+ toFile: targetFile,
1914
+ type: "auto",
1915
+ label: targetName
1916
+ }, baseScore);
1917
+ }
1918
+ }
1919
+ for (const def of layerDefs) {
1920
+ if (def.name === sourceLayer) continue;
1921
+ const pairKey = `${sourceLayer}\u2192${def.name}`;
1922
+ const layerName = def.name;
1923
+ const suffixes = ["Client", "Service", "API", "Handler", "Provider", "Manager", "Gateway", "Proxy", "Adapter", "Connector"];
1924
+ const typedRe = new RegExp(`\\b${escapeRegex(layerName)}(?:${suffixes.join("|")})\\b`);
1925
+ if (typedRe.test(content)) {
1926
+ const targetGraph = layers[def.name];
1927
+ if (!targetGraph) continue;
1928
+ const entryFile = findEntryPoint(targetGraph);
1929
+ if (entryFile) {
1930
+ tryAdd(pairKey, {
1931
+ fromLayer: sourceLayer,
1932
+ fromFile: filePath,
1933
+ toLayer: def.name,
1934
+ toFile: entryFile,
1935
+ type: "auto",
1936
+ label: `${layerName}*`
1937
+ }, 25);
1938
+ }
1939
+ }
1940
+ }
1941
+ for (const def of layerDefs) {
1942
+ if (def.name === sourceLayer) continue;
1943
+ const pairKey = `${sourceLayer}\u2192${def.name}`;
1944
+ const dirName = def.targetDir.split("/").pop();
1945
+ const isShortName = dirName.length <= 4;
1946
+ const patterns = [];
1947
+ if (!isShortName) {
1948
+ patterns.push({ re: new RegExp(`(?:from|require|import)\\s+['"].*\\b${escapeRegex(dirName)}\\b`, "i"), score: 15 });
1949
+ patterns.push({ re: new RegExp(`['"\`/]${escapeRegex(dirName)}/[\\w]`, "i"), score: 12 });
1950
+ } else {
1951
+ patterns.push({ re: new RegExp(`(?:from|require|import)\\s+['"].*/${escapeRegex(dirName)}/`, "i"), score: 13 });
1952
+ patterns.push({ re: new RegExp(`['"\`]\\s*(?:https?://[^'"]*)?/${escapeRegex(dirName)}/[\\w]`, "i"), score: 11 });
1953
+ }
1954
+ for (const { re, score } of patterns) {
1955
+ if (re.test(content)) {
1956
+ const targetGraph = layers[def.name];
1957
+ if (!targetGraph) continue;
1958
+ const entryFile = findEntryPoint(targetGraph);
1959
+ if (entryFile) {
1960
+ tryAdd(pairKey, {
1961
+ fromLayer: sourceLayer,
1962
+ fromFile: filePath,
1963
+ toLayer: def.name,
1964
+ toFile: entryFile,
1965
+ type: "auto",
1966
+ label: `\u2192 ${def.name}`
1967
+ }, score);
1968
+ }
1969
+ break;
1970
+ }
1971
+ }
1972
+ }
1973
+ }
1974
+ }
1975
+ return [...pairBest.values()].map((v) => v.conn);
1976
+ }
1977
+ function escapeRegex(s) {
1978
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1979
+ }
1980
+ function findEntryPoint(graph) {
1981
+ const files = Object.values(graph.files);
1982
+ if (files.length === 0) return null;
1983
+ const sorted = files.sort((a, b) => b.dependents.length - a.dependents.length);
1984
+ if (sorted[0].dependents.length > 0) return sorted[0].path;
1985
+ const entryNames = ["main", "index", "app", "server", "lib", "mod"];
1986
+ for (const name of entryNames) {
1987
+ const entry = files.find((f) => {
1988
+ const basename = f.path.split("/").pop().replace(/\.[^.]+$/, "").toLowerCase();
1989
+ return basename === name;
1990
+ });
1991
+ if (entry) return entry.path;
1992
+ }
1993
+ return files[0].path;
1994
+ }
1995
+ var GENERIC_BASENAMES = /* @__PURE__ */ new Set([
1996
+ // Build / project structure
1997
+ "index",
1998
+ "main",
1999
+ "app",
2000
+ "config",
2001
+ "setup",
2002
+ "init",
2003
+ "mod",
2004
+ "package",
2005
+ "build",
2006
+ "makefile",
2007
+ "dockerfile",
2008
+ "rakefile",
2009
+ "gemfile",
2010
+ "podfile",
2011
+ // Common modules
2012
+ "utils",
2013
+ "helpers",
2014
+ "types",
2015
+ "models",
2016
+ "views",
2017
+ "controllers",
2018
+ "services",
2019
+ "lib",
2020
+ "src",
2021
+ "test",
2022
+ "spec",
2023
+ "tests",
2024
+ "bench",
2025
+ "example",
2026
+ "examples",
2027
+ // Infrastructure / patterns
2028
+ "server",
2029
+ "client",
2030
+ "routes",
2031
+ "middleware",
2032
+ "database",
2033
+ "engine",
2034
+ "error",
2035
+ "errors",
2036
+ "logger",
2037
+ "logging",
2038
+ "constants",
2039
+ "common",
2040
+ "base",
2041
+ "core",
2042
+ "data",
2043
+ "manager",
2044
+ "handler",
2045
+ "factory",
2046
+ "context",
2047
+ "state",
2048
+ "store",
2049
+ "cache",
2050
+ "queue",
2051
+ "task",
2052
+ "worker",
2053
+ "adapter",
2054
+ "bridge",
2055
+ // UI / presentation
2056
+ "event",
2057
+ "events",
2058
+ "model",
2059
+ "view",
2060
+ "home",
2061
+ "user",
2062
+ "page",
2063
+ "layout",
2064
+ "router",
2065
+ "provider",
2066
+ "component",
2067
+ "widget",
2068
+ "screen",
2069
+ "template",
2070
+ "header",
2071
+ "footer",
2072
+ "sidebar",
2073
+ "navbar",
2074
+ "dialog",
2075
+ "modal",
2076
+ "panel",
2077
+ // Data / IO
2078
+ "reader",
2079
+ "writer",
2080
+ "parser",
2081
+ "formatter",
2082
+ "serializer",
2083
+ "converter",
2084
+ "loader",
2085
+ "exporter",
2086
+ "importer",
2087
+ "transformer",
2088
+ "mapper",
2089
+ "reducer",
2090
+ "filter",
2091
+ "sorter",
2092
+ "validator",
2093
+ "checker",
2094
+ "scanner",
2095
+ "analyzer",
2096
+ // Auth / Security (generic enough to exist in many layers)
2097
+ "login",
2098
+ "register",
2099
+ "verify",
2100
+ "token",
2101
+ "session",
2102
+ "credential",
2103
+ "password",
2104
+ "permission",
2105
+ "profile",
2106
+ "account",
2107
+ "settings",
2108
+ // Network / API
2109
+ "request",
2110
+ "response",
2111
+ "endpoint",
2112
+ "controller",
2113
+ "service",
2114
+ "gateway",
2115
+ "proxy",
2116
+ "connector",
2117
+ "socket",
2118
+ "channel",
2119
+ "stream",
2120
+ "pipeline",
2121
+ // Storage / DB
2122
+ "schema",
2123
+ "migration",
2124
+ "seed",
2125
+ "fixture",
2126
+ "record",
2127
+ "entity",
2128
+ "repository",
2129
+ "storage",
2130
+ "driver",
2131
+ "connection",
2132
+ "pool",
2133
+ // Testing
2134
+ "mock",
2135
+ "stub",
2136
+ "fake",
2137
+ "helper",
2138
+ "fixture",
2139
+ "factory"
2140
+ ]);
2141
+ function mergeLayerGraphs(projectRoot, layers) {
2142
+ const mergedFiles = {};
2143
+ const mergedEdges = [];
2144
+ const mergedCircular = [];
2145
+ for (const [layerName, graph] of Object.entries(layers)) {
2146
+ for (const [origPath, node] of Object.entries(graph.files)) {
2147
+ const prefixedPath = `${layerName}/${origPath}`;
2148
+ mergedFiles[prefixedPath] = {
2149
+ path: prefixedPath,
2150
+ exists: node.exists,
2151
+ dependencies: node.dependencies.map((d) => `${layerName}/${d}`),
2152
+ dependents: node.dependents.map((d) => `${layerName}/${d}`)
2153
+ };
2154
+ }
2155
+ for (const edge of graph.edges) {
2156
+ mergedEdges.push({
2157
+ source: `${layerName}/${edge.source}`,
2158
+ target: `${layerName}/${edge.target}`,
2159
+ type: edge.type
2160
+ });
2161
+ }
2162
+ for (const circ of graph.circularDependencies) {
2163
+ mergedCircular.push({
2164
+ cycle: circ.cycle.map((f) => `${layerName}/${f}`)
2165
+ });
2166
+ }
2167
+ }
2168
+ return {
2169
+ rootDir: resolve4(projectRoot),
2170
+ files: mergedFiles,
2171
+ edges: mergedEdges,
2172
+ circularDependencies: mergedCircular,
2173
+ totalFiles: Object.keys(mergedFiles).length,
2174
+ totalEdges: mergedEdges.length
2175
+ };
2176
+ }
2177
+
1713
2178
  // src/storage/snapshot.ts
1714
2179
  import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
1715
- import { join as join4 } from "path";
2180
+ import { join as join5 } from "path";
1716
2181
  import { z } from "zod";
1717
2182
 
1718
2183
  // src/types/schema.ts
1719
- var SCHEMA_VERSION = "1.0";
2184
+ var SCHEMA_VERSION = "1.1";
1720
2185
 
1721
2186
  // src/storage/snapshot.ts
1722
2187
  var ARCHTRACKER_DIR = ".archtracker";
@@ -1740,26 +2205,27 @@ var DependencyGraphSchema = z.object({
1740
2205
  totalEdges: z.number()
1741
2206
  });
1742
2207
  var SnapshotSchema = z.object({
1743
- version: z.literal(SCHEMA_VERSION),
2208
+ version: z.enum([SCHEMA_VERSION, "1.0"]),
1744
2209
  timestamp: z.string(),
1745
2210
  rootDir: z.string(),
1746
2211
  graph: DependencyGraphSchema
1747
2212
  });
1748
- async function saveSnapshot(projectRoot, graph) {
1749
- const dirPath = join4(projectRoot, ARCHTRACKER_DIR);
1750
- const filePath = join4(dirPath, SNAPSHOT_FILE);
2213
+ async function saveSnapshot(projectRoot, graph, multiLayer) {
2214
+ const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
2215
+ const filePath = join5(dirPath, SNAPSHOT_FILE);
1751
2216
  const snapshot = {
1752
2217
  version: SCHEMA_VERSION,
1753
2218
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1754
2219
  rootDir: graph.rootDir,
1755
- graph
2220
+ graph,
2221
+ ...multiLayer ? { multiLayer } : {}
1756
2222
  };
1757
2223
  await mkdir(dirPath, { recursive: true });
1758
2224
  await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
1759
2225
  return snapshot;
1760
2226
  }
1761
2227
  async function loadSnapshot(projectRoot) {
1762
- const filePath = join4(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
2228
+ const filePath = join5(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
1763
2229
  let raw;
1764
2230
  try {
1765
2231
  raw = await readFile2(filePath, "utf-8");
@@ -1896,6 +2362,77 @@ function arraysEqual(a, b) {
1896
2362
  return true;
1897
2363
  }
1898
2364
 
2365
+ // src/storage/layers.ts
2366
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
2367
+ import { join as join6 } from "path";
2368
+ import { z as z2 } from "zod";
2369
+ var ARCHTRACKER_DIR2 = ".archtracker";
2370
+ var LAYERS_FILE = "layers.json";
2371
+ var LayerDefinitionSchema = z2.object({
2372
+ name: z2.string().min(1).regex(
2373
+ /^[a-zA-Z0-9_-]+$/,
2374
+ "Layer name must be alphanumeric (hyphens/underscores allowed)"
2375
+ ),
2376
+ targetDir: z2.string().min(1),
2377
+ language: z2.enum(LANGUAGE_IDS).optional(),
2378
+ exclude: z2.array(z2.string()).optional(),
2379
+ color: z2.string().optional(),
2380
+ description: z2.string().optional()
2381
+ });
2382
+ var CrossLayerConnectionSchema = z2.object({
2383
+ fromLayer: z2.string(),
2384
+ fromFile: z2.string(),
2385
+ toLayer: z2.string(),
2386
+ toFile: z2.string(),
2387
+ type: z2.enum(["api-call", "event", "data-flow", "manual"]),
2388
+ label: z2.string().optional()
2389
+ });
2390
+ var LayerConfigSchema = z2.object({
2391
+ version: z2.literal("1.0"),
2392
+ layers: z2.array(LayerDefinitionSchema).min(1).refine(
2393
+ (layers) => {
2394
+ const names = layers.map((l) => l.name);
2395
+ return new Set(names).size === names.length;
2396
+ },
2397
+ { message: "Layer names must be unique" }
2398
+ ),
2399
+ connections: z2.array(CrossLayerConnectionSchema).optional()
2400
+ });
2401
+ async function loadLayerConfig(projectRoot) {
2402
+ const filePath = join6(projectRoot, ARCHTRACKER_DIR2, LAYERS_FILE);
2403
+ let raw;
2404
+ try {
2405
+ raw = await readFile3(filePath, "utf-8");
2406
+ } catch (error) {
2407
+ if (isNodeError2(error) && error.code === "ENOENT") {
2408
+ return null;
2409
+ }
2410
+ throw new Error(`Failed to read ${filePath}`);
2411
+ }
2412
+ let parsed;
2413
+ try {
2414
+ parsed = JSON.parse(raw);
2415
+ } catch {
2416
+ throw new Error(`Invalid JSON in ${filePath}`);
2417
+ }
2418
+ const result = LayerConfigSchema.safeParse(parsed);
2419
+ if (!result.success) {
2420
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
2421
+ throw new Error(`layers.json validation failed:
2422
+ ${issues}`);
2423
+ }
2424
+ return result.data;
2425
+ }
2426
+ async function saveLayerConfig(projectRoot, config) {
2427
+ const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
2428
+ const filePath = join6(dirPath, LAYERS_FILE);
2429
+ await mkdir2(dirPath, { recursive: true });
2430
+ await writeFile2(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2431
+ }
2432
+ function isNodeError2(error) {
2433
+ return error instanceof Error && "code" in error;
2434
+ }
2435
+
1899
2436
  // src/web/server.ts
1900
2437
  import { createServer } from "http";
1901
2438
 
@@ -1903,6 +2440,8 @@ import { createServer } from "http";
1903
2440
  function buildGraphPage(graph, options = {}) {
1904
2441
  const locale = options.locale ?? "en";
1905
2442
  const diff = options.diff ?? null;
2443
+ const layers = options.layerMetadata ?? null;
2444
+ const crossEdges = options.crossLayerEdges ?? null;
1906
2445
  const files = Object.values(graph.files);
1907
2446
  const nodes = files.map((f) => ({
1908
2447
  id: f.path,
@@ -1911,7 +2450,8 @@ function buildGraphPage(graph, options = {}) {
1911
2450
  dependencies: f.dependencies,
1912
2451
  dependentsList: f.dependents,
1913
2452
  isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
1914
- dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : "."
2453
+ dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".",
2454
+ layer: layers && f.path.includes("/") ? f.path.substring(0, f.path.indexOf("/")) : null
1915
2455
  }));
1916
2456
  const links = graph.edges.map((e) => ({
1917
2457
  source: e.source,
@@ -1925,6 +2465,8 @@ function buildGraphPage(graph, options = {}) {
1925
2465
  const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
1926
2466
  const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
1927
2467
  const diffData = diff ? JSON.stringify(diff) : "null";
2468
+ const layersData = layers ? JSON.stringify(layers) : "null";
2469
+ const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
1928
2470
  const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
1929
2471
  return (
1930
2472
  /* html */
@@ -1989,13 +2531,25 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
1989
2531
  #tooltip .tt-out { color: var(--accent); }
1990
2532
  #tooltip .tt-in { color: var(--green); }
1991
2533
 
1992
- /* \u2500\u2500\u2500 Filters \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1993
- #filters { position: absolute; bottom: 12px; left: 12px; right: 120px; z-index: 10; display: flex; flex-wrap: wrap; gap: 5px; }
1994
- .filter-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 3px 10px; font-size: 11px; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
2534
+ /* \u2500\u2500\u2500 Filter bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2535
+ #filter-bar { position: absolute; bottom: 12px; left: 12px; right: 120px; z-index: 10; display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
2536
+ #filter-bar > * { pointer-events: auto; }
2537
+ #filter-layer-row { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
2538
+ #filter-dir-toggle { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 3px 10px; font-size: 11px; cursor: pointer; user-select: none; color: var(--text-dim); transition: all 0.15s; flex-shrink: 0; }
2539
+ #filter-dir-toggle:hover { border-color: var(--text-dim); color: var(--text); }
2540
+ #filter-dir-toggle.open { border-color: var(--accent); color: var(--text); }
2541
+ #filter-dir-panel { display: none; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; max-height: 220px; overflow-y: auto; backdrop-filter: blur(8px); }
2542
+ #filter-dir-panel.open { display: block; }
2543
+ .dir-group { margin-bottom: 8px; }
2544
+ .dir-group:last-child { margin-bottom: 0; }
2545
+ .dir-group-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
2546
+ .dir-group-label .dg-dot { width: 6px; height: 6px; border-radius: 50%; }
2547
+ .dir-group-pills { display: flex; flex-wrap: wrap; gap: 3px; }
2548
+ .filter-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 2px 8px; font-size: 10px; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 4px; }
1995
2549
  .filter-pill:hover { border-color: var(--text-dim); }
1996
2550
  .filter-pill.active { border-color: var(--accent); }
1997
- .filter-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; }
1998
- .filter-pill .pill-count { color: var(--text-muted); font-size: 10px; }
2551
+ .filter-pill .pill-dot { width: 5px; height: 5px; border-radius: 50%; }
2552
+ .filter-pill .pill-count { color: var(--text-muted); font-size: 9px; }
1999
2553
 
2000
2554
  /* \u2500\u2500\u2500 Zoom controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2001
2555
  #zoom-ctrl { position: absolute; bottom: 52px; right: 12px; z-index: 10; display: flex; flex-direction: column; gap: 2px; }
@@ -2054,6 +2608,24 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2054
2608
 
2055
2609
  /* \u2500\u2500\u2500 Help bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2056
2610
  #help-bar { position: absolute; bottom: 12px; right: 12px; z-index: 10; font-size: 11px; color: var(--text-muted); background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; transition: background 0.3s; }
2611
+
2612
+ /* \u2500\u2500\u2500 Layer hulls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2613
+ .layer-hull { fill-opacity: 0.06; stroke-width: 1.5; stroke-dasharray: 6,4; pointer-events: none; }
2614
+ .layer-hull-label { font-size: 13px; font-weight: 700; letter-spacing: 0.5px; pointer-events: none; opacity: 0.7; }
2615
+
2616
+ /* \u2500\u2500\u2500 Layer tabs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2617
+ #layer-tabs { display: flex; gap: 2px; margin-left: 12px; padding-left: 12px; border-left: 1px solid var(--border); }
2618
+ .layer-tab { padding: 4px 10px; font-size: 11px; color: var(--text-dim); cursor: pointer; border-radius: 4px; border: 1px solid transparent; transition: all 0.15s; user-select: none; display: flex; align-items: center; gap: 5px; }
2619
+ .layer-tab:hover { color: var(--text); background: var(--bg-hover); }
2620
+ .layer-tab.active { border-color: var(--accent); color: var(--text); }
2621
+ .layer-tab .lt-dot { width: 6px; height: 6px; border-radius: 50%; }
2622
+
2623
+ /* \u2500\u2500\u2500 Layer filter pills \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2624
+ .layer-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 2px 9px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
2625
+ .layer-pill:hover { border-color: var(--text-dim); }
2626
+ .layer-pill.active { border-color: var(--accent); }
2627
+ .layer-pill .lp-dot { width: 6px; height: 6px; border-radius: 50%; }
2628
+ .layer-pill .lp-count { color: var(--text-muted); font-size: 9px; font-weight: 400; }
2057
2629
  </style>
2058
2630
  </head>
2059
2631
  <body>
@@ -2064,6 +2636,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2064
2636
  <div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
2065
2637
  <div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
2066
2638
  <div class="tab" data-view="diff-view" id="diff-tab" style="display:none" data-i18n="tab.diff">Diff</div>
2639
+ <div id="layer-tabs"></div>
2067
2640
  <div class="tab-right">
2068
2641
  <div class="tab-stats">
2069
2642
  <span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
@@ -2104,6 +2677,11 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2104
2677
  <input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
2105
2678
  <div class="setting-value"><span id="gravity-val">150</span></div>
2106
2679
  </div>
2680
+ <div id="layer-gravity-setting" class="setting-group" style="display:none">
2681
+ <label>Layer Cohesion</label>
2682
+ <input type="range" id="layer-gravity-slider" min="1" max="40" value="12" oninput="setLayerGravity(this.value)">
2683
+ <div class="setting-value"><span id="layer-gravity-val">12</span></div>
2684
+ </div>
2107
2685
  <div class="setting-group">
2108
2686
  <label data-i18n="settings.language">Language</label>
2109
2687
  <div class="theme-toggle">
@@ -2111,6 +2689,12 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2111
2689
  <div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
2112
2690
  </div>
2113
2691
  </div>
2692
+ <div id="cross-layer-setting" class="setting-group" style="display:none">
2693
+ <label>Cross-layer Links</label>
2694
+ <div class="theme-toggle">
2695
+ <div class="theme-btn active" id="cross-link-toggle" onclick="toggleCrossLinks()">ON</div>
2696
+ </div>
2697
+ </div>
2114
2698
  <div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
2115
2699
  <label data-i18n="settings.export">Export</label>
2116
2700
  <div class="theme-toggle">
@@ -2130,6 +2714,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2130
2714
  <kbd>/</kbd>
2131
2715
  </div>
2132
2716
  <div class="hud-panel" id="legend-panel">
2717
+ <div id="layer-legend"></div>
2133
2718
  <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
2134
2719
  <div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
2135
2720
  <div class="legend-item"><div class="legend-dot" style="border:2px solid var(--yellow);width:6px;height:6px"></div> <span data-i18n="legend.highCoupling">High coupling</span></div>
@@ -2143,7 +2728,10 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2143
2728
  <div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
2144
2729
  <div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
2145
2730
  </div>
2146
- <div id="filters"></div>
2731
+ <div id="filter-bar">
2732
+ <div id="filter-dir-panel"></div>
2733
+ <div id="filter-layer-row"></div>
2734
+ </div>
2147
2735
  <div id="zoom-ctrl">
2148
2736
  <button onclick="zoomIn()" title="Zoom in">+</button>
2149
2737
  <button onclick="zoomOut()" title="Zoom out">\u2212</button>
@@ -2171,7 +2759,9 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2171
2759
  <div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
2172
2760
  <div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
2173
2761
  </div>
2174
- <div id="hier-filters" style="position:absolute;bottom:42px;left:12px;right:120px;z-index:10;display:flex;flex-wrap:wrap;gap:5px;"></div>
2762
+ <div id="hier-filter-bar" style="position:absolute;bottom:12px;left:12px;right:120px;z-index:10;display:none;">
2763
+ <div id="hier-filter-row" style="display:flex;flex-wrap:wrap;gap:4px;"></div>
2764
+ </div>
2175
2765
  <div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
2176
2766
  </div>
2177
2767
 
@@ -2263,7 +2853,7 @@ function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
2263
2853
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2264
2854
  const STORAGE_KEY = 'archtracker-settings';
2265
2855
  function saveSettings() {
2266
- const s = { theme: document.body.getAttribute('data-theme') || 'dark', fontSize: document.getElementById('font-size-val').textContent, nodeSize: document.getElementById('node-size-val').textContent, linkOpacity: document.getElementById('link-opacity-val').textContent, gravity: document.getElementById('gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
2856
+ const s = { theme: document.body.getAttribute('data-theme') || 'dark', fontSize: document.getElementById('font-size-val').textContent, nodeSize: document.getElementById('node-size-val').textContent, linkOpacity: document.getElementById('link-opacity-val').textContent, gravity: document.getElementById('gravity-val').textContent, layerGravity: document.getElementById('layer-gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
2267
2857
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
2268
2858
  }
2269
2859
  function loadSettings() {
@@ -2312,6 +2902,15 @@ window.setGravity = (v) => {
2312
2902
  }
2313
2903
  saveSettings();
2314
2904
  };
2905
+ let layerGravity = 12;
2906
+ window.setLayerGravity = (v) => {
2907
+ layerGravity = +v;
2908
+ document.getElementById('layer-gravity-val').textContent = v;
2909
+ if (typeof simulation !== 'undefined' && typeof applyLayerFilter === 'function') {
2910
+ applyLayerFilter();
2911
+ }
2912
+ saveSettings();
2913
+ };
2315
2914
 
2316
2915
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2317
2916
  // EXPORT
@@ -2347,6 +2946,8 @@ window.exportPNG = () => {
2347
2946
  // DATA
2348
2947
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2349
2948
  const DATA = ${graphData};
2949
+ const LAYERS = ${layersData};
2950
+ const CROSS_EDGES = ${crossEdgesData};
2350
2951
  const W = window.innerWidth, H = window.innerHeight - 44;
2351
2952
  const circularSet = new Set(DATA.circularFiles);
2352
2953
 
@@ -2367,6 +2968,7 @@ if (_savedSettings) {
2367
2968
  if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
2368
2969
  if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
2369
2970
  if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
2971
+ if (_savedSettings.layerGravity) { document.getElementById('layer-gravity-slider').value = _savedSettings.layerGravity; document.getElementById('layer-gravity-val').textContent = _savedSettings.layerGravity; layerGravity = +_savedSettings.layerGravity; }
2370
2972
  }
2371
2973
 
2372
2974
  document.getElementById('s-files').textContent = DATA.nodes.length;
@@ -2376,9 +2978,21 @@ document.getElementById('s-circular').textContent = DATA.circularFiles.length;
2376
2978
  const dirColor = d3.scaleOrdinal()
2377
2979
  .domain(DATA.dirs)
2378
2980
  .range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
2981
+
2982
+ // Layer color map (from LAYERS metadata)
2983
+ const layerColorMap = {};
2984
+ let activeLayerFilter = null; // DEPRECATED \u2014 kept for backward compat, always null with multi-select tabs
2985
+ const activeLayers = new Set(); // empty = no filter (show all); non-empty = show only selected
2986
+ if (LAYERS) {
2987
+ LAYERS.forEach(l => { layerColorMap[l.name] = l.color; });
2988
+ document.getElementById('layer-gravity-setting').style.display = '';
2989
+ }
2990
+
2379
2991
  function nodeColor(d) {
2380
2992
  if (circularSet.has(d.id)) return '#f97583';
2381
2993
  if (d.isOrphan) return '#484f58';
2994
+ // Layer coloring: all-visible or multi-select \u2192 layer colors; single-select \u2192 dir colors
2995
+ if (LAYERS && d.layer && layerColorMap[d.layer] && activeLayers.size !== 1) return layerColorMap[d.layer];
2382
2996
  return dirColor(d.dir);
2383
2997
  }
2384
2998
  function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
@@ -2388,13 +3002,18 @@ function fileName(id) { return id.split('/').pop(); }
2388
3002
  // TAB SWITCHING
2389
3003
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2390
3004
  let hierBuilt = false;
3005
+ let hierRelayout = null;
3006
+ let hierSyncFromTab = null;
2391
3007
  document.querySelectorAll('.tab').forEach(tab => {
2392
3008
  tab.addEventListener('click', () => {
2393
3009
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
2394
3010
  document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
2395
3011
  tab.classList.add('active');
2396
3012
  document.getElementById(tab.dataset.view).classList.add('active');
2397
- if (tab.dataset.view === 'hier-view' && !hierBuilt) { buildHierarchy(); hierBuilt = true; }
3013
+ if (tab.dataset.view === 'hier-view') {
3014
+ if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
3015
+ if (hierSyncFromTab) { hierSyncFromTab(null); hierRelayout(); }
3016
+ }
2398
3017
  });
2399
3018
  });
2400
3019
 
@@ -2485,6 +3104,35 @@ const link = g.append('g').selectAll('line').data(DATA.links).join('line')
2485
3104
  .attr('marker-end','url(#arrow-0)')
2486
3105
  .attr('opacity', baseLinkOpacity);
2487
3106
 
3107
+ // Cross-layer links (from layers.json connections)
3108
+ defs.append('marker').attr('id','arrow-cross').attr('viewBox','0 -4 8 8')
3109
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3110
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#f0883e');
3111
+
3112
+ const crossLinkData = (CROSS_EDGES || []).map(e => ({
3113
+ source: e.fromLayer + '/' + e.fromFile,
3114
+ target: e.toLayer + '/' + e.toFile,
3115
+ sourceLayer: e.fromLayer,
3116
+ targetLayer: e.toLayer,
3117
+ type: e.type || 'api-call',
3118
+ label: e.label || e.type || '',
3119
+ })).filter(e => DATA.nodes.some(n => n.id === e.source) && DATA.nodes.some(n => n.id === e.target));
3120
+
3121
+ const crossLinkG = g.append('g');
3122
+ const crossLink = crossLinkG.selectAll('line').data(crossLinkData).join('line')
3123
+ .attr('stroke', '#f0883e')
3124
+ .attr('stroke-width', 2)
3125
+ .attr('stroke-dasharray', '8,4')
3126
+ .attr('marker-end', 'url(#arrow-cross)')
3127
+ .attr('opacity', 0.7);
3128
+ const crossLabel = crossLinkG.selectAll('text').data(crossLinkData).join('text')
3129
+ .text(d => d.label)
3130
+ .attr('font-size', 9)
3131
+ .attr('fill', '#f0883e')
3132
+ .attr('text-anchor', 'middle')
3133
+ .attr('opacity', 0.8)
3134
+ .attr('pointer-events', 'none');
3135
+
2488
3136
  // Nodes
2489
3137
  const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
2490
3138
  .attr('cursor','pointer')
@@ -2527,6 +3175,396 @@ const simulation = d3.forceSimulation(DATA.nodes)
2527
3175
  node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
2528
3176
  });
2529
3177
 
3178
+ // \u2500\u2500\u2500 Layer convex hulls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3179
+ let hullGroup = null;
3180
+ const activeDirs = new Set(DATA.dirs);
3181
+ const dirCounts = {};
3182
+ DATA.nodes.forEach(n => dirCounts[n.dir] = (dirCounts[n.dir] || 0) + 1);
3183
+ var applyLayerFilter = null; // hoisted for dir-filter integration
3184
+
3185
+ if (LAYERS && LAYERS.length > 0) {
3186
+ // \u2500\u2500\u2500 Water droplet physics: intra-layer cohesion + inter-layer separation \u2500\u2500\u2500
3187
+ const allLayerCount = LAYERS.length;
3188
+ const allBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(allLayerCount));
3189
+ // Pre-compute full-circle positions for all layers (used when no filter)
3190
+ const allLayerCenters = {};
3191
+ LAYERS.forEach((l, idx) => {
3192
+ const angle = (2 * Math.PI * idx) / allLayerCount - Math.PI / 2;
3193
+ allLayerCenters[l.name] = { x: Math.cos(angle) * allBaseRadius, y: Math.sin(angle) * allBaseRadius };
3194
+ });
3195
+
3196
+ // Dynamic center calculation: compact when multi-selecting, full spread when all
3197
+ function getLayerCenters() {
3198
+ if (activeLayers.size <= 1) return allLayerCenters; // 0 = all, 1 = single (centered)
3199
+ // Multi-select: arrange only selected layers compactly on a smaller circle
3200
+ const selected = LAYERS.filter(l => activeLayers.has(l.name));
3201
+ const count = selected.length;
3202
+ const compactRadius = Math.max(40, Math.min(W, H) * 0.03 * Math.sqrt(count));
3203
+ const centers = {};
3204
+ selected.forEach((l, idx) => {
3205
+ const angle = (2 * Math.PI * idx) / count - Math.PI / 2;
3206
+ centers[l.name] = { x: Math.cos(angle) * compactRadius, y: Math.sin(angle) * compactRadius };
3207
+ });
3208
+ return centers;
3209
+ }
3210
+
3211
+ // Replace default centering forces with per-layer positioning
3212
+ const layerStrength = layerGravity / 100;
3213
+ simulation.force('x', null).force('y', null).force('center', null);
3214
+ simulation.force('layerX', d3.forceX(d => allLayerCenters[d.layer]?.x || 0).strength(d => d.layer ? layerStrength : 0.03));
3215
+ simulation.force('layerY', d3.forceY(d => allLayerCenters[d.layer]?.y || 0).strength(d => d.layer ? layerStrength : 0.03));
3216
+
3217
+ // Custom clustering force \u2014 surface tension pulling nodes toward their layer centroid
3218
+ function clusterForce() {
3219
+ let nodes;
3220
+ function force(alpha) {
3221
+ const centroids = {};
3222
+ const counts = {};
3223
+ nodes.forEach(n => {
3224
+ if (!n.layer) return;
3225
+ if (!centroids[n.layer]) { centroids[n.layer] = {x: 0, y: 0}; counts[n.layer] = 0; }
3226
+ centroids[n.layer].x += n.x;
3227
+ centroids[n.layer].y += n.y;
3228
+ counts[n.layer]++;
3229
+ });
3230
+ Object.keys(centroids).forEach(k => {
3231
+ centroids[k].x /= counts[k];
3232
+ centroids[k].y /= counts[k];
3233
+ });
3234
+ // Pull each node toward its layer centroid (surface tension)
3235
+ const strength = 0.2;
3236
+ nodes.forEach(n => {
3237
+ if (!n.layer || !centroids[n.layer]) return;
3238
+ n.vx += (centroids[n.layer].x - n.x) * alpha * strength;
3239
+ n.vy += (centroids[n.layer].y - n.y) * alpha * strength;
3240
+ });
3241
+ }
3242
+ force.initialize = (n) => { nodes = n; };
3243
+ return force;
3244
+ }
3245
+ simulation.force('cluster', clusterForce());
3246
+
3247
+ // Boost link strength for intra-layer edges (tighter connections within a layer)
3248
+ simulation.force('link').strength(l => {
3249
+ const sLayer = (l.source.layer ?? l.source);
3250
+ const tLayer = (l.target.layer ?? l.target);
3251
+ return sLayer === tLayer ? 0.4 : 0.1;
3252
+ });
3253
+
3254
+ hullGroup = g.insert('g', ':first-child');
3255
+
3256
+ function updateHulls() {
3257
+ if (!hullGroup) return;
3258
+ hullGroup.selectAll('*').remove();
3259
+ // Show hulls always (filter to selected layers when focused)
3260
+
3261
+ LAYERS.forEach(layer => {
3262
+ if (activeLayers.size > 0 && !activeLayers.has(layer.name)) return;
3263
+ const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
3264
+ if (layerNodes.length === 0) return;
3265
+
3266
+ const points = [];
3267
+ layerNodes.forEach(n => {
3268
+ if (n.x == null || n.y == null) return;
3269
+ const r = nodeRadius(n) * nodeScale + 30;
3270
+ // Add expanded points for a nicer hull shape
3271
+ for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
3272
+ points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
3273
+ }
3274
+ });
3275
+
3276
+ if (points.length < 3) {
3277
+ // Fallback: circle for 1-2 nodes
3278
+ const cx = layerNodes.reduce((s, n) => s + (n.x || 0), 0) / layerNodes.length;
3279
+ const cy = layerNodes.reduce((s, n) => s + (n.y || 0), 0) / layerNodes.length;
3280
+ const maxR = Math.max(60, ...layerNodes.map(n => {
3281
+ const dx = (n.x || 0) - cx, dy = (n.y || 0) - cy;
3282
+ return Math.sqrt(dx*dx + dy*dy) + nodeRadius(n) * nodeScale + 30;
3283
+ }));
3284
+ hullGroup.append('circle')
3285
+ .attr('cx', cx).attr('cy', cy).attr('r', maxR)
3286
+ .attr('class', 'layer-hull')
3287
+ .attr('fill', layer.color).attr('stroke', layer.color);
3288
+ hullGroup.append('text')
3289
+ .attr('class', 'layer-hull-label')
3290
+ .attr('x', cx).attr('y', cy - maxR - 8)
3291
+ .attr('text-anchor', 'middle')
3292
+ .attr('fill', layer.color)
3293
+ .text(layer.name);
3294
+ return;
3295
+ }
3296
+
3297
+ const hull = d3.polygonHull(points);
3298
+ if (!hull) return;
3299
+
3300
+ // Smooth the hull with a cardinal closed curve
3301
+ hullGroup.append('path')
3302
+ .attr('class', 'layer-hull')
3303
+ .attr('d', d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5))(hull))
3304
+ .attr('fill', layer.color).attr('stroke', layer.color);
3305
+
3306
+ // Label at the top of the hull
3307
+ const topPt = hull.reduce((best, p) => p[1] < best[1] ? p : best, hull[0]);
3308
+ hullGroup.append('text')
3309
+ .attr('class', 'layer-hull-label')
3310
+ .attr('x', topPt[0]).attr('y', topPt[1] - 10)
3311
+ .attr('text-anchor', 'middle')
3312
+ .attr('fill', layer.color)
3313
+ .text(layer.name);
3314
+ });
3315
+ }
3316
+
3317
+ // Update hulls + cross-layer links on each tick
3318
+ simulation.on('tick', () => {
3319
+ // Regular links
3320
+ link.each(function(d) {
3321
+ const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
3322
+ const dist=Math.sqrt(dx*dx+dy*dy)||1;
3323
+ const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
3324
+ d3.select(this)
3325
+ .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
3326
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3327
+ });
3328
+ node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
3329
+ // Cross-layer links \u2014 resolve node positions by ID
3330
+ if (crossLinkData.length > 0) {
3331
+ const nodeById = {};
3332
+ DATA.nodes.forEach(n => { nodeById[n.id] = n; });
3333
+ crossLink.each(function(d) {
3334
+ const sN = nodeById[d.source], tN = nodeById[d.target];
3335
+ if (!sN || !tN) return;
3336
+ const dx = tN.x - sN.x, dy = tN.y - sN.y;
3337
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
3338
+ const rS = nodeRadius(sN) * nodeScale, rT = nodeRadius(tN) * nodeScale;
3339
+ d3.select(this)
3340
+ .attr('x1', sN.x + (dx/dist)*rS).attr('y1', sN.y + (dy/dist)*rS)
3341
+ .attr('x2', tN.x - (dx/dist)*rT).attr('y2', tN.y - (dy/dist)*rT);
3342
+ });
3343
+ crossLabel.each(function(d) {
3344
+ const sN = nodeById[d.source], tN = nodeById[d.target];
3345
+ if (!sN || !tN) return;
3346
+ d3.select(this).attr('x', (sN.x + tN.x) / 2).attr('y', (sN.y + tN.y) / 2 - 6);
3347
+ });
3348
+ }
3349
+ updateHulls();
3350
+ });
3351
+
3352
+ // \u2500\u2500\u2500 Layer legend \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3353
+ const layerLegend = document.getElementById('layer-legend');
3354
+ LAYERS.forEach(layer => {
3355
+ const item = document.createElement('div');
3356
+ item.className = 'legend-item';
3357
+ item.innerHTML = '<div class="legend-dot" style="background:' + layer.color + '"></div> ' + layer.name;
3358
+ layerLegend.appendChild(item);
3359
+ });
3360
+ // Cross-layer edge legend
3361
+ if (CROSS_EDGES && CROSS_EDGES.length > 0) {
3362
+ const crossItem = document.createElement('div');
3363
+ crossItem.className = 'legend-item';
3364
+ crossItem.innerHTML = '<span style="color:#f0883e;font-size:11px">- - \u2192</span> Cross-layer link';
3365
+ layerLegend.appendChild(crossItem);
3366
+ }
3367
+ // Add separator
3368
+ const sep = document.createElement('hr');
3369
+ sep.style.cssText = 'border:none;border-top:1px solid var(--border);margin:6px 0;';
3370
+ layerLegend.appendChild(sep);
3371
+
3372
+ // \u2500\u2500\u2500 Layer tabs (multi-select toggles in tab bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3373
+ const layerTabsEl = document.getElementById('layer-tabs');
3374
+ const allTab = document.createElement('div');
3375
+ allTab.className = 'layer-tab active';
3376
+ allTab.textContent = 'All';
3377
+ allTab.onclick = () => {
3378
+ activeLayers.clear();
3379
+ syncLayerTabUI();
3380
+ applyLayerFilter();
3381
+ if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
3382
+ };
3383
+ layerTabsEl.appendChild(allTab);
3384
+
3385
+ LAYERS.forEach(layer => {
3386
+ const tab = document.createElement('div');
3387
+ tab.className = 'layer-tab';
3388
+ tab.dataset.layer = layer.name;
3389
+ tab.innerHTML = '<div class="lt-dot" style="background:' + layer.color + '"></div>' + layer.name;
3390
+ tab.onclick = (e) => {
3391
+ if (e.shiftKey) {
3392
+ // Shift+click: solo this layer
3393
+ activeLayers.clear();
3394
+ activeLayers.add(layer.name);
3395
+ } else {
3396
+ // Toggle
3397
+ if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
3398
+ else activeLayers.add(layer.name);
3399
+ }
3400
+ syncLayerTabUI();
3401
+ applyLayerFilter();
3402
+ if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
3403
+ };
3404
+ layerTabsEl.appendChild(tab);
3405
+ });
3406
+
3407
+ function syncLayerTabUI() {
3408
+ allTab.classList.toggle('active', activeLayers.size === 0);
3409
+ layerTabsEl.querySelectorAll('.layer-tab[data-layer]').forEach(t => {
3410
+ t.classList.toggle('active', activeLayers.has(t.dataset.layer));
3411
+ });
3412
+ // Also sync the filter bar layer pills
3413
+ layerRowEl.querySelectorAll('.layer-pill[data-layer]').forEach(p => {
3414
+ p.classList.toggle('active', activeLayers.has(p.dataset.layer));
3415
+ });
3416
+ }
3417
+
3418
+ applyLayerFilter = function() {
3419
+ const isSingleLayer = activeLayers.size === 1;
3420
+ const hasLayerFilter = activeLayers.size > 0;
3421
+ node.attr('display', d => {
3422
+ if (!activeDirs.has(d.dir)) return 'none';
3423
+ if (hasLayerFilter && !activeLayers.has(d.layer)) return 'none';
3424
+ return null;
3425
+ });
3426
+ link.attr('display', l => {
3427
+ const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3428
+ const sN = DATA.nodes.find(n => n.id === s), tN = DATA.nodes.find(n => n.id === t);
3429
+ if (!sN || !tN) return 'none';
3430
+ if (!activeDirs.has(sN.dir) || !activeDirs.has(tN.dir)) return 'none';
3431
+ if (hasLayerFilter && (!activeLayers.has(sN.layer) || !activeLayers.has(tN.layer))) return 'none';
3432
+ return null;
3433
+ });
3434
+ // Refresh node colors: single-layer = dir-based, multi-layer = layer-based
3435
+ node.select('circle')
3436
+ .attr('fill', nodeColor)
3437
+ .attr('stroke', d => d.deps >= 5 ? 'var(--yellow)' : nodeColor(d));
3438
+ // Cross-layer links: respect user toggle + layer filter
3439
+ if (typeof crossLink !== 'undefined') {
3440
+ if (!crossLinksUserEnabled || isSingleLayer) {
3441
+ crossLink.attr('display', 'none');
3442
+ crossLabel.attr('display', 'none');
3443
+ } else if (hasLayerFilter) {
3444
+ crossLink.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
3445
+ crossLabel.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
3446
+ } else {
3447
+ crossLink.attr('display', null);
3448
+ crossLabel.attr('display', null);
3449
+ }
3450
+ }
3451
+ // Update stats
3452
+ const visibleNodes = DATA.nodes.filter(d => {
3453
+ if (!activeDirs.has(d.dir)) return false;
3454
+ if (hasLayerFilter && !activeLayers.has(d.layer)) return false;
3455
+ return true;
3456
+ });
3457
+ const visibleIds = new Set(visibleNodes.map(n => n.id));
3458
+ const visibleEdges = DATA.links.filter(l => {
3459
+ const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3460
+ return visibleIds.has(s) && visibleIds.has(t);
3461
+ });
3462
+ document.getElementById('s-files').textContent = visibleNodes.length;
3463
+ document.getElementById('s-edges').textContent = visibleEdges.length;
3464
+ const visCirc = DATA.circularFiles.filter(f => visibleIds.has(f));
3465
+ document.getElementById('s-circular').textContent = visCirc.length;
3466
+ updateHulls();
3467
+ // Adjust physics: single-layer = centered, multi-select = compact, all = full spread
3468
+ const lStrength = layerGravity / 100;
3469
+ if (isSingleLayer) {
3470
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength * 3).distanceMax(800));
3471
+ simulation.force('layerX', d3.forceX(0).strength(0.03));
3472
+ simulation.force('layerY', d3.forceY(0).strength(0.03));
3473
+ } else {
3474
+ const centers = getLayerCenters();
3475
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
3476
+ simulation.force('layerX', d3.forceX(d => centers[d.layer]?.x || 0).strength(d => d.layer ? lStrength : 0.03));
3477
+ simulation.force('layerY', d3.forceY(d => centers[d.layer]?.y || 0).strength(d => d.layer ? lStrength : 0.03));
3478
+ }
3479
+ simulation.alpha(0.6).restart();
3480
+ // Zoom to fit visible nodes after simulation settles
3481
+ setTimeout(() => zoomFit(), 600);
3482
+ }
3483
+
3484
+ // \u2500\u2500\u2500 Layer filter pills (new grouped bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3485
+ const layerRowEl = document.getElementById('filter-layer-row');
3486
+ const dirPanelEl = document.getElementById('filter-dir-panel');
3487
+
3488
+ // Dir toggle button
3489
+ const dirToggle = document.createElement('div');
3490
+ dirToggle.id = 'filter-dir-toggle';
3491
+ dirToggle.textContent = '\u25B8 Dirs';
3492
+ dirToggle.onclick = () => {
3493
+ dirToggle.classList.toggle('open');
3494
+ dirPanelEl.classList.toggle('open');
3495
+ dirToggle.textContent = dirPanelEl.classList.contains('open') ? '\u25BE Dirs' : '\u25B8 Dirs';
3496
+ };
3497
+ layerRowEl.appendChild(dirToggle);
3498
+
3499
+ // Cross-layer link toggle (in settings sidebar)
3500
+ let crossLinksUserEnabled = true;
3501
+ if (crossLinkData.length > 0) {
3502
+ document.getElementById('cross-layer-setting').style.display = '';
3503
+ window.toggleCrossLinks = () => {
3504
+ crossLinksUserEnabled = !crossLinksUserEnabled;
3505
+ const btn = document.getElementById('cross-link-toggle');
3506
+ btn.textContent = crossLinksUserEnabled ? 'ON' : 'OFF';
3507
+ btn.classList.toggle('active', crossLinksUserEnabled);
3508
+ applyLayerFilter();
3509
+ };
3510
+ }
3511
+
3512
+ LAYERS.forEach(layer => {
3513
+ const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
3514
+ const pill = document.createElement('div');
3515
+ pill.className = 'layer-pill';
3516
+ pill.dataset.layer = layer.name;
3517
+ pill.innerHTML = '<div class="lp-dot" style="background:' + layer.color + '"></div>' + layer.name + ' <span class="lp-count">' + layerNodes.length + '</span>';
3518
+ pill.onclick = () => {
3519
+ if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
3520
+ else activeLayers.add(layer.name);
3521
+ syncLayerTabUI();
3522
+ applyLayerFilter();
3523
+ };
3524
+ pill.onmouseenter = () => {
3525
+ if (pinnedNode) return;
3526
+ node.select('circle').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.1);
3527
+ node.select('text').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.05);
3528
+ };
3529
+ pill.onmouseleave = () => {
3530
+ if (pinnedNode) return;
3531
+ node.select('circle').transition().duration(150).attr('opacity', 1);
3532
+ node.select('text').transition().duration(150).attr('opacity', d => d.dependents >= 1 || d.deps >= 3 ? 1 : 0.5);
3533
+ };
3534
+ layerRowEl.appendChild(pill);
3535
+
3536
+ // Build dir group in panel for this layer
3537
+ const layerDirs = [...new Set(layerNodes.map(n => n.dir))].sort();
3538
+ if (layerDirs.length > 0) {
3539
+ const group = document.createElement('div');
3540
+ group.className = 'dir-group';
3541
+ const label = document.createElement('div');
3542
+ label.className = 'dir-group-label';
3543
+ label.innerHTML = '<div class="dg-dot" style="background:' + layer.color + '"></div>' + layer.name;
3544
+ group.appendChild(label);
3545
+ const pillsWrap = document.createElement('div');
3546
+ pillsWrap.className = 'dir-group-pills';
3547
+ layerDirs.forEach(dir => {
3548
+ const dp = document.createElement('div');
3549
+ dp.className = 'filter-pill active';
3550
+ const shortDir = dir.includes('/') ? dir.substring(dir.indexOf('/') + 1) : dir;
3551
+ dp.innerHTML = '<div class="pill-dot" style="background:' + dirColor(dir) + '"></div>' + (shortDir || '.') + ' <span class="pill-count">' + (dirCounts[dir] || 0) + '</span>';
3552
+ dp.onclick = () => {
3553
+ if (activeDirs.has(dir)) { activeDirs.delete(dir); dp.classList.remove('active'); }
3554
+ else { activeDirs.add(dir); dp.classList.add('active'); }
3555
+ applyLayerFilter();
3556
+ };
3557
+ pillsWrap.appendChild(dp);
3558
+ });
3559
+ group.appendChild(pillsWrap);
3560
+ dirPanelEl.appendChild(group);
3561
+ }
3562
+ });
3563
+
3564
+ // Override applyFilter to respect layers
3565
+ window._origApplyFilter = applyFilter;
3566
+ }
3567
+
2530
3568
  setTimeout(()=>zoomFit(), 1500);
2531
3569
 
2532
3570
  // Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
@@ -2616,32 +3654,36 @@ searchInput.addEventListener('input',e=>{
2616
3654
  });
2617
3655
 
2618
3656
  // \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
2619
- const filtersEl=document.getElementById('filters');
2620
- const activeDirs=new Set(DATA.dirs);
2621
- const dirCounts={};
2622
- DATA.nodes.forEach(n=>dirCounts[n.dir]=(dirCounts[n.dir]||0)+1);
2623
- DATA.dirs.forEach(dir=>{
2624
- const pill=document.createElement('div');
2625
- pill.className='filter-pill active';
2626
- pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
2627
- pill.onclick=()=>{
2628
- if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
2629
- else{activeDirs.add(dir);pill.classList.add('active');}
2630
- applyFilter();
2631
- };
2632
- pill.onmouseenter=()=>{
2633
- if(pinnedNode)return;
2634
- node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
2635
- node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
2636
- };
2637
- pill.onmouseleave=()=>{
2638
- if(pinnedNode)return;
2639
- node.select('circle').transition().duration(150).attr('opacity',1);
2640
- node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
2641
- };
2642
- filtersEl.appendChild(pill);
2643
- });
3657
+ if (!LAYERS) {
3658
+ // Non-layer mode: flat pills in filter-layer-row
3659
+ const filterRowEl=document.getElementById('filter-layer-row');
3660
+ DATA.dirs.forEach(dir=>{
3661
+ const pill=document.createElement('div');
3662
+ pill.className='filter-pill active';
3663
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
3664
+ pill.onclick=()=>{
3665
+ if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
3666
+ else{activeDirs.add(dir);pill.classList.add('active');}
3667
+ applyFilter();
3668
+ };
3669
+ pill.onmouseenter=()=>{
3670
+ if(pinnedNode)return;
3671
+ node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
3672
+ node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
3673
+ };
3674
+ pill.onmouseleave=()=>{
3675
+ if(pinnedNode)return;
3676
+ node.select('circle').transition().duration(150).attr('opacity',1);
3677
+ node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
3678
+ };
3679
+ filterRowEl.appendChild(pill);
3680
+ });
3681
+ }
2644
3682
  function applyFilter(){
3683
+ if (LAYERS) {
3684
+ // Delegate to layer-aware filter
3685
+ if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
3686
+ }
2645
3687
  node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
2646
3688
  link.attr('display',l=>{
2647
3689
  const s=l.source.id??l.source,t=l.target.id??l.target;
@@ -2753,6 +3795,7 @@ function buildHierarchy(){
2753
3795
  for(let layer=0;layer<=maxLayer;layer++){
2754
3796
  if(!layerGroups[layer].length)continue;
2755
3797
  hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
3798
+ .attr('data-depth-idx',layer)
2756
3799
  .attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
2757
3800
  }
2758
3801
 
@@ -2822,30 +3865,184 @@ function buildHierarchy(){
2822
3865
  // Click on empty space to deselect
2823
3866
  hSvg.on('click',()=>{closeHierDetail();});
2824
3867
 
2825
- // Hierarchy dir filters
2826
- const hFiltersEl=document.getElementById('hier-filters');
2827
- const hActiveDirs=new Set(DATA.dirs);
2828
- DATA.dirs.forEach(dir=>{
2829
- const pill=document.createElement('div');
2830
- pill.className='filter-pill active';
2831
- pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
2832
- pill.onclick=()=>{
2833
- if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
2834
- else{hActiveDirs.add(dir);pill.classList.add('active');}
2835
- nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
2836
- };
2837
- pill.onmouseenter=()=>{
2838
- nodeG.selectAll('.hier-node').attr('opacity',function(){return this.__data_id&&nodeMap[this.__data_id]?.dir===dir?1:0.1;});
2839
- };
2840
- pill.onmouseleave=()=>{
2841
- nodeG.selectAll('.hier-node').attr('opacity',1);
3868
+ // Hierarchy filters \u2014 layer pills or dir pills
3869
+ const hFilterRow=document.getElementById('hier-filter-row');
3870
+ const hFilterBar=document.getElementById('hier-filter-bar');
3871
+ if (hFilterBar) hFilterBar.style.display='';
3872
+ const hActiveLayers=new Set(); // empty = show all (same as graph view)
3873
+
3874
+ function hierRelayoutInner() {
3875
+ function isVisible(nId) {
3876
+ var nd = nodeMap[nId];
3877
+ if (!nd) return false;
3878
+ if (LAYERS && nd.layer && hActiveLayers.size > 0 && !hActiveLayers.has(nd.layer)) return false;
3879
+ return true;
3880
+ }
3881
+
3882
+ // Build visible layer groups and compact Y positions
3883
+ var visibleDepths = [];
3884
+ var visLayerGroups = {};
3885
+ for (var depth = 0; depth <= maxLayer; depth++) {
3886
+ var visItems = layerGroups[depth].filter(function(id) { return isVisible(id); });
3887
+ if (visItems.length > 0) {
3888
+ visLayerGroups[depth] = visItems;
3889
+ visibleDepths.push(depth);
3890
+ }
3891
+ }
3892
+
3893
+ // Recalculate positions for visible nodes (compacted)
3894
+ var newPositions = {};
3895
+ var newMaxRowWidth = 0;
3896
+ visibleDepths.forEach(function(depth) {
3897
+ newMaxRowWidth = Math.max(newMaxRowWidth, visLayerGroups[depth].length * (boxW + gapX) - gapX);
3898
+ });
3899
+ visibleDepths.forEach(function(depth, yIdx) {
3900
+ var items = visLayerGroups[depth];
3901
+ var rowWidth = items.length * (boxW + gapX) - gapX;
3902
+ var startX = padX + (newMaxRowWidth - rowWidth) / 2;
3903
+ items.forEach(function(id, idx) {
3904
+ newPositions[id] = { x: startX + idx * (boxW + gapX), y: padY + yIdx * (boxH + gapY) };
3905
+ });
3906
+ });
3907
+
3908
+ // Update SVG size
3909
+ var newTotalW = (newMaxRowWidth || 0) + padX * 2;
3910
+ var newTotalH = padY * 2 + Math.max(1, visibleDepths.length) * (boxH + gapY);
3911
+ hSvg.attr('width', Math.max(newTotalW, W)).attr('height', Math.max(newTotalH, H));
3912
+
3913
+ // Update nodes: hide/show + transition positions
3914
+ nodeG.selectAll('.hier-node').each(function() {
3915
+ var nId = this.__data_id;
3916
+ var el = d3.select(this);
3917
+ if (!isVisible(nId) || !newPositions[nId]) {
3918
+ el.attr('display', 'none');
3919
+ } else {
3920
+ el.attr('display', null)
3921
+ .transition().duration(300)
3922
+ .attr('transform', 'translate(' + newPositions[nId].x + ',' + newPositions[nId].y + ')');
3923
+ }
3924
+ });
3925
+
3926
+ // Update links: show only if both endpoints visible, recalculate bezier
3927
+ linkG.selectAll('path').each(function() {
3928
+ var sId = this.getAttribute('data-source');
3929
+ var tId = this.getAttribute('data-target');
3930
+ var el = d3.select(this);
3931
+ if (!isVisible(sId) || !isVisible(tId) || !newPositions[sId] || !newPositions[tId]) {
3932
+ el.attr('display', 'none');
3933
+ } else {
3934
+ var s = newPositions[sId], t = newPositions[tId];
3935
+ var x1 = s.x + boxW / 2, y1 = s.y + boxH;
3936
+ var x2 = t.x + boxW / 2, y2 = t.y;
3937
+ var midY = (y1 + y2) / 2;
3938
+ el.attr('display', null)
3939
+ .transition().duration(300)
3940
+ .attr('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2);
3941
+ }
3942
+ });
3943
+
3944
+ // Update depth labels: hide empty depths, reposition visible ones
3945
+ hG.selectAll('.hier-layer-label').each(function() {
3946
+ var depthIdx = +this.getAttribute('data-depth-idx');
3947
+ var el = d3.select(this);
3948
+ var yIdx = visibleDepths.indexOf(depthIdx);
3949
+ if (yIdx === -1) {
3950
+ el.attr('display', 'none');
3951
+ } else {
3952
+ el.attr('display', null)
3953
+ .transition().duration(300)
3954
+ .attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
3955
+ }
3956
+ });
3957
+
3958
+ // Close detail panel if pinned node became hidden
3959
+ if (hierPinned && !isVisible(hierPinned)) {
3960
+ closeHierDetail();
3961
+ }
3962
+ }
3963
+
3964
+ function hierSyncFromTabInner() {
3965
+ if (!LAYERS) return;
3966
+ hActiveLayers.clear();
3967
+ activeLayers.forEach(function(name) { hActiveLayers.add(name); });
3968
+ // Sync pill UI
3969
+ hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
3970
+ var ln = p.dataset.layer;
3971
+ if (ln === 'all') {
3972
+ p.classList.toggle('active', hActiveLayers.size === 0);
3973
+ } else {
3974
+ p.classList.toggle('active', hActiveLayers.has(ln));
3975
+ }
3976
+ });
3977
+ }
3978
+
3979
+ if (LAYERS) {
3980
+ // "All" button
3981
+ const allPill=document.createElement('div');
3982
+ allPill.className='layer-pill active';
3983
+ allPill.style.fontWeight='400';
3984
+ allPill.textContent='All';
3985
+ allPill.dataset.layer='all';
3986
+ allPill.onclick=()=>{
3987
+ hActiveLayers.clear();
3988
+ hFilterRow.querySelectorAll('.layer-pill').forEach(p=>p.classList.remove('active'));
3989
+ allPill.classList.add('active');
3990
+ hierRelayoutInner();
2842
3991
  };
2843
- hFiltersEl.appendChild(pill);
2844
- });
3992
+ hFilterRow.appendChild(allPill);
3993
+
3994
+ LAYERS.forEach(layer => {
3995
+ const pill=document.createElement('div');
3996
+ pill.className='layer-pill';
3997
+ pill.dataset.layer=layer.name;
3998
+ const count=DATA.nodes.filter(n=>n.layer===layer.name).length;
3999
+ pill.innerHTML='<div class="lp-dot" style="background:'+layer.color+'"></div>'+layer.name+' <span class="lp-count">'+count+'</span>';
4000
+ pill.onclick=(e)=>{
4001
+ if (e.shiftKey) {
4002
+ hActiveLayers.clear();
4003
+ hActiveLayers.add(layer.name);
4004
+ } else {
4005
+ if (hActiveLayers.has(layer.name)) hActiveLayers.delete(layer.name);
4006
+ else hActiveLayers.add(layer.name);
4007
+ }
4008
+ // Sync pill UI
4009
+ hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
4010
+ var ln = p.dataset.layer;
4011
+ if (ln === 'all') p.classList.toggle('active', hActiveLayers.size === 0);
4012
+ else p.classList.toggle('active', hActiveLayers.has(ln));
4013
+ });
4014
+ hierRelayoutInner();
4015
+ };
4016
+ hFilterRow.appendChild(pill);
4017
+ });
4018
+ } else {
4019
+ const hActiveDirs=new Set(DATA.dirs);
4020
+ DATA.dirs.forEach(dir=>{
4021
+ const pill=document.createElement('div');
4022
+ pill.className='filter-pill active';
4023
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
4024
+ pill.onclick=()=>{
4025
+ if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
4026
+ else{hActiveDirs.add(dir);pill.classList.add('active');}
4027
+ nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
4028
+ };
4029
+ hFilterRow.appendChild(pill);
4030
+ });
4031
+ }
4032
+
4033
+ // Assign function pointers for cross-view sync
4034
+ hierRelayout = hierRelayoutInner;
4035
+ hierSyncFromTab = hierSyncFromTabInner;
2845
4036
 
2846
4037
  hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
2847
4038
  Math.max(0,(W-totalW)/2),20
2848
4039
  ).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
4040
+
4041
+ // If a layer tab was already selected, sync hierarchy on first build
4042
+ if (activeLayerFilter) {
4043
+ hierSyncFromTabInner(activeLayerFilter);
4044
+ hierRelayoutInner();
4045
+ }
2849
4046
  }
2850
4047
 
2851
4048
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
@@ -2879,12 +4076,12 @@ if (DIFF) {
2879
4076
  .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
2880
4077
  .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
2881
4078
 
2882
- const dLink = dG.append('g').selectAll('line').data(DATA.links).join('line')
2883
- .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
2884
-
2885
- const simNodes = DATA.nodes.map(d=>({...d}));
4079
+ const simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
2886
4080
  const simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
2887
4081
 
4082
+ const dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
4083
+ .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
4084
+
2888
4085
  const dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
2889
4086
  dNode.append('circle')
2890
4087
  .attr('r', d=>nodeRadius(d)*nodeScale)
@@ -2902,22 +4099,116 @@ if (DIFF) {
2902
4099
  .force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
2903
4100
  .force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
2904
4101
  .force('center', d3.forceCenter(0,0))
2905
- .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
2906
- .on('tick', ()=>{
2907
- dLink.each(function(d){
2908
- const dx=d.target.x-d.source.x,dy=d.target.y-d.source.y,dist=Math.sqrt(dx*dx+dy*dy)||1;
2909
- const rT=nodeRadius(d.target)*nodeScale,rS=nodeRadius(d.source)*nodeScale;
4102
+ .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4));
4103
+
4104
+ // Layer-aware physics for diff view (same pattern as graph view)
4105
+ var dHullGroup = null;
4106
+ if (LAYERS && LAYERS.length > 0) {
4107
+ var dLayerCenters = {};
4108
+ var dLayerCount = LAYERS.length;
4109
+ var dBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(dLayerCount));
4110
+ LAYERS.forEach(function(l, idx) {
4111
+ var angle = (2 * Math.PI * idx) / dLayerCount - Math.PI / 2;
4112
+ dLayerCenters[l.name] = { x: Math.cos(angle) * dBaseRadius, y: Math.sin(angle) * dBaseRadius };
4113
+ });
4114
+ dSim.force('center', null);
4115
+ dSim.force('layerX', d3.forceX(function(d) { return dLayerCenters[d.layer]?.x || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
4116
+ dSim.force('layerY', d3.forceY(function(d) { return dLayerCenters[d.layer]?.y || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
4117
+ dSim.force('link').strength(function(l) {
4118
+ var sL = l.source.layer ?? l.source, tL = l.target.layer ?? l.target;
4119
+ return sL === tL ? 0.4 : 0.1;
4120
+ });
4121
+ // Cluster force for diff view
4122
+ dSim.force('cluster', (function() {
4123
+ var ns;
4124
+ function f(alpha) {
4125
+ var centroids = {}, counts = {};
4126
+ ns.forEach(function(n) {
4127
+ if (!n.layer) return;
4128
+ if (!centroids[n.layer]) { centroids[n.layer] = {x:0,y:0}; counts[n.layer] = 0; }
4129
+ centroids[n.layer].x += n.x; centroids[n.layer].y += n.y; counts[n.layer]++;
4130
+ });
4131
+ Object.keys(centroids).forEach(function(k) { centroids[k].x /= counts[k]; centroids[k].y /= counts[k]; });
4132
+ ns.forEach(function(n) {
4133
+ if (!n.layer || !centroids[n.layer]) return;
4134
+ n.vx += (centroids[n.layer].x - n.x) * alpha * 0.2;
4135
+ n.vy += (centroids[n.layer].y - n.y) * alpha * 0.2;
4136
+ });
4137
+ }
4138
+ f.initialize = function(n) { ns = n; };
4139
+ return f;
4140
+ })());
4141
+
4142
+ dHullGroup = dG.insert('g', ':first-child');
4143
+ }
4144
+
4145
+ function isDiffNode(id) {
4146
+ return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
4147
+ }
4148
+
4149
+ function updateDiffHulls() {
4150
+ if (!dHullGroup) return;
4151
+ dHullGroup.selectAll('*').remove();
4152
+ LAYERS.forEach(function(layer) {
4153
+ var layerNodes = simNodes.filter(function(n) { return n.layer === layer.name; });
4154
+ if (layerNodes.length === 0) return;
4155
+ var hasDiff = layerNodes.some(function(n) { return isDiffNode(n.id); });
4156
+
4157
+ var points = [];
4158
+ layerNodes.forEach(function(n) {
4159
+ if (n.x == null || n.y == null) return;
4160
+ var r = nodeRadius(n) * nodeScale + 30;
4161
+ for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
4162
+ points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
4163
+ }
4164
+ });
4165
+
4166
+ var fillOp = hasDiff ? 0.15 : 0.06;
4167
+ var strokeOp = hasDiff ? 0.6 : 0.2;
4168
+ var sw = hasDiff ? 2.5 : 1;
4169
+ if (points.length < 6) {
4170
+ var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4171
+ var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
4172
+ dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
4173
+ .attr('fill', layer.color).attr('fill-opacity', fillOp)
4174
+ .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
4175
+ } else {
4176
+ var hull = d3.polygonHull(points);
4177
+ if (hull) {
4178
+ dHullGroup.append('path')
4179
+ .attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
4180
+ .attr('fill', layer.color).attr('fill-opacity', fillOp)
4181
+ .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
4182
+ .attr('stroke-dasharray', hasDiff ? null : '6,3');
4183
+ }
4184
+ }
4185
+ // Layer name label
4186
+ var lx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4187
+ var ly = Math.min.apply(null, layerNodes.map(function(n) { return n.y||0; })) - 25;
4188
+ dHullGroup.append('text')
4189
+ .attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
4190
+ .attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
4191
+ .attr('font-size', 12).attr('font-weight', 600).text(layer.name);
4192
+ });
4193
+ }
4194
+
4195
+ var dTickCount = 0;
4196
+ dSim.on('tick', function() {
4197
+ dLink.each(function(d) {
4198
+ var dx=d.target.x-d.source.x, dy=d.target.y-d.source.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
4199
+ var rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
2910
4200
  d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
2911
4201
  .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
2912
4202
  });
2913
- dNode.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
4203
+ dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
4204
+ if (++dTickCount % 3 === 0) updateDiffHulls();
2914
4205
  });
2915
4206
 
2916
- dNode.on('mouseover',(e,d)=>showTooltip(e,d)).on('mousemove',e=>positionTooltip(e)).on('mouseout',()=>scheduleHideTooltip());
4207
+ dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
2917
4208
 
2918
- setTimeout(()=>{
2919
- const b=dG.node().getBBox();if(!b.width)return;
2920
- const s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
4209
+ setTimeout(function() {
4210
+ var b=dG.node().getBBox(); if(!b.width) return;
4211
+ var s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
2921
4212
  dSvg.call(dZoom.transform,d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s,H/2-(b.y+b.height/2)*s).scale(s));
2922
4213
  },1500);
2923
4214
  }
@@ -2945,7 +4236,12 @@ applyI18n();
2945
4236
  function startViewer(graph, options = {}) {
2946
4237
  const port = options.port ?? 3e3;
2947
4238
  const locale = options.locale ?? getLocale();
2948
- const html = buildGraphPage(graph, { locale, diff: options.diff });
4239
+ const html = buildGraphPage(graph, {
4240
+ locale,
4241
+ diff: options.diff,
4242
+ layerMetadata: options.layerMetadata,
4243
+ crossLayerEdges: options.crossLayerEdges
4244
+ });
2949
4245
  const graphJson = JSON.stringify(graph);
2950
4246
  const server = createServer((req, res) => {
2951
4247
  if (req.url === "/api/graph") {
@@ -2964,14 +4260,14 @@ function startViewer(graph, options = {}) {
2964
4260
  }
2965
4261
 
2966
4262
  // src/utils/version.ts
2967
- import { readFileSync as readFileSync2 } from "fs";
2968
- import { join as join5, dirname as dirname2 } from "path";
4263
+ import { readFileSync as readFileSync3 } from "fs";
4264
+ import { join as join7, dirname as dirname2 } from "path";
2969
4265
  import { fileURLToPath } from "url";
2970
4266
  function loadVersion() {
2971
4267
  let dir = dirname2(fileURLToPath(import.meta.url));
2972
4268
  for (let i = 0; i < 5; i++) {
2973
4269
  try {
2974
- const pkg = JSON.parse(readFileSync2(join5(dir, "package.json"), "utf-8"));
4270
+ const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
2975
4271
  return pkg.version;
2976
4272
  } catch {
2977
4273
  dir = dirname2(dir);
@@ -2983,6 +4279,37 @@ var VERSION = loadVersion();
2983
4279
 
2984
4280
  // src/cli/index.ts
2985
4281
  var VALID_LANGUAGES = LANGUAGE_IDS;
4282
+ async function resolveGraph(opts) {
4283
+ const targetExplicit = process.argv.some((a) => a === "-t" || a === "--target");
4284
+ if (!targetExplicit) {
4285
+ const layerConfig = await loadLayerConfig(opts.root);
4286
+ if (layerConfig) {
4287
+ const multi = await analyzeMultiLayer(opts.root, layerConfig.layers);
4288
+ const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
4289
+ const manualConnections = layerConfig.connections ?? [];
4290
+ const manualKeys = new Set(manualConnections.map(
4291
+ (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
4292
+ ));
4293
+ const merged = [
4294
+ ...manualConnections,
4295
+ ...autoConnections.filter(
4296
+ (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
4297
+ )
4298
+ ];
4299
+ return {
4300
+ graph: multi.merged,
4301
+ multiLayer: multi,
4302
+ layerMetadata: multi.layerMetadata,
4303
+ crossLayerEdges: merged
4304
+ };
4305
+ }
4306
+ }
4307
+ const graph = await analyzeProject(opts.target, {
4308
+ exclude: opts.exclude,
4309
+ language: opts.language
4310
+ });
4311
+ return { graph };
4312
+ }
2986
4313
  var program = new Command();
2987
4314
  program.name("archtracker").description(
2988
4315
  "Architecture & Dependency Tracker \u2014 Prevent missed architecture changes in AI-driven development"
@@ -2999,11 +4326,13 @@ program.command("init").description("Generate initial snapshot and save to .arch
2999
4326
  try {
3000
4327
  const language = validateLanguage(opts.language);
3001
4328
  console.log(t("cli.analyzing"));
3002
- const graph = await analyzeProject(opts.target, {
4329
+ const { graph, multiLayer } = await resolveGraph({
4330
+ target: opts.target,
4331
+ root: opts.root,
3003
4332
  exclude: opts.exclude,
3004
4333
  language
3005
4334
  });
3006
- const snapshot = await saveSnapshot(opts.root, graph);
4335
+ const snapshot = await saveSnapshot(opts.root, graph, multiLayer);
3007
4336
  console.log(t("cli.snapshotSaved"));
3008
4337
  console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
3009
4338
  console.log(t("cli.fileCount", { count: graph.totalFiles }));
@@ -3034,14 +4363,16 @@ program.command("analyze").description(
3034
4363
  try {
3035
4364
  const language = validateLanguage(opts.language);
3036
4365
  console.log(t("cli.analyzing"));
3037
- const graph = await analyzeProject(opts.target, {
4366
+ const { graph, multiLayer } = await resolveGraph({
4367
+ target: opts.target,
4368
+ root: opts.root,
3038
4369
  exclude: opts.exclude,
3039
4370
  language
3040
4371
  });
3041
4372
  const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
3042
4373
  console.log(report);
3043
4374
  if (opts.save) {
3044
- await saveSnapshot(opts.root, graph);
4375
+ await saveSnapshot(opts.root, graph, multiLayer);
3045
4376
  console.log(t("analyze.snapshotSaved"));
3046
4377
  }
3047
4378
  } catch (error) {
@@ -3059,7 +4390,11 @@ program.command("check").description(
3059
4390
  process.exit(1);
3060
4391
  }
3061
4392
  console.log(t("cli.analyzing"));
3062
- const currentGraph = await analyzeProject(opts.target, { language });
4393
+ const { graph: currentGraph } = await resolveGraph({
4394
+ target: opts.target,
4395
+ root: opts.root,
4396
+ language
4397
+ });
3063
4398
  const diff = computeDiff(existingSnapshot.graph, currentGraph);
3064
4399
  const report = formatDiffReport(diff);
3065
4400
  console.log(report);
@@ -3079,8 +4414,12 @@ program.command("context").description(
3079
4414
  let snapshot = await loadSnapshot(opts.root);
3080
4415
  if (!snapshot) {
3081
4416
  console.log(t("cli.autoGenerating"));
3082
- const graph2 = await analyzeProject(opts.target, { language });
3083
- snapshot = await saveSnapshot(opts.root, graph2);
4417
+ const result = await resolveGraph({
4418
+ target: opts.target,
4419
+ root: opts.root,
4420
+ language
4421
+ });
4422
+ snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer);
3084
4423
  }
3085
4424
  const graph = snapshot.graph;
3086
4425
  if (opts.json) {
@@ -3122,18 +4461,24 @@ program.command("serve").description(
3122
4461
  const language = validateLanguage(opts.language);
3123
4462
  console.log(t("web.starting"));
3124
4463
  console.log(t("cli.analyzing"));
3125
- let graph;
3126
4464
  let diff = null;
4465
+ const result = await resolveGraph({
4466
+ target: opts.target,
4467
+ root: opts.root,
4468
+ exclude: opts.exclude,
4469
+ language
4470
+ });
3127
4471
  const snapshot = await loadSnapshot(opts.root);
3128
4472
  if (snapshot) {
3129
- const currentGraph = await analyzeProject(opts.target, { exclude: opts.exclude, language });
3130
- diff = computeDiff(snapshot.graph, currentGraph);
3131
- graph = currentGraph;
3132
- } else {
3133
- graph = await analyzeProject(opts.target, { exclude: opts.exclude, language });
4473
+ diff = computeDiff(snapshot.graph, result.graph);
3134
4474
  }
3135
4475
  const port = parseInt(opts.port, 10);
3136
- const viewer = startViewer(graph, { port, diff });
4476
+ const viewer = startViewer(result.graph, {
4477
+ port,
4478
+ diff,
4479
+ layerMetadata: result.layerMetadata,
4480
+ crossLayerEdges: result.crossLayerEdges
4481
+ });
3137
4482
  console.log(t("web.listening", { port }));
3138
4483
  console.log(t("web.stop"));
3139
4484
  if (opts.watch) {
@@ -3144,9 +4489,18 @@ program.command("serve").description(
3144
4489
  debounce = setTimeout(async () => {
3145
4490
  try {
3146
4491
  console.log(t("web.reloading"));
3147
- const newGraph = await analyzeProject(opts.target, { exclude: opts.exclude, language });
4492
+ const newResult = await resolveGraph({
4493
+ target: opts.target,
4494
+ root: opts.root,
4495
+ exclude: opts.exclude,
4496
+ language
4497
+ });
3148
4498
  viewer.close();
3149
- startViewer(newGraph, { port });
4499
+ startViewer(newResult.graph, {
4500
+ port,
4501
+ layerMetadata: newResult.layerMetadata,
4502
+ crossLayerEdges: newResult.crossLayerEdges
4503
+ });
3150
4504
  console.log(t("web.reloaded"));
3151
4505
  } catch {
3152
4506
  }
@@ -3178,15 +4532,53 @@ jobs:
3178
4532
  - run: npx archtracker check --target ${opts.target} --ci
3179
4533
  `;
3180
4534
  try {
3181
- const dir = join6(".github", "workflows");
3182
- await mkdir2(dir, { recursive: true });
3183
- const path = join6(dir, "arch-check.yml");
3184
- await writeFile2(path, workflow, "utf-8");
4535
+ const dir = join8(".github", "workflows");
4536
+ await mkdir3(dir, { recursive: true });
4537
+ const path = join8(dir, "arch-check.yml");
4538
+ await writeFile3(path, workflow, "utf-8");
3185
4539
  console.log(t("ci.generated", { path }));
3186
4540
  } catch (error) {
3187
4541
  handleError(error);
3188
4542
  }
3189
4543
  });
4544
+ var layersCmd = program.command("layers").description("Manage multi-layer architecture configuration");
4545
+ layersCmd.command("init").description("Create a template .archtracker/layers.json").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
4546
+ try {
4547
+ const existing = await loadLayerConfig(opts.root);
4548
+ if (existing) {
4549
+ console.log(t("layers.alreadyExists"));
4550
+ return;
4551
+ }
4552
+ const config = {
4553
+ version: "1.0",
4554
+ layers: [
4555
+ { name: "Frontend", targetDir: "frontend", description: "UI layer" },
4556
+ { name: "Backend", targetDir: "backend", description: "API layer" }
4557
+ ]
4558
+ };
4559
+ await saveLayerConfig(opts.root, config);
4560
+ console.log(t("layers.created"));
4561
+ } catch (error) {
4562
+ handleError(error);
4563
+ }
4564
+ });
4565
+ layersCmd.command("list").description("List configured layers").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
4566
+ try {
4567
+ const config = await loadLayerConfig(opts.root);
4568
+ if (!config) {
4569
+ console.log(t("layers.notFound"));
4570
+ return;
4571
+ }
4572
+ console.log(t("layers.header", { count: config.layers.length }));
4573
+ for (const layer of config.layers) {
4574
+ const lang = layer.language ? ` [${layer.language}]` : "";
4575
+ const desc = layer.description ? ` \u2014 ${layer.description}` : "";
4576
+ console.log(` ${layer.name}: ${layer.targetDir}${lang}${desc}`);
4577
+ }
4578
+ } catch (error) {
4579
+ handleError(error);
4580
+ }
4581
+ });
3190
4582
  function validateLanguage(lang) {
3191
4583
  if (!lang) return void 0;
3192
4584
  if (VALID_LANGUAGES.includes(lang)) return lang;