archtracker-mcp 0.4.2 → 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/README.md +189 -14
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/cli/index.js +1549 -125
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +94 -4
- package/dist/index.js +624 -32
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.js +716 -74
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/arch-analyze/SKILL.md +6 -2
- package/skills/arch-check/SKILL.md +11 -8
- package/skills/arch-context/SKILL.md +8 -6
- package/skills/arch-search/SKILL.md +7 -7
- package/skills/arch-serve/SKILL.md +27 -0
- package/skills/arch-snapshot/SKILL.md +9 -6
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
|
|
7
|
-
import { join as
|
|
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";
|
|
@@ -605,7 +605,7 @@ var RegexEngine = class {
|
|
|
605
605
|
continue;
|
|
606
606
|
}
|
|
607
607
|
const stripped = stripComments(content, this.config.commentStyle);
|
|
608
|
-
const imports = this.extractImports(stripped);
|
|
608
|
+
const imports = this.extractImports(stripped, filePath, absRootDir, projectFileSet);
|
|
609
609
|
for (const importPath of imports) {
|
|
610
610
|
const resolved = this.config.resolveImport(
|
|
611
611
|
importPath,
|
|
@@ -639,9 +639,9 @@ var RegexEngine = class {
|
|
|
639
639
|
totalEdges: edges.length
|
|
640
640
|
};
|
|
641
641
|
}
|
|
642
|
-
extractImports(content) {
|
|
642
|
+
extractImports(content, filePath, rootDir, projectFiles) {
|
|
643
643
|
if (this.config.extractImports) {
|
|
644
|
-
return this.config.extractImports(content);
|
|
644
|
+
return this.config.extractImports(content, filePath, rootDir, projectFiles);
|
|
645
645
|
}
|
|
646
646
|
const imports = [];
|
|
647
647
|
for (const pattern of this.config.importPatterns) {
|
|
@@ -842,19 +842,20 @@ var python = {
|
|
|
842
842
|
extensions: [".py"],
|
|
843
843
|
commentStyle: "python",
|
|
844
844
|
importPatterns: [
|
|
845
|
-
// from package.module import something
|
|
846
|
-
{ regex:
|
|
845
|
+
// from package.module import something (including indented, e.g. inside try/except)
|
|
846
|
+
{ regex: /^\s*from\s+(\.[\w.]*|\w[\w.]*)\s+import\b/gm }
|
|
847
847
|
// import package.module (handled by extractImports for multi-module case)
|
|
848
848
|
],
|
|
849
849
|
// Bug #1 fix: custom extractImports to handle `import a, b, c`
|
|
850
|
+
// Bug #12 fix: allow leading whitespace to catch try/except indented imports
|
|
850
851
|
extractImports(content) {
|
|
851
852
|
const imports = [];
|
|
852
|
-
const fromRegex =
|
|
853
|
+
const fromRegex = /^\s*from\s+(\.[\w.]*|\w[\w.]*)\s+import\b/gm;
|
|
853
854
|
let match;
|
|
854
855
|
while ((match = fromRegex.exec(content)) !== null) {
|
|
855
856
|
imports.push(match[1]);
|
|
856
857
|
}
|
|
857
|
-
const importRegex =
|
|
858
|
+
const importRegex = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm;
|
|
858
859
|
while ((match = importRegex.exec(content)) !== null) {
|
|
859
860
|
const modules = match[1].split(",");
|
|
860
861
|
for (const mod of modules) {
|
|
@@ -1087,6 +1088,13 @@ var java = {
|
|
|
1087
1088
|
if (projectFiles.has(full)) return full;
|
|
1088
1089
|
}
|
|
1089
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
|
+
}
|
|
1090
1098
|
return null;
|
|
1091
1099
|
},
|
|
1092
1100
|
defaultExclude: ["build", "target", "\\.gradle", "\\.idea"]
|
|
@@ -1141,12 +1149,18 @@ var php = {
|
|
|
1141
1149
|
importPatterns: [
|
|
1142
1150
|
// require/include/require_once/include_once 'path'
|
|
1143
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 },
|
|
1144
1154
|
// Bug #9 fix: use Namespace\Class — skip `function` and `const` keywords
|
|
1145
1155
|
{ regex: /^use\s+(?:function\s+|const\s+)?([\w\\]+)/gm }
|
|
1146
1156
|
],
|
|
1147
1157
|
resolveImport(importPath, sourceFile, rootDir, projectFiles) {
|
|
1148
|
-
|
|
1149
|
-
|
|
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";
|
|
1150
1164
|
const fromSource = resolve2(dirname(sourceFile), withExt);
|
|
1151
1165
|
if (projectFiles.has(fromSource)) return fromSource;
|
|
1152
1166
|
const fromRoot2 = join3(rootDir, withExt);
|
|
@@ -1162,15 +1176,46 @@ var php = {
|
|
|
1162
1176
|
},
|
|
1163
1177
|
defaultExclude: ["vendor"]
|
|
1164
1178
|
};
|
|
1179
|
+
var SWIFT_SKIP_FILES = /* @__PURE__ */ new Set(["Package", "main", "AppDelegate", "SceneDelegate"]);
|
|
1165
1180
|
var swift = {
|
|
1166
1181
|
id: "swift",
|
|
1167
1182
|
extensions: [".swift"],
|
|
1168
1183
|
commentStyle: "c-style",
|
|
1169
|
-
importPatterns: [
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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;
|
|
1174
1219
|
const spmDir = join3(rootDir, "Sources", importPath);
|
|
1175
1220
|
for (const f of projectFiles) {
|
|
1176
1221
|
if (f.startsWith(spmDir + "/") && f.endsWith(".swift")) return f;
|
|
@@ -1195,7 +1240,8 @@ var kotlin = {
|
|
|
1195
1240
|
if (cleanPath.endsWith(".")) {
|
|
1196
1241
|
cleanPath = cleanPath.slice(0, -1);
|
|
1197
1242
|
}
|
|
1198
|
-
const
|
|
1243
|
+
const segments = cleanPath.split(".");
|
|
1244
|
+
const filePath = segments.join("/");
|
|
1199
1245
|
for (const ext of [".kt", ".kts"]) {
|
|
1200
1246
|
for (const srcRoot of [
|
|
1201
1247
|
"",
|
|
@@ -1209,21 +1255,68 @@ var kotlin = {
|
|
|
1209
1255
|
if (projectFiles.has(full)) return full;
|
|
1210
1256
|
}
|
|
1211
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
|
+
}
|
|
1212
1274
|
return null;
|
|
1213
1275
|
},
|
|
1214
1276
|
defaultExclude: ["build", "\\.gradle", "\\.idea"]
|
|
1215
1277
|
};
|
|
1278
|
+
var CS_SKIP_CLASSNAMES = /* @__PURE__ */ new Set(["AssemblyInfo", "GlobalUsings"]);
|
|
1216
1279
|
var cSharp = {
|
|
1217
1280
|
id: "c-sharp",
|
|
1218
1281
|
extensions: [".cs"],
|
|
1219
1282
|
commentStyle: "c-style",
|
|
1220
|
-
importPatterns: [
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1283
|
+
importPatterns: [],
|
|
1284
|
+
// handled by extractImports
|
|
1285
|
+
extractImports(content, filePath, _rootDir, projectFiles) {
|
|
1286
|
+
const imports = [];
|
|
1287
|
+
const usingRegex = /^\s*(?:global\s+)?using\s+(?:static\s+)?([\w.]+)\s*;/gm;
|
|
1288
|
+
let match;
|
|
1289
|
+
while ((match = usingRegex.exec(content)) !== null) {
|
|
1290
|
+
imports.push(match[1]);
|
|
1291
|
+
}
|
|
1292
|
+
const classMap = /* @__PURE__ */ new Map();
|
|
1293
|
+
for (const f of projectFiles) {
|
|
1294
|
+
if (f === filePath) continue;
|
|
1295
|
+
if (!f.endsWith(".cs")) continue;
|
|
1296
|
+
const basename = f.split("/").pop();
|
|
1297
|
+
const className = basename.replace(/\.xaml\.cs$/i, "").replace(/\.cs$/i, "");
|
|
1298
|
+
if (!className || CS_SKIP_CLASSNAMES.has(className)) continue;
|
|
1299
|
+
classMap.set(className, f);
|
|
1300
|
+
}
|
|
1301
|
+
if (classMap.size > 0) {
|
|
1302
|
+
const escaped = [...classMap.keys()].map(
|
|
1303
|
+
(n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
1304
|
+
);
|
|
1305
|
+
const combined = new RegExp(`\\b(${escaped.join("|")})\\b`, "g");
|
|
1306
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1307
|
+
while ((match = combined.exec(content)) !== null) {
|
|
1308
|
+
const className = match[1];
|
|
1309
|
+
const targetPath = classMap.get(className);
|
|
1310
|
+
if (targetPath && !matched.has(targetPath)) {
|
|
1311
|
+
matched.add(targetPath);
|
|
1312
|
+
imports.push(targetPath);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return imports;
|
|
1317
|
+
},
|
|
1226
1318
|
resolveImport(importPath, _sourceFile, rootDir, projectFiles) {
|
|
1319
|
+
if (projectFiles.has(importPath)) return importPath;
|
|
1227
1320
|
const segments = importPath.split(".");
|
|
1228
1321
|
for (let i = segments.length; i > 0; i--) {
|
|
1229
1322
|
const filePath = segments.slice(0, i).join("/") + ".cs";
|
|
@@ -1263,8 +1356,10 @@ var dart = {
|
|
|
1263
1356
|
const prefix = `package:${ownPackage}/`;
|
|
1264
1357
|
if (!importPath.startsWith(prefix)) return null;
|
|
1265
1358
|
const relPath = importPath.slice(prefix.length);
|
|
1266
|
-
const
|
|
1267
|
-
if (projectFiles.has(
|
|
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;
|
|
1268
1363
|
return null;
|
|
1269
1364
|
}
|
|
1270
1365
|
const resolved = resolve2(dirname(sourceFile), importPath);
|
|
@@ -1327,6 +1422,15 @@ var scala = {
|
|
|
1327
1422
|
}
|
|
1328
1423
|
}
|
|
1329
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
|
+
}
|
|
1330
1434
|
return null;
|
|
1331
1435
|
},
|
|
1332
1436
|
defaultExclude: ["target", "\\.bsp", "\\.metals", "\\.bloop"]
|
|
@@ -1514,6 +1618,11 @@ var en = {
|
|
|
1514
1618
|
"analyze.snapshotSaved": "\nSnapshot saved alongside analysis.",
|
|
1515
1619
|
// CI
|
|
1516
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}):",
|
|
1517
1626
|
// Web viewer
|
|
1518
1627
|
"web.starting": "Starting architecture viewer...",
|
|
1519
1628
|
"web.listening": "Architecture graph available at: http://localhost:{port}",
|
|
@@ -1602,6 +1711,11 @@ var ja = {
|
|
|
1602
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",
|
|
1603
1712
|
// CI
|
|
1604
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):",
|
|
1605
1719
|
// Web viewer
|
|
1606
1720
|
"web.starting": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30D3\u30E5\u30FC\u30A2\u30FC\u3092\u8D77\u52D5\u4E2D...",
|
|
1607
1721
|
"web.listening": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30B0\u30E9\u30D5: http://localhost:{port}",
|
|
@@ -1678,13 +1792,396 @@ function formatAnalysisReport(graph, options = {}) {
|
|
|
1678
1792
|
return lines.join("\n");
|
|
1679
1793
|
}
|
|
1680
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
|
+
|
|
1681
2178
|
// src/storage/snapshot.ts
|
|
1682
2179
|
import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
|
|
1683
|
-
import { join as
|
|
2180
|
+
import { join as join5 } from "path";
|
|
1684
2181
|
import { z } from "zod";
|
|
1685
2182
|
|
|
1686
2183
|
// src/types/schema.ts
|
|
1687
|
-
var SCHEMA_VERSION = "1.
|
|
2184
|
+
var SCHEMA_VERSION = "1.1";
|
|
1688
2185
|
|
|
1689
2186
|
// src/storage/snapshot.ts
|
|
1690
2187
|
var ARCHTRACKER_DIR = ".archtracker";
|
|
@@ -1708,26 +2205,27 @@ var DependencyGraphSchema = z.object({
|
|
|
1708
2205
|
totalEdges: z.number()
|
|
1709
2206
|
});
|
|
1710
2207
|
var SnapshotSchema = z.object({
|
|
1711
|
-
version: z.
|
|
2208
|
+
version: z.enum([SCHEMA_VERSION, "1.0"]),
|
|
1712
2209
|
timestamp: z.string(),
|
|
1713
2210
|
rootDir: z.string(),
|
|
1714
2211
|
graph: DependencyGraphSchema
|
|
1715
2212
|
});
|
|
1716
|
-
async function saveSnapshot(projectRoot, graph) {
|
|
1717
|
-
const dirPath =
|
|
1718
|
-
const filePath =
|
|
2213
|
+
async function saveSnapshot(projectRoot, graph, multiLayer) {
|
|
2214
|
+
const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
|
|
2215
|
+
const filePath = join5(dirPath, SNAPSHOT_FILE);
|
|
1719
2216
|
const snapshot = {
|
|
1720
2217
|
version: SCHEMA_VERSION,
|
|
1721
2218
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1722
2219
|
rootDir: graph.rootDir,
|
|
1723
|
-
graph
|
|
2220
|
+
graph,
|
|
2221
|
+
...multiLayer ? { multiLayer } : {}
|
|
1724
2222
|
};
|
|
1725
2223
|
await mkdir(dirPath, { recursive: true });
|
|
1726
2224
|
await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
1727
2225
|
return snapshot;
|
|
1728
2226
|
}
|
|
1729
2227
|
async function loadSnapshot(projectRoot) {
|
|
1730
|
-
const filePath =
|
|
2228
|
+
const filePath = join5(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
|
|
1731
2229
|
let raw;
|
|
1732
2230
|
try {
|
|
1733
2231
|
raw = await readFile2(filePath, "utf-8");
|
|
@@ -1864,6 +2362,77 @@ function arraysEqual(a, b) {
|
|
|
1864
2362
|
return true;
|
|
1865
2363
|
}
|
|
1866
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
|
+
|
|
1867
2436
|
// src/web/server.ts
|
|
1868
2437
|
import { createServer } from "http";
|
|
1869
2438
|
|
|
@@ -1871,6 +2440,8 @@ import { createServer } from "http";
|
|
|
1871
2440
|
function buildGraphPage(graph, options = {}) {
|
|
1872
2441
|
const locale = options.locale ?? "en";
|
|
1873
2442
|
const diff = options.diff ?? null;
|
|
2443
|
+
const layers = options.layerMetadata ?? null;
|
|
2444
|
+
const crossEdges = options.crossLayerEdges ?? null;
|
|
1874
2445
|
const files = Object.values(graph.files);
|
|
1875
2446
|
const nodes = files.map((f) => ({
|
|
1876
2447
|
id: f.path,
|
|
@@ -1879,7 +2450,8 @@ function buildGraphPage(graph, options = {}) {
|
|
|
1879
2450
|
dependencies: f.dependencies,
|
|
1880
2451
|
dependentsList: f.dependents,
|
|
1881
2452
|
isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
|
|
1882
|
-
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
|
|
1883
2455
|
}));
|
|
1884
2456
|
const links = graph.edges.map((e) => ({
|
|
1885
2457
|
source: e.source,
|
|
@@ -1893,6 +2465,8 @@ function buildGraphPage(graph, options = {}) {
|
|
|
1893
2465
|
const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
|
|
1894
2466
|
const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
|
|
1895
2467
|
const diffData = diff ? JSON.stringify(diff) : "null";
|
|
2468
|
+
const layersData = layers ? JSON.stringify(layers) : "null";
|
|
2469
|
+
const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
|
|
1896
2470
|
const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
|
|
1897
2471
|
return (
|
|
1898
2472
|
/* html */
|
|
@@ -1957,13 +2531,25 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
1957
2531
|
#tooltip .tt-out { color: var(--accent); }
|
|
1958
2532
|
#tooltip .tt-in { color: var(--green); }
|
|
1959
2533
|
|
|
1960
|
-
/* \u2500\u2500\u2500
|
|
1961
|
-
#
|
|
1962
|
-
|
|
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; }
|
|
1963
2549
|
.filter-pill:hover { border-color: var(--text-dim); }
|
|
1964
2550
|
.filter-pill.active { border-color: var(--accent); }
|
|
1965
|
-
.filter-pill .pill-dot { width:
|
|
1966
|
-
.filter-pill .pill-count { color: var(--text-muted); font-size:
|
|
2551
|
+
.filter-pill .pill-dot { width: 5px; height: 5px; border-radius: 50%; }
|
|
2552
|
+
.filter-pill .pill-count { color: var(--text-muted); font-size: 9px; }
|
|
1967
2553
|
|
|
1968
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 */
|
|
1969
2555
|
#zoom-ctrl { position: absolute; bottom: 52px; right: 12px; z-index: 10; display: flex; flex-direction: column; gap: 2px; }
|
|
@@ -2022,6 +2608,24 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2022
2608
|
|
|
2023
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 */
|
|
2024
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; }
|
|
2025
2629
|
</style>
|
|
2026
2630
|
</head>
|
|
2027
2631
|
<body>
|
|
@@ -2032,6 +2636,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2032
2636
|
<div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
|
|
2033
2637
|
<div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
|
|
2034
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>
|
|
2035
2640
|
<div class="tab-right">
|
|
2036
2641
|
<div class="tab-stats">
|
|
2037
2642
|
<span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
|
|
@@ -2072,6 +2677,11 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2072
2677
|
<input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
|
|
2073
2678
|
<div class="setting-value"><span id="gravity-val">150</span></div>
|
|
2074
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>
|
|
2075
2685
|
<div class="setting-group">
|
|
2076
2686
|
<label data-i18n="settings.language">Language</label>
|
|
2077
2687
|
<div class="theme-toggle">
|
|
@@ -2079,6 +2689,12 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2079
2689
|
<div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
|
|
2080
2690
|
</div>
|
|
2081
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>
|
|
2082
2698
|
<div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
|
2083
2699
|
<label data-i18n="settings.export">Export</label>
|
|
2084
2700
|
<div class="theme-toggle">
|
|
@@ -2098,6 +2714,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2098
2714
|
<kbd>/</kbd>
|
|
2099
2715
|
</div>
|
|
2100
2716
|
<div class="hud-panel" id="legend-panel">
|
|
2717
|
+
<div id="layer-legend"></div>
|
|
2101
2718
|
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
2102
2719
|
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
2103
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>
|
|
@@ -2111,7 +2728,10 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2111
2728
|
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
|
|
2112
2729
|
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
|
|
2113
2730
|
</div>
|
|
2114
|
-
<div id="
|
|
2731
|
+
<div id="filter-bar">
|
|
2732
|
+
<div id="filter-dir-panel"></div>
|
|
2733
|
+
<div id="filter-layer-row"></div>
|
|
2734
|
+
</div>
|
|
2115
2735
|
<div id="zoom-ctrl">
|
|
2116
2736
|
<button onclick="zoomIn()" title="Zoom in">+</button>
|
|
2117
2737
|
<button onclick="zoomOut()" title="Zoom out">\u2212</button>
|
|
@@ -2139,7 +2759,9 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2139
2759
|
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
|
|
2140
2760
|
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
|
|
2141
2761
|
</div>
|
|
2142
|
-
<div id="hier-
|
|
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>
|
|
2143
2765
|
<div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
|
|
2144
2766
|
</div>
|
|
2145
2767
|
|
|
@@ -2231,7 +2853,7 @@ function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
|
|
|
2231
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
|
|
2232
2854
|
const STORAGE_KEY = 'archtracker-settings';
|
|
2233
2855
|
function saveSettings() {
|
|
2234
|
-
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 };
|
|
2235
2857
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
|
|
2236
2858
|
}
|
|
2237
2859
|
function loadSettings() {
|
|
@@ -2280,6 +2902,15 @@ window.setGravity = (v) => {
|
|
|
2280
2902
|
}
|
|
2281
2903
|
saveSettings();
|
|
2282
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
|
+
};
|
|
2283
2914
|
|
|
2284
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
|
|
2285
2916
|
// EXPORT
|
|
@@ -2315,6 +2946,8 @@ window.exportPNG = () => {
|
|
|
2315
2946
|
// DATA
|
|
2316
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
|
|
2317
2948
|
const DATA = ${graphData};
|
|
2949
|
+
const LAYERS = ${layersData};
|
|
2950
|
+
const CROSS_EDGES = ${crossEdgesData};
|
|
2318
2951
|
const W = window.innerWidth, H = window.innerHeight - 44;
|
|
2319
2952
|
const circularSet = new Set(DATA.circularFiles);
|
|
2320
2953
|
|
|
@@ -2335,6 +2968,7 @@ if (_savedSettings) {
|
|
|
2335
2968
|
if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
|
|
2336
2969
|
if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
|
|
2337
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; }
|
|
2338
2972
|
}
|
|
2339
2973
|
|
|
2340
2974
|
document.getElementById('s-files').textContent = DATA.nodes.length;
|
|
@@ -2344,9 +2978,21 @@ document.getElementById('s-circular').textContent = DATA.circularFiles.length;
|
|
|
2344
2978
|
const dirColor = d3.scaleOrdinal()
|
|
2345
2979
|
.domain(DATA.dirs)
|
|
2346
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
|
+
|
|
2347
2991
|
function nodeColor(d) {
|
|
2348
2992
|
if (circularSet.has(d.id)) return '#f97583';
|
|
2349
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];
|
|
2350
2996
|
return dirColor(d.dir);
|
|
2351
2997
|
}
|
|
2352
2998
|
function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
|
|
@@ -2356,13 +3002,18 @@ function fileName(id) { return id.split('/').pop(); }
|
|
|
2356
3002
|
// TAB SWITCHING
|
|
2357
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
|
|
2358
3004
|
let hierBuilt = false;
|
|
3005
|
+
let hierRelayout = null;
|
|
3006
|
+
let hierSyncFromTab = null;
|
|
2359
3007
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
2360
3008
|
tab.addEventListener('click', () => {
|
|
2361
3009
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
2362
3010
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
2363
3011
|
tab.classList.add('active');
|
|
2364
3012
|
document.getElementById(tab.dataset.view).classList.add('active');
|
|
2365
|
-
if (tab.dataset.view === 'hier-view'
|
|
3013
|
+
if (tab.dataset.view === 'hier-view') {
|
|
3014
|
+
if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
|
|
3015
|
+
if (hierSyncFromTab) { hierSyncFromTab(null); hierRelayout(); }
|
|
3016
|
+
}
|
|
2366
3017
|
});
|
|
2367
3018
|
});
|
|
2368
3019
|
|
|
@@ -2453,6 +3104,35 @@ const link = g.append('g').selectAll('line').data(DATA.links).join('line')
|
|
|
2453
3104
|
.attr('marker-end','url(#arrow-0)')
|
|
2454
3105
|
.attr('opacity', baseLinkOpacity);
|
|
2455
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
|
+
|
|
2456
3136
|
// Nodes
|
|
2457
3137
|
const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
|
|
2458
3138
|
.attr('cursor','pointer')
|
|
@@ -2495,6 +3175,396 @@ const simulation = d3.forceSimulation(DATA.nodes)
|
|
|
2495
3175
|
node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
2496
3176
|
});
|
|
2497
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
|
+
|
|
2498
3568
|
setTimeout(()=>zoomFit(), 1500);
|
|
2499
3569
|
|
|
2500
3570
|
// Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
|
|
@@ -2584,32 +3654,36 @@ searchInput.addEventListener('input',e=>{
|
|
|
2584
3654
|
});
|
|
2585
3655
|
|
|
2586
3656
|
// \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
const
|
|
2590
|
-
DATA.
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
}
|
|
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
|
+
}
|
|
2612
3682
|
function applyFilter(){
|
|
3683
|
+
if (LAYERS) {
|
|
3684
|
+
// Delegate to layer-aware filter
|
|
3685
|
+
if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
|
|
3686
|
+
}
|
|
2613
3687
|
node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
|
|
2614
3688
|
link.attr('display',l=>{
|
|
2615
3689
|
const s=l.source.id??l.source,t=l.target.id??l.target;
|
|
@@ -2721,6 +3795,7 @@ function buildHierarchy(){
|
|
|
2721
3795
|
for(let layer=0;layer<=maxLayer;layer++){
|
|
2722
3796
|
if(!layerGroups[layer].length)continue;
|
|
2723
3797
|
hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
|
|
3798
|
+
.attr('data-depth-idx',layer)
|
|
2724
3799
|
.attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
|
|
2725
3800
|
}
|
|
2726
3801
|
|
|
@@ -2790,30 +3865,184 @@ function buildHierarchy(){
|
|
|
2790
3865
|
// Click on empty space to deselect
|
|
2791
3866
|
hSvg.on('click',()=>{closeHierDetail();});
|
|
2792
3867
|
|
|
2793
|
-
// Hierarchy dir
|
|
2794
|
-
const
|
|
2795
|
-
const
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
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();
|
|
2810
3991
|
};
|
|
2811
|
-
|
|
2812
|
-
|
|
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;
|
|
2813
4036
|
|
|
2814
4037
|
hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
|
|
2815
4038
|
Math.max(0,(W-totalW)/2),20
|
|
2816
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
|
+
}
|
|
2817
4046
|
}
|
|
2818
4047
|
|
|
2819
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
|
|
@@ -2847,12 +4076,12 @@ if (DIFF) {
|
|
|
2847
4076
|
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
2848
4077
|
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
|
|
2849
4078
|
|
|
2850
|
-
const
|
|
2851
|
-
.attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
|
|
2852
|
-
|
|
2853
|
-
const simNodes = DATA.nodes.map(d=>({...d}));
|
|
4079
|
+
const simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
|
|
2854
4080
|
const simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
|
|
2855
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
|
+
|
|
2856
4085
|
const dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
|
|
2857
4086
|
dNode.append('circle')
|
|
2858
4087
|
.attr('r', d=>nodeRadius(d)*nodeScale)
|
|
@@ -2870,22 +4099,116 @@ if (DIFF) {
|
|
|
2870
4099
|
.force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
|
|
2871
4100
|
.force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
|
|
2872
4101
|
.force('center', d3.forceCenter(0,0))
|
|
2873
|
-
.force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
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;
|
|
2878
4200
|
d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
2879
4201
|
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
2880
4202
|
});
|
|
2881
|
-
dNode.attr('transform', d
|
|
4203
|
+
dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
|
|
4204
|
+
if (++dTickCount % 3 === 0) updateDiffHulls();
|
|
2882
4205
|
});
|
|
2883
4206
|
|
|
2884
|
-
dNode.on('mouseover',(e,d)
|
|
4207
|
+
dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
|
|
2885
4208
|
|
|
2886
|
-
setTimeout(()
|
|
2887
|
-
|
|
2888
|
-
|
|
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;
|
|
2889
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));
|
|
2890
4213
|
},1500);
|
|
2891
4214
|
}
|
|
@@ -2913,7 +4236,12 @@ applyI18n();
|
|
|
2913
4236
|
function startViewer(graph, options = {}) {
|
|
2914
4237
|
const port = options.port ?? 3e3;
|
|
2915
4238
|
const locale = options.locale ?? getLocale();
|
|
2916
|
-
const html = buildGraphPage(graph, {
|
|
4239
|
+
const html = buildGraphPage(graph, {
|
|
4240
|
+
locale,
|
|
4241
|
+
diff: options.diff,
|
|
4242
|
+
layerMetadata: options.layerMetadata,
|
|
4243
|
+
crossLayerEdges: options.crossLayerEdges
|
|
4244
|
+
});
|
|
2917
4245
|
const graphJson = JSON.stringify(graph);
|
|
2918
4246
|
const server = createServer((req, res) => {
|
|
2919
4247
|
if (req.url === "/api/graph") {
|
|
@@ -2932,14 +4260,14 @@ function startViewer(graph, options = {}) {
|
|
|
2932
4260
|
}
|
|
2933
4261
|
|
|
2934
4262
|
// src/utils/version.ts
|
|
2935
|
-
import { readFileSync as
|
|
2936
|
-
import { join as
|
|
4263
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
4264
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
2937
4265
|
import { fileURLToPath } from "url";
|
|
2938
4266
|
function loadVersion() {
|
|
2939
4267
|
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
2940
4268
|
for (let i = 0; i < 5; i++) {
|
|
2941
4269
|
try {
|
|
2942
|
-
const pkg = JSON.parse(
|
|
4270
|
+
const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
|
|
2943
4271
|
return pkg.version;
|
|
2944
4272
|
} catch {
|
|
2945
4273
|
dir = dirname2(dir);
|
|
@@ -2951,6 +4279,37 @@ var VERSION = loadVersion();
|
|
|
2951
4279
|
|
|
2952
4280
|
// src/cli/index.ts
|
|
2953
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
|
+
}
|
|
2954
4313
|
var program = new Command();
|
|
2955
4314
|
program.name("archtracker").description(
|
|
2956
4315
|
"Architecture & Dependency Tracker \u2014 Prevent missed architecture changes in AI-driven development"
|
|
@@ -2967,11 +4326,13 @@ program.command("init").description("Generate initial snapshot and save to .arch
|
|
|
2967
4326
|
try {
|
|
2968
4327
|
const language = validateLanguage(opts.language);
|
|
2969
4328
|
console.log(t("cli.analyzing"));
|
|
2970
|
-
const graph = await
|
|
4329
|
+
const { graph, multiLayer } = await resolveGraph({
|
|
4330
|
+
target: opts.target,
|
|
4331
|
+
root: opts.root,
|
|
2971
4332
|
exclude: opts.exclude,
|
|
2972
4333
|
language
|
|
2973
4334
|
});
|
|
2974
|
-
const snapshot = await saveSnapshot(opts.root, graph);
|
|
4335
|
+
const snapshot = await saveSnapshot(opts.root, graph, multiLayer);
|
|
2975
4336
|
console.log(t("cli.snapshotSaved"));
|
|
2976
4337
|
console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
|
|
2977
4338
|
console.log(t("cli.fileCount", { count: graph.totalFiles }));
|
|
@@ -3002,14 +4363,16 @@ program.command("analyze").description(
|
|
|
3002
4363
|
try {
|
|
3003
4364
|
const language = validateLanguage(opts.language);
|
|
3004
4365
|
console.log(t("cli.analyzing"));
|
|
3005
|
-
const graph = await
|
|
4366
|
+
const { graph, multiLayer } = await resolveGraph({
|
|
4367
|
+
target: opts.target,
|
|
4368
|
+
root: opts.root,
|
|
3006
4369
|
exclude: opts.exclude,
|
|
3007
4370
|
language
|
|
3008
4371
|
});
|
|
3009
4372
|
const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
|
|
3010
4373
|
console.log(report);
|
|
3011
4374
|
if (opts.save) {
|
|
3012
|
-
await saveSnapshot(opts.root, graph);
|
|
4375
|
+
await saveSnapshot(opts.root, graph, multiLayer);
|
|
3013
4376
|
console.log(t("analyze.snapshotSaved"));
|
|
3014
4377
|
}
|
|
3015
4378
|
} catch (error) {
|
|
@@ -3027,7 +4390,11 @@ program.command("check").description(
|
|
|
3027
4390
|
process.exit(1);
|
|
3028
4391
|
}
|
|
3029
4392
|
console.log(t("cli.analyzing"));
|
|
3030
|
-
const currentGraph = await
|
|
4393
|
+
const { graph: currentGraph } = await resolveGraph({
|
|
4394
|
+
target: opts.target,
|
|
4395
|
+
root: opts.root,
|
|
4396
|
+
language
|
|
4397
|
+
});
|
|
3031
4398
|
const diff = computeDiff(existingSnapshot.graph, currentGraph);
|
|
3032
4399
|
const report = formatDiffReport(diff);
|
|
3033
4400
|
console.log(report);
|
|
@@ -3047,8 +4414,12 @@ program.command("context").description(
|
|
|
3047
4414
|
let snapshot = await loadSnapshot(opts.root);
|
|
3048
4415
|
if (!snapshot) {
|
|
3049
4416
|
console.log(t("cli.autoGenerating"));
|
|
3050
|
-
const
|
|
3051
|
-
|
|
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);
|
|
3052
4423
|
}
|
|
3053
4424
|
const graph = snapshot.graph;
|
|
3054
4425
|
if (opts.json) {
|
|
@@ -3090,18 +4461,24 @@ program.command("serve").description(
|
|
|
3090
4461
|
const language = validateLanguage(opts.language);
|
|
3091
4462
|
console.log(t("web.starting"));
|
|
3092
4463
|
console.log(t("cli.analyzing"));
|
|
3093
|
-
let graph;
|
|
3094
4464
|
let diff = null;
|
|
4465
|
+
const result = await resolveGraph({
|
|
4466
|
+
target: opts.target,
|
|
4467
|
+
root: opts.root,
|
|
4468
|
+
exclude: opts.exclude,
|
|
4469
|
+
language
|
|
4470
|
+
});
|
|
3095
4471
|
const snapshot = await loadSnapshot(opts.root);
|
|
3096
4472
|
if (snapshot) {
|
|
3097
|
-
|
|
3098
|
-
diff = computeDiff(snapshot.graph, currentGraph);
|
|
3099
|
-
graph = currentGraph;
|
|
3100
|
-
} else {
|
|
3101
|
-
graph = await analyzeProject(opts.target, { exclude: opts.exclude, language });
|
|
4473
|
+
diff = computeDiff(snapshot.graph, result.graph);
|
|
3102
4474
|
}
|
|
3103
4475
|
const port = parseInt(opts.port, 10);
|
|
3104
|
-
const viewer = startViewer(graph, {
|
|
4476
|
+
const viewer = startViewer(result.graph, {
|
|
4477
|
+
port,
|
|
4478
|
+
diff,
|
|
4479
|
+
layerMetadata: result.layerMetadata,
|
|
4480
|
+
crossLayerEdges: result.crossLayerEdges
|
|
4481
|
+
});
|
|
3105
4482
|
console.log(t("web.listening", { port }));
|
|
3106
4483
|
console.log(t("web.stop"));
|
|
3107
4484
|
if (opts.watch) {
|
|
@@ -3112,9 +4489,18 @@ program.command("serve").description(
|
|
|
3112
4489
|
debounce = setTimeout(async () => {
|
|
3113
4490
|
try {
|
|
3114
4491
|
console.log(t("web.reloading"));
|
|
3115
|
-
const
|
|
4492
|
+
const newResult = await resolveGraph({
|
|
4493
|
+
target: opts.target,
|
|
4494
|
+
root: opts.root,
|
|
4495
|
+
exclude: opts.exclude,
|
|
4496
|
+
language
|
|
4497
|
+
});
|
|
3116
4498
|
viewer.close();
|
|
3117
|
-
startViewer(
|
|
4499
|
+
startViewer(newResult.graph, {
|
|
4500
|
+
port,
|
|
4501
|
+
layerMetadata: newResult.layerMetadata,
|
|
4502
|
+
crossLayerEdges: newResult.crossLayerEdges
|
|
4503
|
+
});
|
|
3118
4504
|
console.log(t("web.reloaded"));
|
|
3119
4505
|
} catch {
|
|
3120
4506
|
}
|
|
@@ -3146,15 +4532,53 @@ jobs:
|
|
|
3146
4532
|
- run: npx archtracker check --target ${opts.target} --ci
|
|
3147
4533
|
`;
|
|
3148
4534
|
try {
|
|
3149
|
-
const dir =
|
|
3150
|
-
await
|
|
3151
|
-
const path =
|
|
3152
|
-
await
|
|
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");
|
|
3153
4539
|
console.log(t("ci.generated", { path }));
|
|
3154
4540
|
} catch (error) {
|
|
3155
4541
|
handleError(error);
|
|
3156
4542
|
}
|
|
3157
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
|
+
});
|
|
3158
4582
|
function validateLanguage(lang) {
|
|
3159
4583
|
if (!lang) return void 0;
|
|
3160
4584
|
if (VALID_LANGUAGES.includes(lang)) return lang;
|