archtracker-mcp 0.4.3 → 0.6.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 +2092 -464
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +94 -4
- package/dist/index.js +604 -44
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.js +713 -89
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/arch-analyze/SKILL.md +8 -2
- package/skills/arch-check/SKILL.md +12 -8
- package/skills/arch-context/SKILL.md +8 -6
- package/skills/arch-search/SKILL.md +11 -7
- package/skills/arch-serve/SKILL.md +27 -0
- package/skills/arch-snapshot/SKILL.md +10 -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";
|
|
@@ -1088,6 +1088,13 @@ var java = {
|
|
|
1088
1088
|
if (projectFiles.has(full)) return full;
|
|
1089
1089
|
}
|
|
1090
1090
|
}
|
|
1091
|
+
for (let i = 1; i < segments.length; i++) {
|
|
1092
|
+
const filePath = segments.slice(i).join("/") + ".java";
|
|
1093
|
+
for (const srcRoot of ["", "src/main/java/", "src/", "app/src/main/java/"]) {
|
|
1094
|
+
const full = join3(rootDir, srcRoot, filePath);
|
|
1095
|
+
if (projectFiles.has(full)) return full;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1091
1098
|
return null;
|
|
1092
1099
|
},
|
|
1093
1100
|
defaultExclude: ["build", "target", "\\.gradle", "\\.idea"]
|
|
@@ -1142,12 +1149,18 @@ var php = {
|
|
|
1142
1149
|
importPatterns: [
|
|
1143
1150
|
// require/include/require_once/include_once 'path'
|
|
1144
1151
|
{ regex: /\b(?:require|include)(?:_once)?\s+['"]([^'"]+)['"]/gm },
|
|
1152
|
+
// require_once __DIR__ . '/path' (common PHP pattern)
|
|
1153
|
+
{ regex: /\b(?:require|include)(?:_once)?\s+__DIR__\s*\.\s*['"]([^'"]+)['"]/gm },
|
|
1145
1154
|
// Bug #9 fix: use Namespace\Class — skip `function` and `const` keywords
|
|
1146
1155
|
{ regex: /^use\s+(?:function\s+|const\s+)?([\w\\]+)/gm }
|
|
1147
1156
|
],
|
|
1148
1157
|
resolveImport(importPath, sourceFile, rootDir, projectFiles) {
|
|
1149
|
-
|
|
1150
|
-
|
|
1158
|
+
let normalizedPath = importPath;
|
|
1159
|
+
if (normalizedPath.startsWith("/")) {
|
|
1160
|
+
normalizedPath = normalizedPath.slice(1);
|
|
1161
|
+
}
|
|
1162
|
+
if (normalizedPath.includes("/") || normalizedPath.endsWith(".php")) {
|
|
1163
|
+
const withExt = normalizedPath.endsWith(".php") ? normalizedPath : normalizedPath + ".php";
|
|
1151
1164
|
const fromSource = resolve2(dirname(sourceFile), withExt);
|
|
1152
1165
|
if (projectFiles.has(fromSource)) return fromSource;
|
|
1153
1166
|
const fromRoot2 = join3(rootDir, withExt);
|
|
@@ -1163,15 +1176,46 @@ var php = {
|
|
|
1163
1176
|
},
|
|
1164
1177
|
defaultExclude: ["vendor"]
|
|
1165
1178
|
};
|
|
1179
|
+
var SWIFT_SKIP_FILES = /* @__PURE__ */ new Set(["Package", "main", "AppDelegate", "SceneDelegate"]);
|
|
1166
1180
|
var swift = {
|
|
1167
1181
|
id: "swift",
|
|
1168
1182
|
extensions: [".swift"],
|
|
1169
1183
|
commentStyle: "c-style",
|
|
1170
|
-
importPatterns: [
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1184
|
+
importPatterns: [],
|
|
1185
|
+
// handled by extractImports
|
|
1186
|
+
extractImports(content, filePath, _rootDir, projectFiles) {
|
|
1187
|
+
const imports = [];
|
|
1188
|
+
const moduleRegex = /^(?:@testable\s+)?import\s+(?:class\s+|struct\s+|enum\s+|protocol\s+|func\s+|var\s+|let\s+|typealias\s+)?(\w+)/gm;
|
|
1189
|
+
let match;
|
|
1190
|
+
while ((match = moduleRegex.exec(content)) !== null) {
|
|
1191
|
+
imports.push(match[1]);
|
|
1192
|
+
}
|
|
1193
|
+
const typeMap = /* @__PURE__ */ new Map();
|
|
1194
|
+
for (const f of projectFiles) {
|
|
1195
|
+
if (f === filePath || !f.endsWith(".swift")) continue;
|
|
1196
|
+
const basename = f.split("/").pop().replace(/\.swift$/, "");
|
|
1197
|
+
if (!basename || SWIFT_SKIP_FILES.has(basename)) continue;
|
|
1198
|
+
typeMap.set(basename, f);
|
|
1199
|
+
}
|
|
1200
|
+
if (typeMap.size > 0) {
|
|
1201
|
+
const escaped = [...typeMap.keys()].map(
|
|
1202
|
+
(n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
1203
|
+
);
|
|
1204
|
+
const combined = new RegExp(`\\b(${escaped.join("|")})\\b`, "g");
|
|
1205
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1206
|
+
while ((match = combined.exec(content)) !== null) {
|
|
1207
|
+
const typeName = match[1];
|
|
1208
|
+
const targetPath = typeMap.get(typeName);
|
|
1209
|
+
if (targetPath && !matched.has(targetPath)) {
|
|
1210
|
+
matched.add(targetPath);
|
|
1211
|
+
imports.push(targetPath);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return imports;
|
|
1216
|
+
},
|
|
1217
|
+
resolveImport(importPath, _sourceFile, rootDir, projectFiles) {
|
|
1218
|
+
if (projectFiles.has(importPath)) return importPath;
|
|
1175
1219
|
const spmDir = join3(rootDir, "Sources", importPath);
|
|
1176
1220
|
for (const f of projectFiles) {
|
|
1177
1221
|
if (f.startsWith(spmDir + "/") && f.endsWith(".swift")) return f;
|
|
@@ -1196,7 +1240,8 @@ var kotlin = {
|
|
|
1196
1240
|
if (cleanPath.endsWith(".")) {
|
|
1197
1241
|
cleanPath = cleanPath.slice(0, -1);
|
|
1198
1242
|
}
|
|
1199
|
-
const
|
|
1243
|
+
const segments = cleanPath.split(".");
|
|
1244
|
+
const filePath = segments.join("/");
|
|
1200
1245
|
for (const ext of [".kt", ".kts"]) {
|
|
1201
1246
|
for (const srcRoot of [
|
|
1202
1247
|
"",
|
|
@@ -1210,6 +1255,22 @@ var kotlin = {
|
|
|
1210
1255
|
if (projectFiles.has(full)) return full;
|
|
1211
1256
|
}
|
|
1212
1257
|
}
|
|
1258
|
+
for (let i = 1; i < segments.length; i++) {
|
|
1259
|
+
const suffixPath = segments.slice(i).join("/");
|
|
1260
|
+
for (const ext of [".kt", ".kts"]) {
|
|
1261
|
+
for (const srcRoot of [
|
|
1262
|
+
"",
|
|
1263
|
+
"src/main/kotlin/",
|
|
1264
|
+
"src/main/java/",
|
|
1265
|
+
"src/",
|
|
1266
|
+
"app/src/main/kotlin/",
|
|
1267
|
+
"app/src/main/java/"
|
|
1268
|
+
]) {
|
|
1269
|
+
const full = join3(rootDir, srcRoot, suffixPath + ext);
|
|
1270
|
+
if (projectFiles.has(full)) return full;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1213
1274
|
return null;
|
|
1214
1275
|
},
|
|
1215
1276
|
defaultExclude: ["build", "\\.gradle", "\\.idea"]
|
|
@@ -1295,8 +1356,10 @@ var dart = {
|
|
|
1295
1356
|
const prefix = `package:${ownPackage}/`;
|
|
1296
1357
|
if (!importPath.startsWith(prefix)) return null;
|
|
1297
1358
|
const relPath = importPath.slice(prefix.length);
|
|
1298
|
-
const
|
|
1299
|
-
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;
|
|
1300
1363
|
return null;
|
|
1301
1364
|
}
|
|
1302
1365
|
const resolved = resolve2(dirname(sourceFile), importPath);
|
|
@@ -1359,6 +1422,15 @@ var scala = {
|
|
|
1359
1422
|
}
|
|
1360
1423
|
}
|
|
1361
1424
|
}
|
|
1425
|
+
for (let i = 1; i < segments.length; i++) {
|
|
1426
|
+
const suffixPath = segments.slice(i).join("/");
|
|
1427
|
+
for (const ext of [".scala", ".sc"]) {
|
|
1428
|
+
for (const srcRoot of ["", "src/main/scala/", "src/", "app/"]) {
|
|
1429
|
+
const full = join3(rootDir, srcRoot, suffixPath + ext);
|
|
1430
|
+
if (projectFiles.has(full)) return full;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1362
1434
|
return null;
|
|
1363
1435
|
},
|
|
1364
1436
|
defaultExclude: ["target", "\\.bsp", "\\.metals", "\\.bloop"]
|
|
@@ -1546,6 +1618,11 @@ var en = {
|
|
|
1546
1618
|
"analyze.snapshotSaved": "\nSnapshot saved alongside analysis.",
|
|
1547
1619
|
// CI
|
|
1548
1620
|
"ci.generated": "GitHub Actions workflow generated: {path}",
|
|
1621
|
+
// Layers
|
|
1622
|
+
"layers.alreadyExists": "layers.json already exists. Edit it manually to modify.",
|
|
1623
|
+
"layers.created": "Created .archtracker/layers.json \u2014 edit it to configure your layers.",
|
|
1624
|
+
"layers.notFound": "No .archtracker/layers.json found. Run 'archtracker layers init' to create one.",
|
|
1625
|
+
"layers.header": "Configured layers ({count}):",
|
|
1549
1626
|
// Web viewer
|
|
1550
1627
|
"web.starting": "Starting architecture viewer...",
|
|
1551
1628
|
"web.listening": "Architecture graph available at: http://localhost:{port}",
|
|
@@ -1634,6 +1711,11 @@ var ja = {
|
|
|
1634
1711
|
"analyze.snapshotSaved": "\n\u5206\u6790\u3068\u540C\u6642\u306B\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F\u3002",
|
|
1635
1712
|
// CI
|
|
1636
1713
|
"ci.generated": "GitHub Actions \u30EF\u30FC\u30AF\u30D5\u30ED\u30FC\u3092\u751F\u6210\u3057\u307E\u3057\u305F: {path}",
|
|
1714
|
+
// Layers
|
|
1715
|
+
"layers.alreadyExists": "layers.json \u306F\u65E2\u306B\u5B58\u5728\u3057\u307E\u3059\u3002\u76F4\u63A5\u7DE8\u96C6\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
1716
|
+
"layers.created": ".archtracker/layers.json \u3092\u4F5C\u6210\u3057\u307E\u3057\u305F\u3002\u30EC\u30A4\u30E4\u30FC\u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
1717
|
+
"layers.notFound": ".archtracker/layers.json \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002'archtracker layers init' \u3067\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
1718
|
+
"layers.header": "\u8A2D\u5B9A\u6E08\u307F\u30EC\u30A4\u30E4\u30FC ({count}\u4EF6):",
|
|
1637
1719
|
// Web viewer
|
|
1638
1720
|
"web.starting": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30D3\u30E5\u30FC\u30A2\u30FC\u3092\u8D77\u52D5\u4E2D...",
|
|
1639
1721
|
"web.listening": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30B0\u30E9\u30D5: http://localhost:{port}",
|
|
@@ -1710,61 +1792,546 @@ function formatAnalysisReport(graph, options = {}) {
|
|
|
1710
1792
|
return lines.join("\n");
|
|
1711
1793
|
}
|
|
1712
1794
|
|
|
1713
|
-
// src/
|
|
1714
|
-
import {
|
|
1715
|
-
import {
|
|
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
|
+
|
|
2178
|
+
// src/storage/layers.ts
|
|
2179
|
+
import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
|
|
2180
|
+
import { join as join5 } from "path";
|
|
1716
2181
|
import { z } from "zod";
|
|
2182
|
+
var ARCHTRACKER_DIR = ".archtracker";
|
|
2183
|
+
var LAYERS_FILE = "layers.json";
|
|
2184
|
+
var LayerDefinitionSchema = z.object({
|
|
2185
|
+
name: z.string().min(1).regex(
|
|
2186
|
+
/^[a-zA-Z0-9_-]+$/,
|
|
2187
|
+
"Layer name must be alphanumeric (hyphens/underscores allowed)"
|
|
2188
|
+
),
|
|
2189
|
+
targetDir: z.string().min(1),
|
|
2190
|
+
language: z.enum(LANGUAGE_IDS).optional(),
|
|
2191
|
+
exclude: z.array(z.string()).optional(),
|
|
2192
|
+
color: z.string().optional(),
|
|
2193
|
+
description: z.string().optional()
|
|
2194
|
+
});
|
|
2195
|
+
var CrossLayerConnectionSchema = z.object({
|
|
2196
|
+
fromLayer: z.string(),
|
|
2197
|
+
fromFile: z.string(),
|
|
2198
|
+
toLayer: z.string(),
|
|
2199
|
+
toFile: z.string(),
|
|
2200
|
+
type: z.enum(["api-call", "event", "data-flow", "manual"]),
|
|
2201
|
+
label: z.string().optional()
|
|
2202
|
+
});
|
|
2203
|
+
var LayerConfigSchema = z.object({
|
|
2204
|
+
version: z.literal("1.0"),
|
|
2205
|
+
layers: z.array(LayerDefinitionSchema).min(1).refine(
|
|
2206
|
+
(layers) => {
|
|
2207
|
+
const names = layers.map((l) => l.name);
|
|
2208
|
+
return new Set(names).size === names.length;
|
|
2209
|
+
},
|
|
2210
|
+
{ message: "Layer names must be unique" }
|
|
2211
|
+
),
|
|
2212
|
+
connections: z.array(CrossLayerConnectionSchema).optional()
|
|
2213
|
+
});
|
|
2214
|
+
async function loadLayerConfig(projectRoot) {
|
|
2215
|
+
const filePath = join5(projectRoot, ARCHTRACKER_DIR, LAYERS_FILE);
|
|
2216
|
+
let raw;
|
|
2217
|
+
try {
|
|
2218
|
+
raw = await readFile2(filePath, "utf-8");
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
throw new Error(`Failed to read ${filePath}`);
|
|
2224
|
+
}
|
|
2225
|
+
let parsed;
|
|
2226
|
+
try {
|
|
2227
|
+
parsed = JSON.parse(raw);
|
|
2228
|
+
} catch {
|
|
2229
|
+
throw new Error(`Invalid JSON in ${filePath}`);
|
|
2230
|
+
}
|
|
2231
|
+
const result = LayerConfigSchema.safeParse(parsed);
|
|
2232
|
+
if (!result.success) {
|
|
2233
|
+
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
|
|
2234
|
+
throw new Error(`layers.json validation failed:
|
|
2235
|
+
${issues}`);
|
|
2236
|
+
}
|
|
2237
|
+
return result.data;
|
|
2238
|
+
}
|
|
2239
|
+
async function saveLayerConfig(projectRoot, config) {
|
|
2240
|
+
const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
|
|
2241
|
+
const filePath = join5(dirPath, LAYERS_FILE);
|
|
2242
|
+
await mkdir(dirPath, { recursive: true });
|
|
2243
|
+
await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2244
|
+
}
|
|
2245
|
+
function isNodeError(error) {
|
|
2246
|
+
return error instanceof Error && "code" in error;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/analyzer/resolve.ts
|
|
2250
|
+
async function resolveGraph(opts) {
|
|
2251
|
+
const layerConfig = await loadLayerConfig(opts.projectRoot);
|
|
2252
|
+
if (layerConfig) {
|
|
2253
|
+
const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers);
|
|
2254
|
+
const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
|
|
2255
|
+
const manualConnections = layerConfig.connections ?? [];
|
|
2256
|
+
const manualKeys = new Set(manualConnections.map(
|
|
2257
|
+
(c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
|
|
2258
|
+
));
|
|
2259
|
+
const merged = [
|
|
2260
|
+
...manualConnections,
|
|
2261
|
+
...autoConnections.filter(
|
|
2262
|
+
(c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
|
|
2263
|
+
)
|
|
2264
|
+
];
|
|
2265
|
+
return {
|
|
2266
|
+
graph: multi.merged,
|
|
2267
|
+
multiLayer: multi,
|
|
2268
|
+
layerMetadata: multi.layerMetadata,
|
|
2269
|
+
crossLayerEdges: merged
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
const graph = await analyzeProject(opts.targetDir, {
|
|
2273
|
+
exclude: opts.exclude,
|
|
2274
|
+
language: opts.language
|
|
2275
|
+
});
|
|
2276
|
+
return { graph };
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// src/storage/snapshot.ts
|
|
2280
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, access } from "fs/promises";
|
|
2281
|
+
import { join as join6 } from "path";
|
|
2282
|
+
import { z as z2 } from "zod";
|
|
1717
2283
|
|
|
1718
2284
|
// src/types/schema.ts
|
|
1719
|
-
var SCHEMA_VERSION = "1.
|
|
2285
|
+
var SCHEMA_VERSION = "1.1";
|
|
1720
2286
|
|
|
1721
2287
|
// src/storage/snapshot.ts
|
|
1722
|
-
var
|
|
2288
|
+
var ARCHTRACKER_DIR2 = ".archtracker";
|
|
1723
2289
|
var SNAPSHOT_FILE = "snapshot.json";
|
|
1724
|
-
var FileNodeSchema =
|
|
1725
|
-
path:
|
|
1726
|
-
exists:
|
|
1727
|
-
dependencies:
|
|
1728
|
-
dependents:
|
|
2290
|
+
var FileNodeSchema = z2.object({
|
|
2291
|
+
path: z2.string(),
|
|
2292
|
+
exists: z2.boolean(),
|
|
2293
|
+
dependencies: z2.array(z2.string()),
|
|
2294
|
+
dependents: z2.array(z2.string())
|
|
1729
2295
|
});
|
|
1730
|
-
var DependencyGraphSchema =
|
|
1731
|
-
rootDir:
|
|
1732
|
-
files:
|
|
1733
|
-
edges:
|
|
1734
|
-
source:
|
|
1735
|
-
target:
|
|
1736
|
-
type:
|
|
2296
|
+
var DependencyGraphSchema = z2.object({
|
|
2297
|
+
rootDir: z2.string(),
|
|
2298
|
+
files: z2.record(z2.string(), FileNodeSchema),
|
|
2299
|
+
edges: z2.array(z2.object({
|
|
2300
|
+
source: z2.string(),
|
|
2301
|
+
target: z2.string(),
|
|
2302
|
+
type: z2.enum(["static", "dynamic", "type-only"])
|
|
1737
2303
|
})),
|
|
1738
|
-
circularDependencies:
|
|
1739
|
-
totalFiles:
|
|
1740
|
-
totalEdges:
|
|
2304
|
+
circularDependencies: z2.array(z2.object({ cycle: z2.array(z2.string()) })),
|
|
2305
|
+
totalFiles: z2.number(),
|
|
2306
|
+
totalEdges: z2.number()
|
|
1741
2307
|
});
|
|
1742
|
-
var SnapshotSchema =
|
|
1743
|
-
version:
|
|
1744
|
-
timestamp:
|
|
1745
|
-
rootDir:
|
|
2308
|
+
var SnapshotSchema = z2.object({
|
|
2309
|
+
version: z2.enum([SCHEMA_VERSION, "1.0"]),
|
|
2310
|
+
timestamp: z2.string(),
|
|
2311
|
+
rootDir: z2.string(),
|
|
1746
2312
|
graph: DependencyGraphSchema
|
|
1747
2313
|
});
|
|
1748
|
-
async function saveSnapshot(projectRoot, graph) {
|
|
1749
|
-
const dirPath =
|
|
1750
|
-
const filePath =
|
|
2314
|
+
async function saveSnapshot(projectRoot, graph, multiLayer) {
|
|
2315
|
+
const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
|
|
2316
|
+
const filePath = join6(dirPath, SNAPSHOT_FILE);
|
|
1751
2317
|
const snapshot = {
|
|
1752
2318
|
version: SCHEMA_VERSION,
|
|
1753
2319
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1754
2320
|
rootDir: graph.rootDir,
|
|
1755
|
-
graph
|
|
2321
|
+
graph,
|
|
2322
|
+
...multiLayer ? { multiLayer } : {}
|
|
1756
2323
|
};
|
|
1757
|
-
await
|
|
1758
|
-
await
|
|
2324
|
+
await mkdir2(dirPath, { recursive: true });
|
|
2325
|
+
await writeFile2(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
1759
2326
|
return snapshot;
|
|
1760
2327
|
}
|
|
1761
2328
|
async function loadSnapshot(projectRoot) {
|
|
1762
|
-
const filePath =
|
|
2329
|
+
const filePath = join6(projectRoot, ARCHTRACKER_DIR2, SNAPSHOT_FILE);
|
|
1763
2330
|
let raw;
|
|
1764
2331
|
try {
|
|
1765
|
-
raw = await
|
|
2332
|
+
raw = await readFile3(filePath, "utf-8");
|
|
1766
2333
|
} catch (error) {
|
|
1767
|
-
if (
|
|
2334
|
+
if (isNodeError2(error) && error.code === "ENOENT") {
|
|
1768
2335
|
return null;
|
|
1769
2336
|
}
|
|
1770
2337
|
throw new StorageError(
|
|
@@ -1795,7 +2362,7 @@ var StorageError = class extends Error {
|
|
|
1795
2362
|
this.name = "StorageError";
|
|
1796
2363
|
}
|
|
1797
2364
|
};
|
|
1798
|
-
function
|
|
2365
|
+
function isNodeError2(error) {
|
|
1799
2366
|
return error instanceof Error && "code" in error;
|
|
1800
2367
|
}
|
|
1801
2368
|
|
|
@@ -1899,42 +2466,9 @@ function arraysEqual(a, b) {
|
|
|
1899
2466
|
// src/web/server.ts
|
|
1900
2467
|
import { createServer } from "http";
|
|
1901
2468
|
|
|
1902
|
-
// src/web/
|
|
1903
|
-
function
|
|
1904
|
-
|
|
1905
|
-
const diff = options.diff ?? null;
|
|
1906
|
-
const files = Object.values(graph.files);
|
|
1907
|
-
const nodes = files.map((f) => ({
|
|
1908
|
-
id: f.path,
|
|
1909
|
-
deps: f.dependencies.length,
|
|
1910
|
-
dependents: f.dependents.length,
|
|
1911
|
-
dependencies: f.dependencies,
|
|
1912
|
-
dependentsList: f.dependents,
|
|
1913
|
-
isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
|
|
1914
|
-
dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : "."
|
|
1915
|
-
}));
|
|
1916
|
-
const links = graph.edges.map((e) => ({
|
|
1917
|
-
source: e.source,
|
|
1918
|
-
target: e.target,
|
|
1919
|
-
type: e.type
|
|
1920
|
-
}));
|
|
1921
|
-
const circularFiles = /* @__PURE__ */ new Set();
|
|
1922
|
-
for (const c of graph.circularDependencies) {
|
|
1923
|
-
for (const f of c.cycle) circularFiles.add(f);
|
|
1924
|
-
}
|
|
1925
|
-
const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
|
|
1926
|
-
const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
|
|
1927
|
-
const diffData = diff ? JSON.stringify(diff) : "null";
|
|
1928
|
-
const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
|
|
1929
|
-
return (
|
|
1930
|
-
/* html */
|
|
1931
|
-
`<!DOCTYPE html>
|
|
1932
|
-
<html lang="${locale}">
|
|
1933
|
-
<head>
|
|
1934
|
-
<meta charset="utf-8">
|
|
1935
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1936
|
-
<title>${projectName} \u2014 Architecture Viewer</title>
|
|
1937
|
-
<style>
|
|
2469
|
+
// src/web/styles.ts
|
|
2470
|
+
function buildStyles() {
|
|
2471
|
+
return `<style>
|
|
1938
2472
|
:root {
|
|
1939
2473
|
--bg: #0d1117; --bg-card: #161b22; --bg-hover: #1c2129;
|
|
1940
2474
|
--border: #30363d; --border-active: #58a6ff;
|
|
@@ -1989,13 +2523,25 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
1989
2523
|
#tooltip .tt-out { color: var(--accent); }
|
|
1990
2524
|
#tooltip .tt-in { color: var(--green); }
|
|
1991
2525
|
|
|
1992
|
-
/* \u2500\u2500\u2500
|
|
1993
|
-
#
|
|
1994
|
-
|
|
2526
|
+
/* \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 */
|
|
2527
|
+
#filter-bar { position: absolute; bottom: 12px; left: 12px; right: 120px; z-index: 10; display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
|
|
2528
|
+
#filter-bar > * { pointer-events: auto; }
|
|
2529
|
+
#filter-layer-row { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
2530
|
+
#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; }
|
|
2531
|
+
#filter-dir-toggle:hover { border-color: var(--text-dim); color: var(--text); }
|
|
2532
|
+
#filter-dir-toggle.open { border-color: var(--accent); color: var(--text); }
|
|
2533
|
+
#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); }
|
|
2534
|
+
#filter-dir-panel.open { display: block; }
|
|
2535
|
+
.dir-group { margin-bottom: 8px; }
|
|
2536
|
+
.dir-group:last-child { margin-bottom: 0; }
|
|
2537
|
+
.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; }
|
|
2538
|
+
.dir-group-label .dg-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2539
|
+
.dir-group-pills { display: flex; flex-wrap: wrap; gap: 3px; }
|
|
2540
|
+
.filter-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 2px 8px; font-size: 10px; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 4px; }
|
|
1995
2541
|
.filter-pill:hover { border-color: var(--text-dim); }
|
|
1996
2542
|
.filter-pill.active { border-color: var(--accent); }
|
|
1997
|
-
.filter-pill .pill-dot { width:
|
|
1998
|
-
.filter-pill .pill-count { color: var(--text-muted); font-size:
|
|
2543
|
+
.filter-pill .pill-dot { width: 5px; height: 5px; border-radius: 50%; }
|
|
2544
|
+
.filter-pill .pill-count { color: var(--text-muted); font-size: 9px; }
|
|
1999
2545
|
|
|
2000
2546
|
/* \u2500\u2500\u2500 Zoom controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2001
2547
|
#zoom-ctrl { position: absolute; bottom: 52px; right: 12px; z-index: 10; display: flex; flex-direction: column; gap: 2px; }
|
|
@@ -2052,18 +2598,42 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2052
2598
|
#impact-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
|
|
2053
2599
|
#impact-badge { position: absolute; bottom: 52px; left: 12px; z-index: 10; display: none; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: var(--radius); }
|
|
2054
2600
|
|
|
2601
|
+
/* \u2500\u2500\u2500 Diff focus button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2602
|
+
#diff-focus-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
|
|
2603
|
+
|
|
2055
2604
|
/* \u2500\u2500\u2500 Help bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2056
2605
|
#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; }
|
|
2057
|
-
</style>
|
|
2058
|
-
</head>
|
|
2059
|
-
<body>
|
|
2060
2606
|
|
|
2607
|
+
/* \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 */
|
|
2608
|
+
.layer-hull { fill-opacity: 0.06; stroke-width: 1.5; stroke-dasharray: 6,4; pointer-events: none; }
|
|
2609
|
+
.layer-hull-label { font-size: 13px; font-weight: 700; letter-spacing: 0.5px; pointer-events: none; opacity: 0.7; }
|
|
2610
|
+
|
|
2611
|
+
/* \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 */
|
|
2612
|
+
#layer-tabs { display: flex; gap: 2px; margin-left: 12px; padding-left: 12px; border-left: 1px solid var(--border); }
|
|
2613
|
+
.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; }
|
|
2614
|
+
.layer-tab:hover { color: var(--text); background: var(--bg-hover); }
|
|
2615
|
+
.layer-tab.active { border-color: var(--accent); color: var(--text); }
|
|
2616
|
+
.layer-tab .lt-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2617
|
+
|
|
2618
|
+
/* \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 */
|
|
2619
|
+
.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; }
|
|
2620
|
+
.layer-pill:hover { border-color: var(--text-dim); }
|
|
2621
|
+
.layer-pill.active { border-color: var(--accent); }
|
|
2622
|
+
.layer-pill .lp-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2623
|
+
.layer-pill .lp-count { color: var(--text-muted); font-size: 9px; font-weight: 400; }
|
|
2624
|
+
</style>`;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// src/web/viewer-html.ts
|
|
2628
|
+
function buildViewerHtml() {
|
|
2629
|
+
return `
|
|
2061
2630
|
<!-- Tab bar -->
|
|
2062
2631
|
<div id="tab-bar">
|
|
2063
2632
|
<span class="logo" id="project-title" contenteditable="true" spellcheck="false" title="Click to edit project name"></span>
|
|
2064
2633
|
<div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
|
|
2065
2634
|
<div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
|
|
2066
2635
|
<div class="tab" data-view="diff-view" id="diff-tab" style="display:none" data-i18n="tab.diff">Diff</div>
|
|
2636
|
+
<div id="layer-tabs"></div>
|
|
2067
2637
|
<div class="tab-right">
|
|
2068
2638
|
<div class="tab-stats">
|
|
2069
2639
|
<span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
|
|
@@ -2104,6 +2674,11 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2104
2674
|
<input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
|
|
2105
2675
|
<div class="setting-value"><span id="gravity-val">150</span></div>
|
|
2106
2676
|
</div>
|
|
2677
|
+
<div id="layer-gravity-setting" class="setting-group" style="display:none">
|
|
2678
|
+
<label>Layer Cohesion</label>
|
|
2679
|
+
<input type="range" id="layer-gravity-slider" min="1" max="40" value="12" oninput="setLayerGravity(this.value)">
|
|
2680
|
+
<div class="setting-value"><span id="layer-gravity-val">12</span></div>
|
|
2681
|
+
</div>
|
|
2107
2682
|
<div class="setting-group">
|
|
2108
2683
|
<label data-i18n="settings.language">Language</label>
|
|
2109
2684
|
<div class="theme-toggle">
|
|
@@ -2111,6 +2686,12 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2111
2686
|
<div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
|
|
2112
2687
|
</div>
|
|
2113
2688
|
</div>
|
|
2689
|
+
<div id="cross-layer-setting" class="setting-group" style="display:none">
|
|
2690
|
+
<label>Cross-layer Links</label>
|
|
2691
|
+
<div class="theme-toggle">
|
|
2692
|
+
<div class="theme-btn active" id="cross-link-toggle" onclick="toggleCrossLinks()">ON</div>
|
|
2693
|
+
</div>
|
|
2694
|
+
</div>
|
|
2114
2695
|
<div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
|
2115
2696
|
<label data-i18n="settings.export">Export</label>
|
|
2116
2697
|
<div class="theme-toggle">
|
|
@@ -2130,6 +2711,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2130
2711
|
<kbd>/</kbd>
|
|
2131
2712
|
</div>
|
|
2132
2713
|
<div class="hud-panel" id="legend-panel">
|
|
2714
|
+
<div id="layer-legend"></div>
|
|
2133
2715
|
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
2134
2716
|
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
2135
2717
|
<div class="legend-item"><div class="legend-dot" style="border:2px solid var(--yellow);width:6px;height:6px"></div> <span data-i18n="legend.highCoupling">High coupling</span></div>
|
|
@@ -2143,11 +2725,14 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2143
2725
|
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
|
|
2144
2726
|
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
|
|
2145
2727
|
</div>
|
|
2146
|
-
<div id="
|
|
2728
|
+
<div id="filter-bar">
|
|
2729
|
+
<div id="filter-dir-panel"></div>
|
|
2730
|
+
<div id="filter-layer-row"></div>
|
|
2731
|
+
</div>
|
|
2147
2732
|
<div id="zoom-ctrl">
|
|
2148
2733
|
<button onclick="zoomIn()" title="Zoom in">+</button>
|
|
2149
2734
|
<button onclick="zoomOut()" title="Zoom out">\u2212</button>
|
|
2150
|
-
<button onclick="zoomFit()" title="Fit">\
|
|
2735
|
+
<button onclick="zoomFit()" title="Fit">\u229E</button>
|
|
2151
2736
|
<button id="impact-btn" onclick="toggleImpactMode()" title="Impact simulation" style="font-size:12px;margin-top:4px" data-i18n="impact.btn">Impact</button>
|
|
2152
2737
|
</div>
|
|
2153
2738
|
<div id="impact-badge"></div>
|
|
@@ -2171,22 +2756,35 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2171
2756
|
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
|
|
2172
2757
|
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
|
|
2173
2758
|
</div>
|
|
2174
|
-
<div id="hier-
|
|
2759
|
+
<div id="hier-filter-bar" style="position:absolute;bottom:12px;left:12px;right:120px;z-index:10;display:none;">
|
|
2760
|
+
<div id="hier-filter-row" style="display:flex;flex-wrap:wrap;gap:4px;"></div>
|
|
2761
|
+
</div>
|
|
2175
2762
|
<div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
|
|
2176
2763
|
</div>
|
|
2177
2764
|
|
|
2178
2765
|
<!-- Diff View -->
|
|
2179
2766
|
<div id="diff-view" class="view">
|
|
2180
2767
|
<svg id="diff-svg"></svg>
|
|
2181
|
-
<div id="diff-
|
|
2768
|
+
<div id="diff-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
|
|
2182
2769
|
<div class="hud-panel">
|
|
2183
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div> <span data-i18n="diff.addedLabel">Added</span></div>
|
|
2184
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="diff.removedLabel">Removed</span></div>
|
|
2185
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div> <span data-i18n="diff.modifiedLabel">Modified</span></div>
|
|
2186
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> <span data-i18n="diff.affectedLabel">Affected</span></div>
|
|
2770
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div> <span data-i18n="diff.addedLabel">Added</span> <b id="diff-added-count" style="margin-left:auto">0</b></div>
|
|
2771
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="diff.removedLabel">Removed</span> <b id="diff-removed-count" style="margin-left:auto">0</b></div>
|
|
2772
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div> <span data-i18n="diff.modifiedLabel">Modified</span> <b id="diff-modified-count" style="margin-left:auto">0</b></div>
|
|
2773
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> <span data-i18n="diff.affectedLabel">Affected</span> <b id="diff-affected-count" style="margin-left:auto">0</b></div>
|
|
2774
|
+
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;">
|
|
2775
|
+
<button id="diff-focus-btn" onclick="toggleDiffFocus()" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer;color:var(--text-dim);font-size:11px;width:100%;transition:all 0.15s;" data-i18n="diff.focusChanges">Focus changes</button>
|
|
2776
|
+
</div>
|
|
2187
2777
|
</div>
|
|
2188
2778
|
</div>
|
|
2189
|
-
<div id="
|
|
2779
|
+
<div id="diff-detail" style="position:absolute;top:12px;right:12px;width:280px;z-index:10;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-size:13px;display:none;max-height:calc(100vh - 100px);overflow-y:auto;transition:background 0.3s;">
|
|
2780
|
+
<button class="close-btn" onclick="closeDiffDetail()" style="position:absolute;top:8px;right:10px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;">\u2715</button>
|
|
2781
|
+
<div class="detail-name" id="dd-name"></div>
|
|
2782
|
+
<div id="dd-status" style="margin:6px 0;font-size:12px;font-weight:600;"></div>
|
|
2783
|
+
<div class="detail-meta" id="dd-meta"></div>
|
|
2784
|
+
<div class="detail-section"><h4 data-i18n="diff.affectedByChange">Affected by this change</h4><ul class="detail-list" id="dd-affected"></ul></div>
|
|
2785
|
+
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="dd-deps"></ul></div>
|
|
2786
|
+
</div>
|
|
2787
|
+
<div id="help-bar" style="position:absolute" data-i18n="help.diff">Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected \xB7 Click: impact chain</div>
|
|
2190
2788
|
</div>
|
|
2191
2789
|
|
|
2192
2790
|
<!-- Tooltip (shared, interactive) -->
|
|
@@ -2197,79 +2795,798 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2197
2795
|
<span class="tt-badge tt-in" id="tt-dpt-count" style="margin-left:6px"></span> <span data-i18n="tooltip.importedBy">imported by</span>
|
|
2198
2796
|
</div>
|
|
2199
2797
|
<div class="tt-section" id="tt-details"></div>
|
|
2200
|
-
</div
|
|
2201
|
-
|
|
2202
|
-
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
2203
|
-
<script>
|
|
2204
|
-
// \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
|
|
2205
|
-
// i18n
|
|
2206
|
-
// \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
|
|
2207
|
-
const I18N = {
|
|
2208
|
-
en: {
|
|
2209
|
-
'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
|
|
2210
|
-
'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
|
|
2211
|
-
'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
|
|
2212
|
-
'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
|
|
2213
|
-
'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
|
|
2214
|
-
'search.placeholder': 'Search files...',
|
|
2215
|
-
'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
|
|
2216
|
-
'legend.imports': 'imports', 'legend.importedBy': 'imported by',
|
|
2217
|
-
'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
|
|
2218
|
-
'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
|
|
2219
|
-
'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
|
|
2220
|
-
'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
|
|
2221
|
-
'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
|
|
2222
|
-
'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
|
|
2223
|
-
'tab.diff': 'Diff',
|
|
2224
|
-
'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
|
|
2225
|
-
},
|
|
2226
|
-
ja: {
|
|
2227
|
-
'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
|
|
2228
|
-
'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
|
|
2229
|
-
'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
|
|
2230
|
-
'settings.nodeSize': '\u30CE\u30FC\u30C9\u30B5\u30A4\u30BA', 'settings.linkOpacity': '\u30EA\u30F3\u30AF\u900F\u660E\u5EA6', 'settings.gravity': '\u91CD\u529B', 'settings.language': '\u8A00\u8A9E', 'settings.export': '\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8',
|
|
2231
|
-
'impact.title': '\u5F71\u97FF\u7BC4\u56F2\u30B7\u30DF\u30E5\u30EC\u30FC\u30B7\u30E7\u30F3', 'impact.btn': '\u5F71\u97FF', 'impact.transitive': '\u30D5\u30A1\u30A4\u30EB\u306B\u5F71\u97FF',
|
|
2232
|
-
'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
|
|
2233
|
-
'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
|
|
2234
|
-
'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
|
|
2235
|
-
'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
|
|
2236
|
-
'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
|
|
2237
|
-
'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
|
|
2238
|
-
'help.graph': '\u30B9\u30AF\u30ED\u30FC\u30EB: \u30BA\u30FC\u30E0 \xB7 \u30C9\u30E9\u30C3\u30B0: \u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF: \u9078\u629E \xB7 / \u691C\u7D22',
|
|
2239
|
-
'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
|
|
2240
|
-
'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
|
|
2241
|
-
'tab.diff': '\u5DEE\u5206',
|
|
2242
|
-
'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
|
|
2243
|
-
}
|
|
2244
|
-
};
|
|
2245
|
-
let currentLang = '${locale}';
|
|
2246
|
-
function applyI18n() {
|
|
2247
|
-
const msgs = I18N[currentLang] || I18N.en;
|
|
2248
|
-
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
2249
|
-
const key = el.getAttribute('data-i18n');
|
|
2250
|
-
if (msgs[key]) el.textContent = msgs[key];
|
|
2251
|
-
});
|
|
2252
|
-
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
2253
|
-
const key = el.getAttribute('data-i18n-placeholder');
|
|
2254
|
-
if (msgs[key]) el.placeholder = msgs[key];
|
|
2255
|
-
});
|
|
2256
|
-
document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
|
|
2798
|
+
</div>`;
|
|
2257
2799
|
}
|
|
2258
|
-
window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
|
|
2259
|
-
function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
|
|
2260
2800
|
|
|
2801
|
+
// src/web/js-hierarchy.ts
|
|
2802
|
+
function buildHierarchyJs() {
|
|
2803
|
+
return `
|
|
2261
2804
|
// \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
|
|
2262
|
-
//
|
|
2805
|
+
// HIERARCHY VIEW
|
|
2263
2806
|
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
const
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2807
|
+
function buildHierarchy(){
|
|
2808
|
+
const hSvg=d3.select('#hier-svg');
|
|
2809
|
+
const hG=hSvg.append('g');
|
|
2810
|
+
const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
|
|
2811
|
+
hSvg.call(hZoom);
|
|
2812
|
+
|
|
2813
|
+
const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
|
|
2814
|
+
const importsMap={}; DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!importsMap[s])importsMap[s]=[];importsMap[s].push(t);});
|
|
2815
|
+
|
|
2816
|
+
const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
|
|
2817
|
+
const layers={};const visited=new Set();
|
|
2818
|
+
const queue=entryPoints.map(id=>({id,layer:0}));
|
|
2819
|
+
DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
|
|
2820
|
+
|
|
2821
|
+
while(queue.length>0){
|
|
2822
|
+
const{id,layer}=queue.shift();
|
|
2823
|
+
if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
|
|
2824
|
+
layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
|
|
2825
|
+
(importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
|
|
2826
|
+
}
|
|
2827
|
+
DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
|
|
2828
|
+
|
|
2829
|
+
const maxLayer=Math.max(0,...Object.values(layers));
|
|
2830
|
+
const layerGroups={};
|
|
2831
|
+
for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
|
|
2832
|
+
Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
|
|
2833
|
+
Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
|
|
2834
|
+
|
|
2835
|
+
const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
|
|
2836
|
+
const positions={};let maxRowWidth=0;
|
|
2837
|
+
for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
|
|
2838
|
+
for(let layer=0;layer<=maxLayer;layer++){
|
|
2839
|
+
const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
|
|
2840
|
+
items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
|
|
2844
|
+
hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
|
|
2845
|
+
|
|
2846
|
+
const linkG=hG.append('g');
|
|
2847
|
+
DATA.links.forEach(l=>{
|
|
2848
|
+
const sId=l.source.id??l.source,tId=l.target.id??l.target;
|
|
2849
|
+
const s=positions[sId],t=positions[tId]; if(!s||!t)return;
|
|
2850
|
+
const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
|
|
2851
|
+
linkG.append('path').attr('class','hier-link')
|
|
2852
|
+
.attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
|
|
2853
|
+
.attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
|
|
2854
|
+
.attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
|
|
2855
|
+
.attr('data-source',sId).attr('data-target',tId);
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
|
|
2859
|
+
.attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
|
|
2860
|
+
.append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
|
|
2861
|
+
linkG.selectAll('path').attr('marker-end','url(#harrow)');
|
|
2862
|
+
|
|
2863
|
+
for(let layer=0;layer<=maxLayer;layer++){
|
|
2864
|
+
if(!layerGroups[layer].length)continue;
|
|
2865
|
+
hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
|
|
2866
|
+
.attr('data-depth-idx',layer)
|
|
2867
|
+
.attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const nodeG=hG.append('g');
|
|
2871
|
+
DATA.nodes.forEach(n=>{
|
|
2872
|
+
const pos=positions[n.id]; if(!pos)return;
|
|
2873
|
+
const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
|
|
2874
|
+
gn.append('rect').attr('width',boxW).attr('height',boxH)
|
|
2875
|
+
.attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
|
|
2876
|
+
.attr('stroke-width',circularSet.has(n.id)?2:1.5);
|
|
2877
|
+
gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
|
|
2878
|
+
.text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\\u2026':fileName(n.id));
|
|
2879
|
+
gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
|
|
2880
|
+
.attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
|
|
2881
|
+
.text(n.dependents>0?'\\u2191'+n.dependents:'');
|
|
2882
|
+
gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
|
|
2883
|
+
.attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
|
|
2884
|
+
|
|
2885
|
+
gn.node().__data_id=n.id;
|
|
2886
|
+
gn.on('mouseover',e=>{
|
|
2887
|
+
showTooltip(e,n);
|
|
2888
|
+
if (!hierPinned) hierHighlight(n.id);
|
|
2889
|
+
})
|
|
2890
|
+
.on('mousemove',e=>positionTooltip(e))
|
|
2891
|
+
.on('mouseout',()=>{
|
|
2892
|
+
scheduleHideTooltip();
|
|
2893
|
+
if (!hierPinned) hierResetHighlight();
|
|
2894
|
+
})
|
|
2895
|
+
.on('click',(e)=>{
|
|
2896
|
+
e.stopPropagation();
|
|
2897
|
+
hierPinned=n.id;
|
|
2898
|
+
hierHighlight(n.id);
|
|
2899
|
+
showHierDetail(n);
|
|
2900
|
+
});
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
// Hierarchy highlight helpers
|
|
2904
|
+
let hierPinned=null;
|
|
2905
|
+
function hierHighlight(nId){
|
|
2906
|
+
linkG.selectAll('path')
|
|
2907
|
+
.attr('stroke',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');if(s===nId)return'#58a6ff';if(t===nId)return'#3fb950';return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
|
|
2908
|
+
.attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
|
|
2909
|
+
.attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
|
|
2910
|
+
nodeG.selectAll('.hier-node').attr('opacity',function(){
|
|
2911
|
+
const id=this.__data_id; if(id===nId)return 1;
|
|
2912
|
+
const connected=DATA.links.some(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return(s===nId&&t===id)||(t===nId&&s===id);});
|
|
2913
|
+
return connected?1:0.3;
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
function hierResetHighlight(){
|
|
2917
|
+
hierPinned=null;
|
|
2918
|
+
linkG.selectAll('path')
|
|
2919
|
+
.attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
|
|
2920
|
+
.attr('stroke-width',1).attr('opacity',1);
|
|
2921
|
+
nodeG.selectAll('.hier-node').attr('opacity',1);
|
|
2922
|
+
}
|
|
2923
|
+
function showHierDetail(n){
|
|
2924
|
+
const p=document.getElementById('hier-detail');
|
|
2925
|
+
document.getElementById('hd-name').textContent=n.id;
|
|
2926
|
+
document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+esc(n.dir)+'<br>'+i('detail.dependencies')+': '+n.deps+' \\u00b7 '+i('detail.dependents')+': '+n.dependents;
|
|
2927
|
+
document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
2928
|
+
document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
2929
|
+
p.classList.add('open');
|
|
2930
|
+
}
|
|
2931
|
+
window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
|
|
2932
|
+
|
|
2933
|
+
// Click on empty space to deselect
|
|
2934
|
+
hSvg.on('click',()=>{closeHierDetail();});
|
|
2935
|
+
|
|
2936
|
+
// Hierarchy filters \u2014 layer pills or dir pills
|
|
2937
|
+
const hFilterRow=document.getElementById('hier-filter-row');
|
|
2938
|
+
const hFilterBar=document.getElementById('hier-filter-bar');
|
|
2939
|
+
if (hFilterBar) hFilterBar.style.display='';
|
|
2940
|
+
const hActiveLayers=new Set(); // empty = show all (same as graph view)
|
|
2941
|
+
|
|
2942
|
+
function hierRelayoutInner() {
|
|
2943
|
+
function isVisible(nId) {
|
|
2944
|
+
var nd = nodeMap[nId];
|
|
2945
|
+
if (!nd) return false;
|
|
2946
|
+
if (LAYERS && nd.layer && hActiveLayers.size > 0 && !hActiveLayers.has(nd.layer)) return false;
|
|
2947
|
+
return true;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// Build visible layer groups and compact Y positions
|
|
2951
|
+
var visibleDepths = [];
|
|
2952
|
+
var visLayerGroups = {};
|
|
2953
|
+
for (var depth = 0; depth <= maxLayer; depth++) {
|
|
2954
|
+
var visItems = layerGroups[depth].filter(function(id) { return isVisible(id); });
|
|
2955
|
+
if (visItems.length > 0) {
|
|
2956
|
+
visLayerGroups[depth] = visItems;
|
|
2957
|
+
visibleDepths.push(depth);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// Recalculate positions for visible nodes (compacted)
|
|
2962
|
+
var newPositions = {};
|
|
2963
|
+
var newMaxRowWidth = 0;
|
|
2964
|
+
visibleDepths.forEach(function(depth) {
|
|
2965
|
+
newMaxRowWidth = Math.max(newMaxRowWidth, visLayerGroups[depth].length * (boxW + gapX) - gapX);
|
|
2966
|
+
});
|
|
2967
|
+
visibleDepths.forEach(function(depth, yIdx) {
|
|
2968
|
+
var items = visLayerGroups[depth];
|
|
2969
|
+
var rowWidth = items.length * (boxW + gapX) - gapX;
|
|
2970
|
+
var startX = padX + (newMaxRowWidth - rowWidth) / 2;
|
|
2971
|
+
items.forEach(function(id, idx) {
|
|
2972
|
+
newPositions[id] = { x: startX + idx * (boxW + gapX), y: padY + yIdx * (boxH + gapY) };
|
|
2973
|
+
});
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
// Update SVG size
|
|
2977
|
+
var newTotalW = (newMaxRowWidth || 0) + padX * 2;
|
|
2978
|
+
var newTotalH = padY * 2 + Math.max(1, visibleDepths.length) * (boxH + gapY);
|
|
2979
|
+
hSvg.attr('width', Math.max(newTotalW, W)).attr('height', Math.max(newTotalH, H));
|
|
2980
|
+
|
|
2981
|
+
// Update nodes: hide/show + transition positions
|
|
2982
|
+
nodeG.selectAll('.hier-node').each(function() {
|
|
2983
|
+
var nId = this.__data_id;
|
|
2984
|
+
var el = d3.select(this);
|
|
2985
|
+
if (!isVisible(nId) || !newPositions[nId]) {
|
|
2986
|
+
el.attr('display', 'none');
|
|
2987
|
+
} else {
|
|
2988
|
+
el.attr('display', null)
|
|
2989
|
+
.transition().duration(300)
|
|
2990
|
+
.attr('transform', 'translate(' + newPositions[nId].x + ',' + newPositions[nId].y + ')');
|
|
2991
|
+
}
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
// Update links: show only if both endpoints visible, recalculate bezier
|
|
2995
|
+
linkG.selectAll('path').each(function() {
|
|
2996
|
+
var sId = this.getAttribute('data-source');
|
|
2997
|
+
var tId = this.getAttribute('data-target');
|
|
2998
|
+
var el = d3.select(this);
|
|
2999
|
+
if (!isVisible(sId) || !isVisible(tId) || !newPositions[sId] || !newPositions[tId]) {
|
|
3000
|
+
el.attr('display', 'none');
|
|
3001
|
+
} else {
|
|
3002
|
+
var s = newPositions[sId], t = newPositions[tId];
|
|
3003
|
+
var x1 = s.x + boxW / 2, y1 = s.y + boxH;
|
|
3004
|
+
var x2 = t.x + boxW / 2, y2 = t.y;
|
|
3005
|
+
var midY = (y1 + y2) / 2;
|
|
3006
|
+
el.attr('display', null)
|
|
3007
|
+
.transition().duration(300)
|
|
3008
|
+
.attr('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2);
|
|
3009
|
+
}
|
|
3010
|
+
});
|
|
3011
|
+
|
|
3012
|
+
// Update depth labels: hide empty depths, reposition visible ones
|
|
3013
|
+
hG.selectAll('.hier-layer-label').each(function() {
|
|
3014
|
+
var depthIdx = +this.getAttribute('data-depth-idx');
|
|
3015
|
+
var el = d3.select(this);
|
|
3016
|
+
var yIdx = visibleDepths.indexOf(depthIdx);
|
|
3017
|
+
if (yIdx === -1) {
|
|
3018
|
+
el.attr('display', 'none');
|
|
3019
|
+
} else {
|
|
3020
|
+
el.attr('display', null)
|
|
3021
|
+
.transition().duration(300)
|
|
3022
|
+
.attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
// Close detail panel if pinned node became hidden
|
|
3027
|
+
if (hierPinned && !isVisible(hierPinned)) {
|
|
3028
|
+
closeHierDetail();
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function hierSyncFromTabInner() {
|
|
3033
|
+
if (!LAYERS) return;
|
|
3034
|
+
hActiveLayers.clear();
|
|
3035
|
+
activeLayers.forEach(function(name) { hActiveLayers.add(name); });
|
|
3036
|
+
// Sync pill UI
|
|
3037
|
+
hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
|
|
3038
|
+
var ln = p.dataset.layer;
|
|
3039
|
+
if (ln === 'all') {
|
|
3040
|
+
p.classList.toggle('active', hActiveLayers.size === 0);
|
|
3041
|
+
} else {
|
|
3042
|
+
p.classList.toggle('active', hActiveLayers.has(ln));
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
if (LAYERS) {
|
|
3048
|
+
// "All" button
|
|
3049
|
+
const allPill=document.createElement('div');
|
|
3050
|
+
allPill.className='layer-pill active';
|
|
3051
|
+
allPill.style.fontWeight='400';
|
|
3052
|
+
allPill.textContent='All';
|
|
3053
|
+
allPill.dataset.layer='all';
|
|
3054
|
+
allPill.onclick=()=>{
|
|
3055
|
+
hActiveLayers.clear();
|
|
3056
|
+
hFilterRow.querySelectorAll('.layer-pill').forEach(p=>p.classList.remove('active'));
|
|
3057
|
+
allPill.classList.add('active');
|
|
3058
|
+
hierRelayoutInner();
|
|
3059
|
+
};
|
|
3060
|
+
hFilterRow.appendChild(allPill);
|
|
3061
|
+
|
|
3062
|
+
LAYERS.forEach(layer => {
|
|
3063
|
+
const pill=document.createElement('div');
|
|
3064
|
+
pill.className='layer-pill';
|
|
3065
|
+
pill.dataset.layer=layer.name;
|
|
3066
|
+
const count=DATA.nodes.filter(n=>n.layer===layer.name).length;
|
|
3067
|
+
pill.innerHTML='<div class="lp-dot" style="background:'+esc(layer.color)+'"></div>'+esc(layer.name)+' <span class="lp-count">'+count+'</span>';
|
|
3068
|
+
pill.onclick=(e)=>{
|
|
3069
|
+
if (e.shiftKey) {
|
|
3070
|
+
hActiveLayers.clear();
|
|
3071
|
+
hActiveLayers.add(layer.name);
|
|
3072
|
+
} else {
|
|
3073
|
+
if (hActiveLayers.has(layer.name)) hActiveLayers.delete(layer.name);
|
|
3074
|
+
else hActiveLayers.add(layer.name);
|
|
3075
|
+
}
|
|
3076
|
+
// Sync pill UI
|
|
3077
|
+
hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
|
|
3078
|
+
var ln = p.dataset.layer;
|
|
3079
|
+
if (ln === 'all') p.classList.toggle('active', hActiveLayers.size === 0);
|
|
3080
|
+
else p.classList.toggle('active', hActiveLayers.has(ln));
|
|
3081
|
+
});
|
|
3082
|
+
hierRelayoutInner();
|
|
3083
|
+
};
|
|
3084
|
+
hFilterRow.appendChild(pill);
|
|
3085
|
+
});
|
|
3086
|
+
} else {
|
|
3087
|
+
const hActiveDirs=new Set(DATA.dirs);
|
|
3088
|
+
DATA.dirs.forEach(dir=>{
|
|
3089
|
+
const pill=document.createElement('div');
|
|
3090
|
+
pill.className='filter-pill active';
|
|
3091
|
+
pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
|
|
3092
|
+
pill.onclick=()=>{
|
|
3093
|
+
if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
|
|
3094
|
+
else{hActiveDirs.add(dir);pill.classList.add('active');}
|
|
3095
|
+
nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
|
|
3096
|
+
};
|
|
3097
|
+
hFilterRow.appendChild(pill);
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// Assign function pointers for cross-view sync
|
|
3102
|
+
hierRelayout = hierRelayoutInner;
|
|
3103
|
+
hierSyncFromTab = hierSyncFromTabInner;
|
|
3104
|
+
|
|
3105
|
+
hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
|
|
3106
|
+
Math.max(0,(W-totalW)/2),20
|
|
3107
|
+
).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
|
|
3108
|
+
|
|
3109
|
+
// If layers were already filtered in graph view, sync hierarchy on first build
|
|
3110
|
+
if (activeLayers.size > 0) {
|
|
3111
|
+
hierSyncFromTabInner();
|
|
3112
|
+
hierRelayoutInner();
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
`;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// src/web/js-diff.ts
|
|
3119
|
+
function buildDiffJs(diffData) {
|
|
3120
|
+
return `
|
|
3121
|
+
// \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
|
|
3122
|
+
// DIFF VIEW
|
|
3123
|
+
// \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
|
|
3124
|
+
const DIFF = ${diffData};
|
|
3125
|
+
if (DIFF) {
|
|
3126
|
+
document.getElementById('diff-tab').style.display = '';
|
|
3127
|
+
const addedSet = new Set(DIFF.added||[]);
|
|
3128
|
+
const removedSet = new Set(DIFF.removed||[]);
|
|
3129
|
+
const modifiedSet = new Set(DIFF.modified||[]);
|
|
3130
|
+
const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
|
|
3131
|
+
|
|
3132
|
+
// Populate summary counts
|
|
3133
|
+
document.getElementById('diff-added-count').textContent = addedSet.size;
|
|
3134
|
+
document.getElementById('diff-removed-count').textContent = removedSet.size;
|
|
3135
|
+
document.getElementById('diff-modified-count').textContent = modifiedSet.size;
|
|
3136
|
+
document.getElementById('diff-affected-count').textContent = affectedSet.size;
|
|
3137
|
+
|
|
3138
|
+
function isDiffNode(id) {
|
|
3139
|
+
return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
function diffStatus(id) {
|
|
3143
|
+
if (addedSet.has(id)) return 'Added';
|
|
3144
|
+
if (removedSet.has(id)) return 'Removed';
|
|
3145
|
+
if (modifiedSet.has(id)) return 'Modified';
|
|
3146
|
+
if (affectedSet.has(id)) return 'Affected';
|
|
3147
|
+
return 'Unchanged';
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function diffStatusColor(id) {
|
|
3151
|
+
if (addedSet.has(id)) return 'var(--green)';
|
|
3152
|
+
if (removedSet.has(id)) return 'var(--red)';
|
|
3153
|
+
if (modifiedSet.has(id)) return 'var(--yellow)';
|
|
3154
|
+
if (affectedSet.has(id)) return 'var(--accent)';
|
|
3155
|
+
return 'var(--text-muted)';
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// Build reverse dependency map for impact chain
|
|
3159
|
+
var diffRevMap = {};
|
|
3160
|
+
DATA.links.forEach(function(l) {
|
|
3161
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3162
|
+
if (!diffRevMap[t]) diffRevMap[t] = [];
|
|
3163
|
+
diffRevMap[t].push(s);
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
function getImpactChain(startId) {
|
|
3167
|
+
var result = new Set();
|
|
3168
|
+
var queue = [startId];
|
|
3169
|
+
while (queue.length) {
|
|
3170
|
+
var id = queue.shift();
|
|
3171
|
+
if (result.has(id)) continue;
|
|
3172
|
+
result.add(id);
|
|
3173
|
+
(diffRevMap[id] || []).forEach(function(x) { queue.push(x); });
|
|
3174
|
+
}
|
|
3175
|
+
return result;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
let diffFocusMode = false;
|
|
3179
|
+
var dNode, dLink, dSim, simNodes, simLinks;
|
|
3180
|
+
|
|
3181
|
+
window.toggleDiffFocus = function() {
|
|
3182
|
+
diffFocusMode = !diffFocusMode;
|
|
3183
|
+
var btn = document.getElementById('diff-focus-btn');
|
|
3184
|
+
btn.classList.toggle('active', diffFocusMode);
|
|
3185
|
+
btn.textContent = diffFocusMode ? i('diff.showAll') : i('diff.focusChanges');
|
|
3186
|
+
if (!diffBuilt) return;
|
|
3187
|
+
applyDiffFilter();
|
|
3188
|
+
};
|
|
3189
|
+
|
|
3190
|
+
window.closeDiffDetail = function() {
|
|
3191
|
+
document.getElementById('diff-detail').style.display = 'none';
|
|
3192
|
+
if (diffBuilt) resetDiffHighlight();
|
|
3193
|
+
};
|
|
3194
|
+
|
|
3195
|
+
function applyDiffFilter() {
|
|
3196
|
+
dNode.attr('display', function(d) {
|
|
3197
|
+
if (!diffFocusMode) return null;
|
|
3198
|
+
return isDiffNode(d.id) ? null : 'none';
|
|
3199
|
+
});
|
|
3200
|
+
dNode.select('circle')
|
|
3201
|
+
.attr('opacity', function(d) {
|
|
3202
|
+
if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
|
|
3203
|
+
return isDiffNode(d.id) ? 1 : 0.12;
|
|
3204
|
+
});
|
|
3205
|
+
dNode.select('text')
|
|
3206
|
+
.attr('opacity', function(d) {
|
|
3207
|
+
if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
|
|
3208
|
+
return isDiffNode(d.id) ? 1 : 0.08;
|
|
3209
|
+
});
|
|
3210
|
+
dLink.attr('display', function(l) {
|
|
3211
|
+
if (!diffFocusMode) return null;
|
|
3212
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3213
|
+
return (isDiffNode(s) && isDiffNode(t)) ? null : 'none';
|
|
3214
|
+
});
|
|
3215
|
+
dLink.attr('opacity', function(l) {
|
|
3216
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3217
|
+
if (isDiffNode(s) && isDiffNode(t)) return 0.6;
|
|
3218
|
+
if (isDiffNode(s) || isDiffNode(t)) return 0.15;
|
|
3219
|
+
return diffFocusMode ? 0 : 0.05;
|
|
3220
|
+
});
|
|
3221
|
+
dLink.attr('stroke', function(l) {
|
|
3222
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3223
|
+
if (isDiffNode(s) && isDiffNode(t)) return diffStatusColor(s);
|
|
3224
|
+
return '#30363d';
|
|
3225
|
+
});
|
|
3226
|
+
dNode.select('circle')
|
|
3227
|
+
.attr('stroke-width', function(d) { return isDiffNode(d.id) ? 3 : 1; });
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
function resetDiffHighlight() {
|
|
3231
|
+
applyDiffFilter();
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
function highlightDiffImpact(d) {
|
|
3235
|
+
var chain = getImpactChain(d.id);
|
|
3236
|
+
dNode.select('circle').transition().duration(200)
|
|
3237
|
+
.attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.04; })
|
|
3238
|
+
.attr('stroke-width', function(n) { return chain.has(n.id) && n.id !== d.id ? 3 : isDiffNode(n.id) ? 3 : 1; })
|
|
3239
|
+
.attr('stroke', function(n) { return chain.has(n.id) && n.id !== d.id ? 'var(--red)' : diffStatusColor(n.id); });
|
|
3240
|
+
dNode.select('text').transition().duration(200)
|
|
3241
|
+
.attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.03; });
|
|
3242
|
+
dLink.transition().duration(200)
|
|
3243
|
+
.attr('opacity', function(l) {
|
|
3244
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3245
|
+
return (chain.has(s) && chain.has(t)) ? 0.8 : 0.03;
|
|
3246
|
+
})
|
|
3247
|
+
.attr('stroke', function(l) {
|
|
3248
|
+
var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3249
|
+
return (chain.has(s) && chain.has(t)) ? 'var(--red)' : '#30363d';
|
|
3250
|
+
});
|
|
3251
|
+
return chain;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function showDiffDetail(d) {
|
|
3255
|
+
var panel = document.getElementById('diff-detail');
|
|
3256
|
+
document.getElementById('dd-name').textContent = d.id;
|
|
3257
|
+
var statusEl = document.getElementById('dd-status');
|
|
3258
|
+
statusEl.textContent = diffStatus(d.id);
|
|
3259
|
+
statusEl.style.color = diffStatusColor(d.id);
|
|
3260
|
+
document.getElementById('dd-meta').innerHTML = i('detail.dir') + ': ' + esc(d.dir) + '<br>' + i('detail.dependencies') + ': ' + d.deps + ' \\u00b7 ' + i('detail.dependents') + ': ' + d.dependents;
|
|
3261
|
+
|
|
3262
|
+
// Show impact chain
|
|
3263
|
+
var chain = getImpactChain(d.id);
|
|
3264
|
+
chain.delete(d.id);
|
|
3265
|
+
var affectedList = document.getElementById('dd-affected');
|
|
3266
|
+
if (chain.size > 0) {
|
|
3267
|
+
affectedList.innerHTML = Array.from(chain).map(function(id) {
|
|
3268
|
+
return '<li style="color:' + diffStatusColor(id) + '">\\u2190 ' + esc(id) + ' <span style="font-size:10px;color:var(--text-muted)">(' + diffStatus(id) + ')</span></li>';
|
|
3269
|
+
}).join('');
|
|
3270
|
+
} else {
|
|
3271
|
+
affectedList.innerHTML = '<li style="color:var(--text-muted)">' + i('diff.noImpact') + '</li>';
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
// Show imports
|
|
3275
|
+
var depsList = document.getElementById('dd-deps');
|
|
3276
|
+
depsList.innerHTML = (d.dependencies || []).map(function(x) {
|
|
3277
|
+
return '<li style="color:' + diffStatusColor(x) + '">\\u2192 ' + esc(x) + '</li>';
|
|
3278
|
+
}).join('') || '<li style="color:var(--text-muted)">' + i('detail.none') + '</li>';
|
|
3279
|
+
|
|
3280
|
+
panel.style.display = 'block';
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
function buildDiffView() {
|
|
3284
|
+
const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
|
|
3285
|
+
const dG = dSvg.append('g');
|
|
3286
|
+
const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
|
|
3287
|
+
dSvg.call(dZoom);
|
|
3288
|
+
|
|
3289
|
+
function diffColor(d) {
|
|
3290
|
+
if (addedSet.has(d.id)) return 'var(--green)';
|
|
3291
|
+
if (removedSet.has(d.id)) return 'var(--red)';
|
|
3292
|
+
if (modifiedSet.has(d.id)) return 'var(--yellow)';
|
|
3293
|
+
if (affectedSet.has(d.id)) return 'var(--accent)';
|
|
3294
|
+
return '#30363d';
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
const dDefs = dSvg.append('defs');
|
|
3298
|
+
dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
|
|
3299
|
+
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
3300
|
+
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
|
|
3301
|
+
// Colored arrow markers for diff edges
|
|
3302
|
+
[['var(--green)','darrow-g'],['var(--red)','darrow-r'],['var(--yellow)','darrow-y'],['var(--accent)','darrow-a']].forEach(function(pair) {
|
|
3303
|
+
dDefs.append('marker').attr('id',pair[1]).attr('viewBox','0 -4 8 8')
|
|
3304
|
+
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
3305
|
+
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',pair[0]);
|
|
3306
|
+
});
|
|
3307
|
+
|
|
3308
|
+
simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
|
|
3309
|
+
simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
|
|
3310
|
+
|
|
3311
|
+
dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
|
|
3312
|
+
.attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.05);
|
|
3313
|
+
|
|
3314
|
+
dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
|
|
3315
|
+
dNode.append('circle')
|
|
3316
|
+
.attr('r', d=>nodeRadius(d)*nodeScale)
|
|
3317
|
+
.attr('fill', diffColor)
|
|
3318
|
+
.attr('stroke', diffColor).attr('stroke-width', d=>isDiffNode(d.id)?3:1)
|
|
3319
|
+
.attr('opacity', d=>isDiffNode(d.id)?1:0.12);
|
|
3320
|
+
dNode.append('text')
|
|
3321
|
+
.text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
|
|
3322
|
+
.attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
|
|
3323
|
+
.attr('fill', d=>isDiffNode(d.id)?'var(--text)':'var(--text-muted)')
|
|
3324
|
+
.attr('opacity', d=>isDiffNode(d.id)?1:0.08)
|
|
3325
|
+
.attr('pointer-events','none');
|
|
3326
|
+
|
|
3327
|
+
dSim = d3.forceSimulation(simNodes)
|
|
3328
|
+
.force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
|
|
3329
|
+
.force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
|
|
3330
|
+
.force('center', d3.forceCenter(0,0))
|
|
3331
|
+
.force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4));
|
|
3332
|
+
|
|
3333
|
+
// Layer-aware physics for diff view (same pattern as graph view)
|
|
3334
|
+
var dHullGroup = null;
|
|
3335
|
+
if (LAYERS && LAYERS.length > 0) {
|
|
3336
|
+
var dLayerCenters = {};
|
|
3337
|
+
var dLayerCount = LAYERS.length;
|
|
3338
|
+
var dBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(dLayerCount));
|
|
3339
|
+
LAYERS.forEach(function(l, idx) {
|
|
3340
|
+
var angle = (2 * Math.PI * idx) / dLayerCount - Math.PI / 2;
|
|
3341
|
+
dLayerCenters[l.name] = { x: Math.cos(angle) * dBaseRadius, y: Math.sin(angle) * dBaseRadius };
|
|
3342
|
+
});
|
|
3343
|
+
dSim.force('center', null);
|
|
3344
|
+
dSim.force('layerX', d3.forceX(function(d) { return dLayerCenters[d.layer]?.x || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
|
|
3345
|
+
dSim.force('layerY', d3.forceY(function(d) { return dLayerCenters[d.layer]?.y || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
|
|
3346
|
+
dSim.force('link').strength(function(l) {
|
|
3347
|
+
var sL = l.source.layer ?? l.source, tL = l.target.layer ?? l.target;
|
|
3348
|
+
return sL === tL ? 0.4 : 0.1;
|
|
3349
|
+
});
|
|
3350
|
+
// Cluster force for diff view
|
|
3351
|
+
dSim.force('cluster', (function() {
|
|
3352
|
+
var ns;
|
|
3353
|
+
function f(alpha) {
|
|
3354
|
+
var centroids = {}, counts = {};
|
|
3355
|
+
ns.forEach(function(n) {
|
|
3356
|
+
if (!n.layer) return;
|
|
3357
|
+
if (!centroids[n.layer]) { centroids[n.layer] = {x:0,y:0}; counts[n.layer] = 0; }
|
|
3358
|
+
centroids[n.layer].x += n.x; centroids[n.layer].y += n.y; counts[n.layer]++;
|
|
3359
|
+
});
|
|
3360
|
+
Object.keys(centroids).forEach(function(k) { centroids[k].x /= counts[k]; centroids[k].y /= counts[k]; });
|
|
3361
|
+
ns.forEach(function(n) {
|
|
3362
|
+
if (!n.layer || !centroids[n.layer]) return;
|
|
3363
|
+
n.vx += (centroids[n.layer].x - n.x) * alpha * 0.2;
|
|
3364
|
+
n.vy += (centroids[n.layer].y - n.y) * alpha * 0.2;
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
f.initialize = function(n) { ns = n; };
|
|
3368
|
+
return f;
|
|
3369
|
+
})());
|
|
3370
|
+
|
|
3371
|
+
dHullGroup = dG.insert('g', ':first-child');
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
function updateDiffHulls() {
|
|
3375
|
+
if (!dHullGroup) return;
|
|
3376
|
+
dHullGroup.selectAll('*').remove();
|
|
3377
|
+
LAYERS.forEach(function(layer) {
|
|
3378
|
+
var layerNodes = simNodes.filter(function(n) { return n.layer === layer.name; });
|
|
3379
|
+
if (layerNodes.length === 0) return;
|
|
3380
|
+
if (diffFocusMode && !layerNodes.some(function(n) { return isDiffNode(n.id); })) return;
|
|
3381
|
+
var hasDiff = layerNodes.some(function(n) { return isDiffNode(n.id); });
|
|
3382
|
+
|
|
3383
|
+
var points = [];
|
|
3384
|
+
layerNodes.forEach(function(n) {
|
|
3385
|
+
if (n.x == null || n.y == null) return;
|
|
3386
|
+
if (diffFocusMode && !isDiffNode(n.id)) return;
|
|
3387
|
+
var r = nodeRadius(n) * nodeScale + 30;
|
|
3388
|
+
for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
3389
|
+
points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
|
|
3390
|
+
}
|
|
3391
|
+
});
|
|
3392
|
+
|
|
3393
|
+
var fillOp = hasDiff ? 0.15 : 0.06;
|
|
3394
|
+
var strokeOp = hasDiff ? 0.6 : 0.2;
|
|
3395
|
+
var sw = hasDiff ? 2.5 : 1;
|
|
3396
|
+
if (points.length < 6) {
|
|
3397
|
+
var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
|
|
3398
|
+
var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
|
|
3399
|
+
dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
|
|
3400
|
+
.attr('fill', layer.color).attr('fill-opacity', fillOp)
|
|
3401
|
+
.attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
|
|
3402
|
+
} else {
|
|
3403
|
+
var hull = d3.polygonHull(points);
|
|
3404
|
+
if (hull) {
|
|
3405
|
+
dHullGroup.append('path')
|
|
3406
|
+
.attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
|
|
3407
|
+
.attr('fill', layer.color).attr('fill-opacity', fillOp)
|
|
3408
|
+
.attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
|
|
3409
|
+
.attr('stroke-dasharray', hasDiff ? null : '6,3');
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
// Layer name label
|
|
3413
|
+
var visNodes = diffFocusMode ? layerNodes.filter(function(n) { return isDiffNode(n.id); }) : layerNodes;
|
|
3414
|
+
if (visNodes.length === 0) return;
|
|
3415
|
+
var lx = visNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / visNodes.length;
|
|
3416
|
+
var ly = Math.min.apply(null, visNodes.map(function(n) { return n.y||0; })) - 25;
|
|
3417
|
+
dHullGroup.append('text')
|
|
3418
|
+
.attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
|
|
3419
|
+
.attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
|
|
3420
|
+
.attr('font-size', 12).attr('font-weight', 600).text(layer.name);
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
var dTickCount = 0;
|
|
3425
|
+
dSim.on('tick', function() {
|
|
3426
|
+
dLink.each(function(d) {
|
|
3427
|
+
var dx=d.target.x-d.source.x, dy=d.target.y-d.source.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
3428
|
+
var rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
|
|
3429
|
+
d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
3430
|
+
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
3431
|
+
});
|
|
3432
|
+
dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
|
|
3433
|
+
if (++dTickCount % 5 === 0) updateDiffHulls();
|
|
3434
|
+
});
|
|
3435
|
+
|
|
3436
|
+
// Click: show impact chain + detail panel
|
|
3437
|
+
dNode.on('click', function(e, d) {
|
|
3438
|
+
e.stopPropagation();
|
|
3439
|
+
highlightDiffImpact(d);
|
|
3440
|
+
showDiffDetail(d);
|
|
3441
|
+
});
|
|
3442
|
+
|
|
3443
|
+
// Click on empty space to deselect
|
|
3444
|
+
dSvg.on('click', function() {
|
|
3445
|
+
closeDiffDetail();
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
|
|
3449
|
+
|
|
3450
|
+
// Apply initial filter (in case focus was toggled before build)
|
|
3451
|
+
applyDiffFilter();
|
|
3452
|
+
|
|
3453
|
+
var dAutoFitDone = false;
|
|
3454
|
+
dSim.on('end', function() {
|
|
3455
|
+
if (dAutoFitDone) return;
|
|
3456
|
+
dAutoFitDone = true;
|
|
3457
|
+
var b=dG.node().getBBox(); if(!b.width) return;
|
|
3458
|
+
var s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
|
|
3459
|
+
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));
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
}
|
|
3464
|
+
`;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// src/utils/html-escape.ts
|
|
3468
|
+
var ESC_FUNCTION_JS = `function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }`;
|
|
3469
|
+
|
|
3470
|
+
// src/web/template.ts
|
|
3471
|
+
function buildGraphPage(graph, options = {}) {
|
|
3472
|
+
const locale = options.locale ?? "en";
|
|
3473
|
+
const diff = options.diff ?? null;
|
|
3474
|
+
const layers = options.layerMetadata ?? null;
|
|
3475
|
+
const crossEdges = options.crossLayerEdges ?? null;
|
|
3476
|
+
const files = Object.values(graph.files);
|
|
3477
|
+
const nodes = files.map((f) => ({
|
|
3478
|
+
id: f.path,
|
|
3479
|
+
deps: f.dependencies.length,
|
|
3480
|
+
dependents: f.dependents.length,
|
|
3481
|
+
dependencies: f.dependencies,
|
|
3482
|
+
dependentsList: f.dependents,
|
|
3483
|
+
isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
|
|
3484
|
+
dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".",
|
|
3485
|
+
layer: layers && f.path.includes("/") ? f.path.substring(0, f.path.indexOf("/")) : null
|
|
3486
|
+
}));
|
|
3487
|
+
const links = graph.edges.map((e) => ({
|
|
3488
|
+
source: e.source,
|
|
3489
|
+
target: e.target,
|
|
3490
|
+
type: e.type
|
|
3491
|
+
}));
|
|
3492
|
+
const circularFiles = /* @__PURE__ */ new Set();
|
|
3493
|
+
for (const c of graph.circularDependencies) {
|
|
3494
|
+
for (const f of c.cycle) circularFiles.add(f);
|
|
3495
|
+
}
|
|
3496
|
+
const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
|
|
3497
|
+
const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
|
|
3498
|
+
const diffData = diff ? JSON.stringify(diff) : "null";
|
|
3499
|
+
const layersData = layers ? JSON.stringify(layers) : "null";
|
|
3500
|
+
const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
|
|
3501
|
+
const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
|
|
3502
|
+
return (
|
|
3503
|
+
/* html */
|
|
3504
|
+
`<!DOCTYPE html>
|
|
3505
|
+
<html lang="${locale}">
|
|
3506
|
+
<head>
|
|
3507
|
+
<meta charset="utf-8">
|
|
3508
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3509
|
+
<title>${projectName} \u2014 Architecture Viewer</title>
|
|
3510
|
+
${buildStyles()}
|
|
3511
|
+
</head>
|
|
3512
|
+
<body>
|
|
3513
|
+
${buildViewerHtml()}
|
|
3514
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
3515
|
+
<script>
|
|
3516
|
+
// \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
|
|
3517
|
+
// i18n
|
|
3518
|
+
// \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
|
|
3519
|
+
const I18N = {
|
|
3520
|
+
en: {
|
|
3521
|
+
'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
|
|
3522
|
+
'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
|
|
3523
|
+
'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
|
|
3524
|
+
'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
|
|
3525
|
+
'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
|
|
3526
|
+
'search.placeholder': 'Search files...',
|
|
3527
|
+
'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
|
|
3528
|
+
'legend.imports': 'imports', 'legend.importedBy': 'imported by',
|
|
3529
|
+
'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
|
|
3530
|
+
'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
|
|
3531
|
+
'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
|
|
3532
|
+
'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
|
|
3533
|
+
'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
|
|
3534
|
+
'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
|
|
3535
|
+
'tab.diff': 'Diff',
|
|
3536
|
+
'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
|
|
3537
|
+
'diff.showAll': 'Show all', 'diff.focusChanges': 'Focus changes', 'diff.noImpact': 'No downstream impact',
|
|
3538
|
+
'diff.affectedByChange': 'Affected by this change',
|
|
3539
|
+
},
|
|
3540
|
+
ja: {
|
|
3541
|
+
'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
|
|
3542
|
+
'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
|
|
3543
|
+
'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
|
|
3544
|
+
'settings.nodeSize': '\u30CE\u30FC\u30C9\u30B5\u30A4\u30BA', 'settings.linkOpacity': '\u30EA\u30F3\u30AF\u900F\u660E\u5EA6', 'settings.gravity': '\u91CD\u529B', 'settings.language': '\u8A00\u8A9E', 'settings.export': '\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8',
|
|
3545
|
+
'impact.title': '\u5F71\u97FF\u7BC4\u56F2\u30B7\u30DF\u30E5\u30EC\u30FC\u30B7\u30E7\u30F3', 'impact.btn': '\u5F71\u97FF', 'impact.transitive': '\u30D5\u30A1\u30A4\u30EB\u306B\u5F71\u97FF',
|
|
3546
|
+
'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
|
|
3547
|
+
'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
|
|
3548
|
+
'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
|
|
3549
|
+
'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
|
|
3550
|
+
'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
|
|
3551
|
+
'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
|
|
3552
|
+
'help.graph': '\u30B9\u30AF\u30ED\u30FC\u30EB: \u30BA\u30FC\u30E0 \xB7 \u30C9\u30E9\u30C3\u30B0: \u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF: \u9078\u629E \xB7 / \u691C\u7D22',
|
|
3553
|
+
'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
|
|
3554
|
+
'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
|
|
3555
|
+
'tab.diff': '\u5DEE\u5206',
|
|
3556
|
+
'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
|
|
3557
|
+
'diff.showAll': '\u5168\u8868\u793A', 'diff.focusChanges': '\u5909\u66F4\u306E\u307F\u8868\u793A', 'diff.noImpact': '\u4E0B\u6D41\u3078\u306E\u5F71\u97FF\u306A\u3057',
|
|
3558
|
+
'diff.affectedByChange': '\u3053\u306E\u5909\u66F4\u306E\u5F71\u97FF\u7BC4\u56F2',
|
|
3559
|
+
}
|
|
3560
|
+
};
|
|
3561
|
+
let currentLang = '${locale}';
|
|
3562
|
+
function applyI18n() {
|
|
3563
|
+
const msgs = I18N[currentLang] || I18N.en;
|
|
3564
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
3565
|
+
const key = el.getAttribute('data-i18n');
|
|
3566
|
+
if (msgs[key]) el.textContent = msgs[key];
|
|
3567
|
+
});
|
|
3568
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
3569
|
+
const key = el.getAttribute('data-i18n-placeholder');
|
|
3570
|
+
if (msgs[key]) el.placeholder = msgs[key];
|
|
3571
|
+
});
|
|
3572
|
+
document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
|
|
3573
|
+
}
|
|
3574
|
+
window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
|
|
3575
|
+
function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
|
|
3576
|
+
${ESC_FUNCTION_JS}
|
|
3577
|
+
|
|
3578
|
+
// \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
|
|
3579
|
+
// SETTINGS (persisted to localStorage)
|
|
3580
|
+
// \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
|
|
3581
|
+
const STORAGE_KEY = 'archtracker-settings';
|
|
3582
|
+
function saveSettings() {
|
|
3583
|
+
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 };
|
|
3584
|
+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
|
|
3585
|
+
}
|
|
3586
|
+
function loadSettings() {
|
|
3587
|
+
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; } catch(e) { return null; }
|
|
3588
|
+
}
|
|
3589
|
+
|
|
2273
3590
|
let nodeScale = 1, baseLinkOpacity = 0.4;
|
|
2274
3591
|
window.toggleSettings = () => document.getElementById('settings-panel').classList.toggle('open');
|
|
2275
3592
|
window.setTheme = (theme) => {
|
|
@@ -2307,7 +3624,21 @@ window.setGravity = (v) => {
|
|
|
2307
3624
|
gravityStrength = +v;
|
|
2308
3625
|
document.getElementById('gravity-val').textContent = v;
|
|
2309
3626
|
if (typeof simulation !== 'undefined') {
|
|
2310
|
-
|
|
3627
|
+
if (typeof updateLayerPhysics === 'function') {
|
|
3628
|
+
updateLayerPhysics();
|
|
3629
|
+
} else {
|
|
3630
|
+
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
|
|
3631
|
+
}
|
|
3632
|
+
simulation.alpha(0.5).restart();
|
|
3633
|
+
}
|
|
3634
|
+
saveSettings();
|
|
3635
|
+
};
|
|
3636
|
+
let layerGravity = 12;
|
|
3637
|
+
window.setLayerGravity = (v) => {
|
|
3638
|
+
layerGravity = +v;
|
|
3639
|
+
document.getElementById('layer-gravity-val').textContent = v;
|
|
3640
|
+
if (typeof simulation !== 'undefined' && typeof updateLayerPhysics === 'function') {
|
|
3641
|
+
updateLayerPhysics();
|
|
2311
3642
|
simulation.alpha(0.5).restart();
|
|
2312
3643
|
}
|
|
2313
3644
|
saveSettings();
|
|
@@ -2347,6 +3678,8 @@ window.exportPNG = () => {
|
|
|
2347
3678
|
// DATA
|
|
2348
3679
|
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2349
3680
|
const DATA = ${graphData};
|
|
3681
|
+
const LAYERS = ${layersData};
|
|
3682
|
+
const CROSS_EDGES = ${crossEdgesData};
|
|
2350
3683
|
const W = window.innerWidth, H = window.innerHeight - 44;
|
|
2351
3684
|
const circularSet = new Set(DATA.circularFiles);
|
|
2352
3685
|
|
|
@@ -2367,6 +3700,7 @@ if (_savedSettings) {
|
|
|
2367
3700
|
if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
|
|
2368
3701
|
if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
|
|
2369
3702
|
if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
|
|
3703
|
+
if (_savedSettings.layerGravity) { document.getElementById('layer-gravity-slider').value = _savedSettings.layerGravity; document.getElementById('layer-gravity-val').textContent = _savedSettings.layerGravity; layerGravity = +_savedSettings.layerGravity; }
|
|
2370
3704
|
}
|
|
2371
3705
|
|
|
2372
3706
|
document.getElementById('s-files').textContent = DATA.nodes.length;
|
|
@@ -2376,9 +3710,21 @@ document.getElementById('s-circular').textContent = DATA.circularFiles.length;
|
|
|
2376
3710
|
const dirColor = d3.scaleOrdinal()
|
|
2377
3711
|
.domain(DATA.dirs)
|
|
2378
3712
|
.range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
|
|
3713
|
+
|
|
3714
|
+
// Layer color map (from LAYERS metadata)
|
|
3715
|
+
const layerColorMap = {};
|
|
3716
|
+
let activeLayerFilter = null; // DEPRECATED \u2014 kept for backward compat, always null with multi-select tabs
|
|
3717
|
+
const activeLayers = new Set(); // empty = no filter (show all); non-empty = show only selected
|
|
3718
|
+
if (LAYERS) {
|
|
3719
|
+
LAYERS.forEach(l => { layerColorMap[l.name] = l.color; });
|
|
3720
|
+
document.getElementById('layer-gravity-setting').style.display = '';
|
|
3721
|
+
}
|
|
3722
|
+
|
|
2379
3723
|
function nodeColor(d) {
|
|
2380
3724
|
if (circularSet.has(d.id)) return '#f97583';
|
|
2381
3725
|
if (d.isOrphan) return '#484f58';
|
|
3726
|
+
// Layer coloring: all-visible or multi-select \u2192 layer colors; single-select \u2192 dir colors
|
|
3727
|
+
if (LAYERS && d.layer && layerColorMap[d.layer] && activeLayers.size !== 1) return layerColorMap[d.layer];
|
|
2382
3728
|
return dirColor(d.dir);
|
|
2383
3729
|
}
|
|
2384
3730
|
function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
|
|
@@ -2388,13 +3734,22 @@ function fileName(id) { return id.split('/').pop(); }
|
|
|
2388
3734
|
// TAB SWITCHING
|
|
2389
3735
|
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2390
3736
|
let hierBuilt = false;
|
|
3737
|
+
let diffBuilt = false;
|
|
3738
|
+
let hierRelayout = null;
|
|
3739
|
+
let hierSyncFromTab = null;
|
|
2391
3740
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
2392
3741
|
tab.addEventListener('click', () => {
|
|
2393
3742
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
2394
3743
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
2395
3744
|
tab.classList.add('active');
|
|
2396
3745
|
document.getElementById(tab.dataset.view).classList.add('active');
|
|
2397
|
-
if (tab.dataset.view === 'hier-view'
|
|
3746
|
+
if (tab.dataset.view === 'hier-view') {
|
|
3747
|
+
if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
|
|
3748
|
+
if (hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
|
|
3749
|
+
}
|
|
3750
|
+
if (tab.dataset.view === 'diff-view') {
|
|
3751
|
+
if (!diffBuilt) { buildDiffView(); diffBuilt = true; }
|
|
3752
|
+
}
|
|
2398
3753
|
});
|
|
2399
3754
|
});
|
|
2400
3755
|
|
|
@@ -2410,8 +3765,8 @@ function showTooltip(e, d) {
|
|
|
2410
3765
|
document.getElementById('tt-name').textContent = d.id;
|
|
2411
3766
|
document.getElementById('tt-dep-count').textContent = d.deps;
|
|
2412
3767
|
document.getElementById('tt-dpt-count').textContent = d.dependents;
|
|
2413
|
-
const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+x+'</div>');
|
|
2414
|
-
const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+x+'</div>');
|
|
3768
|
+
const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+esc(x)+'</div>');
|
|
3769
|
+
const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+esc(x)+'</div>');
|
|
2415
3770
|
document.getElementById('tt-details').innerHTML = [...out, ...inc].join('');
|
|
2416
3771
|
tooltip.style.display = 'block';
|
|
2417
3772
|
positionTooltip(e);
|
|
@@ -2485,6 +3840,35 @@ const link = g.append('g').selectAll('line').data(DATA.links).join('line')
|
|
|
2485
3840
|
.attr('marker-end','url(#arrow-0)')
|
|
2486
3841
|
.attr('opacity', baseLinkOpacity);
|
|
2487
3842
|
|
|
3843
|
+
// Cross-layer links (from layers.json connections)
|
|
3844
|
+
defs.append('marker').attr('id','arrow-cross').attr('viewBox','0 -4 8 8')
|
|
3845
|
+
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
3846
|
+
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#f0883e');
|
|
3847
|
+
|
|
3848
|
+
const crossLinkData = (CROSS_EDGES || []).map(e => ({
|
|
3849
|
+
source: e.fromLayer + '/' + e.fromFile,
|
|
3850
|
+
target: e.toLayer + '/' + e.toFile,
|
|
3851
|
+
sourceLayer: e.fromLayer,
|
|
3852
|
+
targetLayer: e.toLayer,
|
|
3853
|
+
type: e.type || 'api-call',
|
|
3854
|
+
label: e.label || e.type || '',
|
|
3855
|
+
})).filter(e => DATA.nodes.some(n => n.id === e.source) && DATA.nodes.some(n => n.id === e.target));
|
|
3856
|
+
|
|
3857
|
+
const crossLinkG = g.append('g');
|
|
3858
|
+
const crossLink = crossLinkG.selectAll('line').data(crossLinkData).join('line')
|
|
3859
|
+
.attr('stroke', '#f0883e')
|
|
3860
|
+
.attr('stroke-width', 2)
|
|
3861
|
+
.attr('stroke-dasharray', '8,4')
|
|
3862
|
+
.attr('marker-end', 'url(#arrow-cross)')
|
|
3863
|
+
.attr('opacity', 0.7);
|
|
3864
|
+
const crossLabel = crossLinkG.selectAll('text').data(crossLinkData).join('text')
|
|
3865
|
+
.text(d => d.label)
|
|
3866
|
+
.attr('font-size', 9)
|
|
3867
|
+
.attr('fill', '#f0883e')
|
|
3868
|
+
.attr('text-anchor', 'middle')
|
|
3869
|
+
.attr('opacity', 0.8)
|
|
3870
|
+
.attr('pointer-events', 'none');
|
|
3871
|
+
|
|
2488
3872
|
// Nodes
|
|
2489
3873
|
const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
|
|
2490
3874
|
.attr('cursor','pointer')
|
|
@@ -2527,6 +3911,403 @@ const simulation = d3.forceSimulation(DATA.nodes)
|
|
|
2527
3911
|
node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
2528
3912
|
});
|
|
2529
3913
|
|
|
3914
|
+
// \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
|
|
3915
|
+
let hullGroup = null;
|
|
3916
|
+
const activeDirs = new Set(DATA.dirs);
|
|
3917
|
+
const dirCounts = {};
|
|
3918
|
+
DATA.nodes.forEach(n => dirCounts[n.dir] = (dirCounts[n.dir] || 0) + 1);
|
|
3919
|
+
var applyLayerFilter = null; // hoisted for dir-filter integration
|
|
3920
|
+
var updateLayerPhysics = null; // hoisted \u2014 updates charge/layer forces without visibility changes
|
|
3921
|
+
|
|
3922
|
+
if (LAYERS && LAYERS.length > 0) {
|
|
3923
|
+
// \u2500\u2500\u2500 Water droplet physics: intra-layer cohesion + inter-layer separation \u2500\u2500\u2500
|
|
3924
|
+
const allLayerCount = LAYERS.length;
|
|
3925
|
+
const allBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(allLayerCount));
|
|
3926
|
+
// Pre-compute full-circle positions for all layers (used when no filter)
|
|
3927
|
+
const allLayerCenters = {};
|
|
3928
|
+
LAYERS.forEach((l, idx) => {
|
|
3929
|
+
const angle = (2 * Math.PI * idx) / allLayerCount - Math.PI / 2;
|
|
3930
|
+
allLayerCenters[l.name] = { x: Math.cos(angle) * allBaseRadius, y: Math.sin(angle) * allBaseRadius };
|
|
3931
|
+
});
|
|
3932
|
+
|
|
3933
|
+
// Dynamic center calculation: compact when multi-selecting, full spread when all
|
|
3934
|
+
function getLayerCenters() {
|
|
3935
|
+
if (activeLayers.size <= 1) return allLayerCenters; // 0 = all, 1 = single (centered)
|
|
3936
|
+
// Multi-select: arrange only selected layers compactly on a smaller circle
|
|
3937
|
+
const selected = LAYERS.filter(l => activeLayers.has(l.name));
|
|
3938
|
+
const count = selected.length;
|
|
3939
|
+
const compactRadius = Math.max(40, Math.min(W, H) * 0.03 * Math.sqrt(count));
|
|
3940
|
+
const centers = {};
|
|
3941
|
+
selected.forEach((l, idx) => {
|
|
3942
|
+
const angle = (2 * Math.PI * idx) / count - Math.PI / 2;
|
|
3943
|
+
centers[l.name] = { x: Math.cos(angle) * compactRadius, y: Math.sin(angle) * compactRadius };
|
|
3944
|
+
});
|
|
3945
|
+
return centers;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
// Replace default centering forces with per-layer positioning
|
|
3949
|
+
const layerStrength = layerGravity / 100;
|
|
3950
|
+
simulation.force('x', null).force('y', null).force('center', null);
|
|
3951
|
+
simulation.force('layerX', d3.forceX(d => allLayerCenters[d.layer]?.x || 0).strength(d => d.layer ? layerStrength : 0.03));
|
|
3952
|
+
simulation.force('layerY', d3.forceY(d => allLayerCenters[d.layer]?.y || 0).strength(d => d.layer ? layerStrength : 0.03));
|
|
3953
|
+
|
|
3954
|
+
// Custom clustering force \u2014 surface tension pulling nodes toward their layer centroid
|
|
3955
|
+
function clusterForce() {
|
|
3956
|
+
let nodes;
|
|
3957
|
+
function force(alpha) {
|
|
3958
|
+
const centroids = {};
|
|
3959
|
+
const counts = {};
|
|
3960
|
+
nodes.forEach(n => {
|
|
3961
|
+
if (!n.layer) return;
|
|
3962
|
+
if (!centroids[n.layer]) { centroids[n.layer] = {x: 0, y: 0}; counts[n.layer] = 0; }
|
|
3963
|
+
centroids[n.layer].x += n.x;
|
|
3964
|
+
centroids[n.layer].y += n.y;
|
|
3965
|
+
counts[n.layer]++;
|
|
3966
|
+
});
|
|
3967
|
+
Object.keys(centroids).forEach(k => {
|
|
3968
|
+
centroids[k].x /= counts[k];
|
|
3969
|
+
centroids[k].y /= counts[k];
|
|
3970
|
+
});
|
|
3971
|
+
// Pull each node toward its layer centroid (surface tension)
|
|
3972
|
+
const strength = 0.2;
|
|
3973
|
+
nodes.forEach(n => {
|
|
3974
|
+
if (!n.layer || !centroids[n.layer]) return;
|
|
3975
|
+
n.vx += (centroids[n.layer].x - n.x) * alpha * strength;
|
|
3976
|
+
n.vy += (centroids[n.layer].y - n.y) * alpha * strength;
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
force.initialize = (n) => { nodes = n; };
|
|
3980
|
+
return force;
|
|
3981
|
+
}
|
|
3982
|
+
simulation.force('cluster', clusterForce());
|
|
3983
|
+
|
|
3984
|
+
// Boost link strength for intra-layer edges (tighter connections within a layer)
|
|
3985
|
+
simulation.force('link').strength(l => {
|
|
3986
|
+
const sLayer = (l.source.layer ?? l.source);
|
|
3987
|
+
const tLayer = (l.target.layer ?? l.target);
|
|
3988
|
+
return sLayer === tLayer ? 0.4 : 0.1;
|
|
3989
|
+
});
|
|
3990
|
+
|
|
3991
|
+
hullGroup = g.insert('g', ':first-child');
|
|
3992
|
+
|
|
3993
|
+
function updateHulls() {
|
|
3994
|
+
if (!hullGroup) return;
|
|
3995
|
+
hullGroup.selectAll('*').remove();
|
|
3996
|
+
// Show hulls always (filter to selected layers when focused)
|
|
3997
|
+
|
|
3998
|
+
LAYERS.forEach(layer => {
|
|
3999
|
+
if (activeLayers.size > 0 && !activeLayers.has(layer.name)) return;
|
|
4000
|
+
const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
|
|
4001
|
+
if (layerNodes.length === 0) return;
|
|
4002
|
+
|
|
4003
|
+
const points = [];
|
|
4004
|
+
layerNodes.forEach(n => {
|
|
4005
|
+
if (n.x == null || n.y == null) return;
|
|
4006
|
+
const r = nodeRadius(n) * nodeScale + 30;
|
|
4007
|
+
// Add expanded points for a nicer hull shape
|
|
4008
|
+
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
4009
|
+
points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
|
|
4010
|
+
}
|
|
4011
|
+
});
|
|
4012
|
+
|
|
4013
|
+
if (points.length < 3) {
|
|
4014
|
+
// Fallback: circle for 1-2 nodes
|
|
4015
|
+
const cx = layerNodes.reduce((s, n) => s + (n.x || 0), 0) / layerNodes.length;
|
|
4016
|
+
const cy = layerNodes.reduce((s, n) => s + (n.y || 0), 0) / layerNodes.length;
|
|
4017
|
+
const maxR = Math.max(60, ...layerNodes.map(n => {
|
|
4018
|
+
const dx = (n.x || 0) - cx, dy = (n.y || 0) - cy;
|
|
4019
|
+
return Math.sqrt(dx*dx + dy*dy) + nodeRadius(n) * nodeScale + 30;
|
|
4020
|
+
}));
|
|
4021
|
+
hullGroup.append('circle')
|
|
4022
|
+
.attr('cx', cx).attr('cy', cy).attr('r', maxR)
|
|
4023
|
+
.attr('class', 'layer-hull')
|
|
4024
|
+
.attr('fill', layer.color).attr('stroke', layer.color);
|
|
4025
|
+
hullGroup.append('text')
|
|
4026
|
+
.attr('class', 'layer-hull-label')
|
|
4027
|
+
.attr('x', cx).attr('y', cy - maxR - 8)
|
|
4028
|
+
.attr('text-anchor', 'middle')
|
|
4029
|
+
.attr('fill', layer.color)
|
|
4030
|
+
.text(layer.name);
|
|
4031
|
+
return;
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
const hull = d3.polygonHull(points);
|
|
4035
|
+
if (!hull) return;
|
|
4036
|
+
|
|
4037
|
+
// Smooth the hull with a cardinal closed curve
|
|
4038
|
+
hullGroup.append('path')
|
|
4039
|
+
.attr('class', 'layer-hull')
|
|
4040
|
+
.attr('d', d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5))(hull))
|
|
4041
|
+
.attr('fill', layer.color).attr('stroke', layer.color);
|
|
4042
|
+
|
|
4043
|
+
// Label at the top of the hull
|
|
4044
|
+
const topPt = hull.reduce((best, p) => p[1] < best[1] ? p : best, hull[0]);
|
|
4045
|
+
hullGroup.append('text')
|
|
4046
|
+
.attr('class', 'layer-hull-label')
|
|
4047
|
+
.attr('x', topPt[0]).attr('y', topPt[1] - 10)
|
|
4048
|
+
.attr('text-anchor', 'middle')
|
|
4049
|
+
.attr('fill', layer.color)
|
|
4050
|
+
.text(layer.name);
|
|
4051
|
+
});
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
// Update hulls + cross-layer links on each tick
|
|
4055
|
+
simulation.on('tick', () => {
|
|
4056
|
+
// Regular links
|
|
4057
|
+
link.each(function(d) {
|
|
4058
|
+
const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
|
|
4059
|
+
const dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
4060
|
+
const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
|
|
4061
|
+
d3.select(this)
|
|
4062
|
+
.attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
4063
|
+
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
4064
|
+
});
|
|
4065
|
+
node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
4066
|
+
// Cross-layer links \u2014 resolve node positions by ID
|
|
4067
|
+
if (crossLinkData.length > 0) {
|
|
4068
|
+
const nodeById = {};
|
|
4069
|
+
DATA.nodes.forEach(n => { nodeById[n.id] = n; });
|
|
4070
|
+
crossLink.each(function(d) {
|
|
4071
|
+
const sN = nodeById[d.source], tN = nodeById[d.target];
|
|
4072
|
+
if (!sN || !tN) return;
|
|
4073
|
+
const dx = tN.x - sN.x, dy = tN.y - sN.y;
|
|
4074
|
+
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
4075
|
+
const rS = nodeRadius(sN) * nodeScale, rT = nodeRadius(tN) * nodeScale;
|
|
4076
|
+
d3.select(this)
|
|
4077
|
+
.attr('x1', sN.x + (dx/dist)*rS).attr('y1', sN.y + (dy/dist)*rS)
|
|
4078
|
+
.attr('x2', tN.x - (dx/dist)*rT).attr('y2', tN.y - (dy/dist)*rT);
|
|
4079
|
+
});
|
|
4080
|
+
crossLabel.each(function(d) {
|
|
4081
|
+
const sN = nodeById[d.source], tN = nodeById[d.target];
|
|
4082
|
+
if (!sN || !tN) return;
|
|
4083
|
+
d3.select(this).attr('x', (sN.x + tN.x) / 2).attr('y', (sN.y + tN.y) / 2 - 6);
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
updateHulls();
|
|
4087
|
+
});
|
|
4088
|
+
|
|
4089
|
+
// \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
|
|
4090
|
+
const layerLegend = document.getElementById('layer-legend');
|
|
4091
|
+
LAYERS.forEach(layer => {
|
|
4092
|
+
const item = document.createElement('div');
|
|
4093
|
+
item.className = 'legend-item';
|
|
4094
|
+
item.innerHTML = '<div class="legend-dot" style="background:' + esc(layer.color) + '"></div> ' + esc(layer.name);
|
|
4095
|
+
layerLegend.appendChild(item);
|
|
4096
|
+
});
|
|
4097
|
+
// Cross-layer edge legend
|
|
4098
|
+
if (CROSS_EDGES && CROSS_EDGES.length > 0) {
|
|
4099
|
+
const crossItem = document.createElement('div');
|
|
4100
|
+
crossItem.className = 'legend-item';
|
|
4101
|
+
crossItem.innerHTML = '<span style="color:#f0883e;font-size:11px">- - \u2192</span> Cross-layer link';
|
|
4102
|
+
layerLegend.appendChild(crossItem);
|
|
4103
|
+
}
|
|
4104
|
+
// Add separator
|
|
4105
|
+
const sep = document.createElement('hr');
|
|
4106
|
+
sep.style.cssText = 'border:none;border-top:1px solid var(--border);margin:6px 0;';
|
|
4107
|
+
layerLegend.appendChild(sep);
|
|
4108
|
+
|
|
4109
|
+
// \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
|
|
4110
|
+
const layerTabsEl = document.getElementById('layer-tabs');
|
|
4111
|
+
const allTab = document.createElement('div');
|
|
4112
|
+
allTab.className = 'layer-tab active';
|
|
4113
|
+
allTab.textContent = 'All';
|
|
4114
|
+
allTab.onclick = () => {
|
|
4115
|
+
activeLayers.clear();
|
|
4116
|
+
syncLayerTabUI();
|
|
4117
|
+
applyLayerFilter();
|
|
4118
|
+
if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
|
|
4119
|
+
};
|
|
4120
|
+
layerTabsEl.appendChild(allTab);
|
|
4121
|
+
|
|
4122
|
+
LAYERS.forEach(layer => {
|
|
4123
|
+
const tab = document.createElement('div');
|
|
4124
|
+
tab.className = 'layer-tab';
|
|
4125
|
+
tab.dataset.layer = layer.name;
|
|
4126
|
+
tab.innerHTML = '<div class="lt-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
|
|
4127
|
+
tab.onclick = (e) => {
|
|
4128
|
+
if (e.shiftKey) {
|
|
4129
|
+
// Shift+click: solo this layer
|
|
4130
|
+
activeLayers.clear();
|
|
4131
|
+
activeLayers.add(layer.name);
|
|
4132
|
+
} else {
|
|
4133
|
+
// Toggle
|
|
4134
|
+
if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
|
|
4135
|
+
else activeLayers.add(layer.name);
|
|
4136
|
+
}
|
|
4137
|
+
syncLayerTabUI();
|
|
4138
|
+
applyLayerFilter();
|
|
4139
|
+
if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
|
|
4140
|
+
};
|
|
4141
|
+
layerTabsEl.appendChild(tab);
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
function syncLayerTabUI() {
|
|
4145
|
+
allTab.classList.toggle('active', activeLayers.size === 0);
|
|
4146
|
+
layerTabsEl.querySelectorAll('.layer-tab[data-layer]').forEach(t => {
|
|
4147
|
+
t.classList.toggle('active', activeLayers.has(t.dataset.layer));
|
|
4148
|
+
});
|
|
4149
|
+
// Also sync the filter bar layer pills
|
|
4150
|
+
layerRowEl.querySelectorAll('.layer-pill[data-layer]').forEach(p => {
|
|
4151
|
+
p.classList.toggle('active', activeLayers.has(p.dataset.layer));
|
|
4152
|
+
});
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
applyLayerFilter = function() {
|
|
4156
|
+
const isSingleLayer = activeLayers.size === 1;
|
|
4157
|
+
const hasLayerFilter = activeLayers.size > 0;
|
|
4158
|
+
node.attr('display', d => {
|
|
4159
|
+
if (!activeDirs.has(d.dir)) return 'none';
|
|
4160
|
+
if (hasLayerFilter && !activeLayers.has(d.layer)) return 'none';
|
|
4161
|
+
return null;
|
|
4162
|
+
});
|
|
4163
|
+
link.attr('display', l => {
|
|
4164
|
+
const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
4165
|
+
const sN = DATA.nodes.find(n => n.id === s), tN = DATA.nodes.find(n => n.id === t);
|
|
4166
|
+
if (!sN || !tN) return 'none';
|
|
4167
|
+
if (!activeDirs.has(sN.dir) || !activeDirs.has(tN.dir)) return 'none';
|
|
4168
|
+
if (hasLayerFilter && (!activeLayers.has(sN.layer) || !activeLayers.has(tN.layer))) return 'none';
|
|
4169
|
+
return null;
|
|
4170
|
+
});
|
|
4171
|
+
// Refresh node colors: single-layer = dir-based, multi-layer = layer-based
|
|
4172
|
+
node.select('circle')
|
|
4173
|
+
.attr('fill', nodeColor)
|
|
4174
|
+
.attr('stroke', d => d.deps >= 5 ? 'var(--yellow)' : nodeColor(d));
|
|
4175
|
+
// Cross-layer links: respect user toggle + layer filter
|
|
4176
|
+
if (typeof crossLink !== 'undefined') {
|
|
4177
|
+
if (!crossLinksUserEnabled || isSingleLayer) {
|
|
4178
|
+
crossLink.attr('display', 'none');
|
|
4179
|
+
crossLabel.attr('display', 'none');
|
|
4180
|
+
} else if (hasLayerFilter) {
|
|
4181
|
+
crossLink.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
|
|
4182
|
+
crossLabel.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
|
|
4183
|
+
} else {
|
|
4184
|
+
crossLink.attr('display', null);
|
|
4185
|
+
crossLabel.attr('display', null);
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
// Update stats
|
|
4189
|
+
const visibleNodes = DATA.nodes.filter(d => {
|
|
4190
|
+
if (!activeDirs.has(d.dir)) return false;
|
|
4191
|
+
if (hasLayerFilter && !activeLayers.has(d.layer)) return false;
|
|
4192
|
+
return true;
|
|
4193
|
+
});
|
|
4194
|
+
const visibleIds = new Set(visibleNodes.map(n => n.id));
|
|
4195
|
+
const visibleEdges = DATA.links.filter(l => {
|
|
4196
|
+
const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
4197
|
+
return visibleIds.has(s) && visibleIds.has(t);
|
|
4198
|
+
});
|
|
4199
|
+
document.getElementById('s-files').textContent = visibleNodes.length;
|
|
4200
|
+
document.getElementById('s-edges').textContent = visibleEdges.length;
|
|
4201
|
+
const visCirc = DATA.circularFiles.filter(f => visibleIds.has(f));
|
|
4202
|
+
document.getElementById('s-circular').textContent = visCirc.length;
|
|
4203
|
+
updateHulls();
|
|
4204
|
+
// Delegate physics update and zoom to fit
|
|
4205
|
+
updateLayerPhysics();
|
|
4206
|
+
simulation.alpha(0.6).restart();
|
|
4207
|
+
setTimeout(() => zoomFit(), 600);
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
// Separated physics update: handles charge/layer forces based on filter state.
|
|
4211
|
+
// Called by applyLayerFilter (with zoomFit), setGravity, setLayerGravity (without zoomFit).
|
|
4212
|
+
updateLayerPhysics = function() {
|
|
4213
|
+
const isSingleLayer = activeLayers.size === 1;
|
|
4214
|
+
const lStrength = layerGravity / 100;
|
|
4215
|
+
if (isSingleLayer) {
|
|
4216
|
+
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength * 3).distanceMax(800));
|
|
4217
|
+
simulation.force('layerX', d3.forceX(0).strength(0.03));
|
|
4218
|
+
simulation.force('layerY', d3.forceY(0).strength(0.03));
|
|
4219
|
+
} else {
|
|
4220
|
+
const centers = getLayerCenters();
|
|
4221
|
+
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
|
|
4222
|
+
simulation.force('layerX', d3.forceX(d => centers[d.layer]?.x || 0).strength(d => d.layer ? lStrength : 0.03));
|
|
4223
|
+
simulation.force('layerY', d3.forceY(d => centers[d.layer]?.y || 0).strength(d => d.layer ? lStrength : 0.03));
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
// \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
|
|
4228
|
+
const layerRowEl = document.getElementById('filter-layer-row');
|
|
4229
|
+
const dirPanelEl = document.getElementById('filter-dir-panel');
|
|
4230
|
+
|
|
4231
|
+
// Dir toggle button
|
|
4232
|
+
const dirToggle = document.createElement('div');
|
|
4233
|
+
dirToggle.id = 'filter-dir-toggle';
|
|
4234
|
+
dirToggle.textContent = '\u25B8 Dirs';
|
|
4235
|
+
dirToggle.onclick = () => {
|
|
4236
|
+
dirToggle.classList.toggle('open');
|
|
4237
|
+
dirPanelEl.classList.toggle('open');
|
|
4238
|
+
dirToggle.textContent = dirPanelEl.classList.contains('open') ? '\u25BE Dirs' : '\u25B8 Dirs';
|
|
4239
|
+
};
|
|
4240
|
+
layerRowEl.appendChild(dirToggle);
|
|
4241
|
+
|
|
4242
|
+
// Cross-layer link toggle (in settings sidebar)
|
|
4243
|
+
let crossLinksUserEnabled = true;
|
|
4244
|
+
if (crossLinkData.length > 0) {
|
|
4245
|
+
document.getElementById('cross-layer-setting').style.display = '';
|
|
4246
|
+
window.toggleCrossLinks = () => {
|
|
4247
|
+
crossLinksUserEnabled = !crossLinksUserEnabled;
|
|
4248
|
+
const btn = document.getElementById('cross-link-toggle');
|
|
4249
|
+
btn.textContent = crossLinksUserEnabled ? 'ON' : 'OFF';
|
|
4250
|
+
btn.classList.toggle('active', crossLinksUserEnabled);
|
|
4251
|
+
applyLayerFilter();
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
LAYERS.forEach(layer => {
|
|
4256
|
+
const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
|
|
4257
|
+
const pill = document.createElement('div');
|
|
4258
|
+
pill.className = 'layer-pill';
|
|
4259
|
+
pill.dataset.layer = layer.name;
|
|
4260
|
+
pill.innerHTML = '<div class="lp-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name) + ' <span class="lp-count">' + layerNodes.length + '</span>';
|
|
4261
|
+
pill.onclick = () => {
|
|
4262
|
+
if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
|
|
4263
|
+
else activeLayers.add(layer.name);
|
|
4264
|
+
syncLayerTabUI();
|
|
4265
|
+
applyLayerFilter();
|
|
4266
|
+
};
|
|
4267
|
+
pill.onmouseenter = () => {
|
|
4268
|
+
if (pinnedNode) return;
|
|
4269
|
+
node.select('circle').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.1);
|
|
4270
|
+
node.select('text').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.05);
|
|
4271
|
+
};
|
|
4272
|
+
pill.onmouseleave = () => {
|
|
4273
|
+
if (pinnedNode) return;
|
|
4274
|
+
node.select('circle').transition().duration(150).attr('opacity', 1);
|
|
4275
|
+
node.select('text').transition().duration(150).attr('opacity', d => d.dependents >= 1 || d.deps >= 3 ? 1 : 0.5);
|
|
4276
|
+
};
|
|
4277
|
+
layerRowEl.appendChild(pill);
|
|
4278
|
+
|
|
4279
|
+
// Build dir group in panel for this layer
|
|
4280
|
+
const layerDirs = [...new Set(layerNodes.map(n => n.dir))].sort();
|
|
4281
|
+
if (layerDirs.length > 0) {
|
|
4282
|
+
const group = document.createElement('div');
|
|
4283
|
+
group.className = 'dir-group';
|
|
4284
|
+
const label = document.createElement('div');
|
|
4285
|
+
label.className = 'dir-group-label';
|
|
4286
|
+
label.innerHTML = '<div class="dg-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
|
|
4287
|
+
group.appendChild(label);
|
|
4288
|
+
const pillsWrap = document.createElement('div');
|
|
4289
|
+
pillsWrap.className = 'dir-group-pills';
|
|
4290
|
+
layerDirs.forEach(dir => {
|
|
4291
|
+
const dp = document.createElement('div');
|
|
4292
|
+
dp.className = 'filter-pill active';
|
|
4293
|
+
const shortDir = dir.includes('/') ? dir.substring(dir.indexOf('/') + 1) : dir;
|
|
4294
|
+
dp.innerHTML = '<div class="pill-dot" style="background:' + dirColor(dir) + '"></div>' + esc(shortDir || '.') + ' <span class="pill-count">' + (dirCounts[dir] || 0) + '</span>';
|
|
4295
|
+
dp.onclick = () => {
|
|
4296
|
+
if (activeDirs.has(dir)) { activeDirs.delete(dir); dp.classList.remove('active'); }
|
|
4297
|
+
else { activeDirs.add(dir); dp.classList.add('active'); }
|
|
4298
|
+
applyLayerFilter();
|
|
4299
|
+
};
|
|
4300
|
+
pillsWrap.appendChild(dp);
|
|
4301
|
+
});
|
|
4302
|
+
group.appendChild(pillsWrap);
|
|
4303
|
+
dirPanelEl.appendChild(group);
|
|
4304
|
+
}
|
|
4305
|
+
});
|
|
4306
|
+
|
|
4307
|
+
// Override applyFilter to respect layers
|
|
4308
|
+
window._origApplyFilter = applyFilter;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
2530
4311
|
setTimeout(()=>zoomFit(), 1500);
|
|
2531
4312
|
|
|
2532
4313
|
// Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
|
|
@@ -2584,12 +4365,15 @@ svg.on('click', () => {
|
|
|
2584
4365
|
function showDetail(d) {
|
|
2585
4366
|
const p=document.getElementById('detail');
|
|
2586
4367
|
document.getElementById('d-name').textContent=d.id;
|
|
2587
|
-
document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+d.dir+'<br>'+i('detail.dependencies')+': '+d.deps+'
|
|
4368
|
+
document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+esc(d.dir)+'<br>'+i('detail.dependencies')+': '+d.deps+' \\u00b7 '+i('detail.dependents')+': '+d.dependents;
|
|
2588
4369
|
const deptL=document.getElementById('d-dependents'), depsL=document.getElementById('d-deps');
|
|
2589
|
-
deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li
|
|
2590
|
-
depsL.innerHTML=(d.dependencies||[]).map(x=>'<li
|
|
4370
|
+
deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
4371
|
+
depsL.innerHTML=(d.dependencies||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
2591
4372
|
p.classList.add('open');
|
|
2592
4373
|
}
|
|
4374
|
+
// Event delegation for detail panel list items (avoids inline onclick)
|
|
4375
|
+
document.getElementById('d-dependents').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
|
|
4376
|
+
document.getElementById('d-deps').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
|
|
2593
4377
|
window.closeDetail=()=>document.getElementById('detail').classList.remove('open');
|
|
2594
4378
|
window.focusNode=(id)=>{
|
|
2595
4379
|
const n=DATA.nodes.find(x=>x.id===id); if(!n)return; showDetail(n);
|
|
@@ -2616,32 +4400,36 @@ searchInput.addEventListener('input',e=>{
|
|
|
2616
4400
|
});
|
|
2617
4401
|
|
|
2618
4402
|
// \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
const
|
|
2622
|
-
DATA.
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
}
|
|
4403
|
+
if (!LAYERS) {
|
|
4404
|
+
// Non-layer mode: flat pills in filter-layer-row
|
|
4405
|
+
const filterRowEl=document.getElementById('filter-layer-row');
|
|
4406
|
+
DATA.dirs.forEach(dir=>{
|
|
4407
|
+
const pill=document.createElement('div');
|
|
4408
|
+
pill.className='filter-pill active';
|
|
4409
|
+
pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
|
|
4410
|
+
pill.onclick=()=>{
|
|
4411
|
+
if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
|
|
4412
|
+
else{activeDirs.add(dir);pill.classList.add('active');}
|
|
4413
|
+
applyFilter();
|
|
4414
|
+
};
|
|
4415
|
+
pill.onmouseenter=()=>{
|
|
4416
|
+
if(pinnedNode)return;
|
|
4417
|
+
node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
|
|
4418
|
+
node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
|
|
4419
|
+
};
|
|
4420
|
+
pill.onmouseleave=()=>{
|
|
4421
|
+
if(pinnedNode)return;
|
|
4422
|
+
node.select('circle').transition().duration(150).attr('opacity',1);
|
|
4423
|
+
node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
|
|
4424
|
+
};
|
|
4425
|
+
filterRowEl.appendChild(pill);
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
2644
4428
|
function applyFilter(){
|
|
4429
|
+
if (LAYERS) {
|
|
4430
|
+
// Delegate to layer-aware filter
|
|
4431
|
+
if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
|
|
4432
|
+
}
|
|
2645
4433
|
node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
|
|
2646
4434
|
link.attr('display',l=>{
|
|
2647
4435
|
const s=l.source.id??l.source,t=l.target.id??l.target;
|
|
@@ -2691,246 +4479,8 @@ window.addEventListener('resize',()=>{
|
|
|
2691
4479
|
svg.attr('width',w).attr('height',h);
|
|
2692
4480
|
});
|
|
2693
4481
|
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
// \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
|
|
2697
|
-
function buildHierarchy(){
|
|
2698
|
-
const hSvg=d3.select('#hier-svg');
|
|
2699
|
-
const hG=hSvg.append('g');
|
|
2700
|
-
const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
|
|
2701
|
-
hSvg.call(hZoom);
|
|
2702
|
-
|
|
2703
|
-
const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
|
|
2704
|
-
const importsMap={}; DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!importsMap[s])importsMap[s]=[];importsMap[s].push(t);});
|
|
2705
|
-
|
|
2706
|
-
const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
|
|
2707
|
-
const layers={};const visited=new Set();
|
|
2708
|
-
const queue=entryPoints.map(id=>({id,layer:0}));
|
|
2709
|
-
DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
|
|
2710
|
-
|
|
2711
|
-
while(queue.length>0){
|
|
2712
|
-
const{id,layer}=queue.shift();
|
|
2713
|
-
if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
|
|
2714
|
-
layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
|
|
2715
|
-
(importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
|
|
2716
|
-
}
|
|
2717
|
-
DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
|
|
2718
|
-
|
|
2719
|
-
const maxLayer=Math.max(0,...Object.values(layers));
|
|
2720
|
-
const layerGroups={};
|
|
2721
|
-
for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
|
|
2722
|
-
Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
|
|
2723
|
-
Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
|
|
2724
|
-
|
|
2725
|
-
const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
|
|
2726
|
-
const positions={};let maxRowWidth=0;
|
|
2727
|
-
for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
|
|
2728
|
-
for(let layer=0;layer<=maxLayer;layer++){
|
|
2729
|
-
const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
|
|
2730
|
-
items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
|
|
2734
|
-
hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
|
|
2735
|
-
|
|
2736
|
-
const linkG=hG.append('g');
|
|
2737
|
-
DATA.links.forEach(l=>{
|
|
2738
|
-
const sId=l.source.id??l.source,tId=l.target.id??l.target;
|
|
2739
|
-
const s=positions[sId],t=positions[tId]; if(!s||!t)return;
|
|
2740
|
-
const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
|
|
2741
|
-
linkG.append('path').attr('class','hier-link')
|
|
2742
|
-
.attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
|
|
2743
|
-
.attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
|
|
2744
|
-
.attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
|
|
2745
|
-
.attr('data-source',sId).attr('data-target',tId);
|
|
2746
|
-
});
|
|
2747
|
-
|
|
2748
|
-
hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
|
|
2749
|
-
.attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
|
|
2750
|
-
.append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
|
|
2751
|
-
linkG.selectAll('path').attr('marker-end','url(#harrow)');
|
|
2752
|
-
|
|
2753
|
-
for(let layer=0;layer<=maxLayer;layer++){
|
|
2754
|
-
if(!layerGroups[layer].length)continue;
|
|
2755
|
-
hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
|
|
2756
|
-
.attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
|
|
2757
|
-
}
|
|
2758
|
-
|
|
2759
|
-
const nodeG=hG.append('g');
|
|
2760
|
-
DATA.nodes.forEach(n=>{
|
|
2761
|
-
const pos=positions[n.id]; if(!pos)return;
|
|
2762
|
-
const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
|
|
2763
|
-
gn.append('rect').attr('width',boxW).attr('height',boxH)
|
|
2764
|
-
.attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
|
|
2765
|
-
.attr('stroke-width',circularSet.has(n.id)?2:1.5);
|
|
2766
|
-
gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
|
|
2767
|
-
.text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\u2026':fileName(n.id));
|
|
2768
|
-
gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
|
|
2769
|
-
.attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
|
|
2770
|
-
.text(n.dependents>0?'\u2191'+n.dependents:'');
|
|
2771
|
-
gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
|
|
2772
|
-
.attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
|
|
2773
|
-
|
|
2774
|
-
gn.node().__data_id=n.id;
|
|
2775
|
-
gn.on('mouseover',e=>{
|
|
2776
|
-
showTooltip(e,n);
|
|
2777
|
-
if (!hierPinned) hierHighlight(n.id);
|
|
2778
|
-
})
|
|
2779
|
-
.on('mousemove',e=>positionTooltip(e))
|
|
2780
|
-
.on('mouseout',()=>{
|
|
2781
|
-
scheduleHideTooltip();
|
|
2782
|
-
if (!hierPinned) hierResetHighlight();
|
|
2783
|
-
})
|
|
2784
|
-
.on('click',(e)=>{
|
|
2785
|
-
e.stopPropagation();
|
|
2786
|
-
hierPinned=n.id;
|
|
2787
|
-
hierHighlight(n.id);
|
|
2788
|
-
showHierDetail(n);
|
|
2789
|
-
});
|
|
2790
|
-
});
|
|
2791
|
-
|
|
2792
|
-
// Hierarchy highlight helpers
|
|
2793
|
-
let hierPinned=null;
|
|
2794
|
-
function hierHighlight(nId){
|
|
2795
|
-
linkG.selectAll('path')
|
|
2796
|
-
.attr('stroke',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');if(s===nId)return'#58a6ff';if(t===nId)return'#3fb950';return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
|
|
2797
|
-
.attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
|
|
2798
|
-
.attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
|
|
2799
|
-
nodeG.selectAll('.hier-node').attr('opacity',function(){
|
|
2800
|
-
const id=this.__data_id; if(id===nId)return 1;
|
|
2801
|
-
const connected=DATA.links.some(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return(s===nId&&t===id)||(t===nId&&s===id);});
|
|
2802
|
-
return connected?1:0.3;
|
|
2803
|
-
});
|
|
2804
|
-
}
|
|
2805
|
-
function hierResetHighlight(){
|
|
2806
|
-
hierPinned=null;
|
|
2807
|
-
linkG.selectAll('path')
|
|
2808
|
-
.attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
|
|
2809
|
-
.attr('stroke-width',1).attr('opacity',1);
|
|
2810
|
-
nodeG.selectAll('.hier-node').attr('opacity',1);
|
|
2811
|
-
}
|
|
2812
|
-
function showHierDetail(n){
|
|
2813
|
-
const p=document.getElementById('hier-detail');
|
|
2814
|
-
document.getElementById('hd-name').textContent=n.id;
|
|
2815
|
-
document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+n.dir+'<br>'+i('detail.dependencies')+': '+n.deps+' \xB7 '+i('detail.dependents')+': '+n.dependents;
|
|
2816
|
-
document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\u2190 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
2817
|
-
document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\u2192 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
2818
|
-
p.classList.add('open');
|
|
2819
|
-
}
|
|
2820
|
-
window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
|
|
2821
|
-
|
|
2822
|
-
// Click on empty space to deselect
|
|
2823
|
-
hSvg.on('click',()=>{closeHierDetail();});
|
|
2824
|
-
|
|
2825
|
-
// Hierarchy dir filters
|
|
2826
|
-
const hFiltersEl=document.getElementById('hier-filters');
|
|
2827
|
-
const hActiveDirs=new Set(DATA.dirs);
|
|
2828
|
-
DATA.dirs.forEach(dir=>{
|
|
2829
|
-
const pill=document.createElement('div');
|
|
2830
|
-
pill.className='filter-pill active';
|
|
2831
|
-
pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
|
|
2832
|
-
pill.onclick=()=>{
|
|
2833
|
-
if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
|
|
2834
|
-
else{hActiveDirs.add(dir);pill.classList.add('active');}
|
|
2835
|
-
nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
|
|
2836
|
-
};
|
|
2837
|
-
pill.onmouseenter=()=>{
|
|
2838
|
-
nodeG.selectAll('.hier-node').attr('opacity',function(){return this.__data_id&&nodeMap[this.__data_id]?.dir===dir?1:0.1;});
|
|
2839
|
-
};
|
|
2840
|
-
pill.onmouseleave=()=>{
|
|
2841
|
-
nodeG.selectAll('.hier-node').attr('opacity',1);
|
|
2842
|
-
};
|
|
2843
|
-
hFiltersEl.appendChild(pill);
|
|
2844
|
-
});
|
|
2845
|
-
|
|
2846
|
-
hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
|
|
2847
|
-
Math.max(0,(W-totalW)/2),20
|
|
2848
|
-
).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
// \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
|
|
2852
|
-
// DIFF VIEW
|
|
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
|
|
2854
|
-
const DIFF = ${diffData};
|
|
2855
|
-
if (DIFF) {
|
|
2856
|
-
document.getElementById('diff-tab').style.display = '';
|
|
2857
|
-
const addedSet = new Set(DIFF.added||[]);
|
|
2858
|
-
const removedSet = new Set(DIFF.removed||[]);
|
|
2859
|
-
const modifiedSet = new Set(DIFF.modified||[]);
|
|
2860
|
-
const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
|
|
2861
|
-
|
|
2862
|
-
let diffBuilt = false;
|
|
2863
|
-
function buildDiffView() {
|
|
2864
|
-
const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
|
|
2865
|
-
const dG = dSvg.append('g');
|
|
2866
|
-
const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
|
|
2867
|
-
dSvg.call(dZoom);
|
|
2868
|
-
|
|
2869
|
-
function diffColor(d) {
|
|
2870
|
-
if (addedSet.has(d.id)) return 'var(--green)';
|
|
2871
|
-
if (removedSet.has(d.id)) return 'var(--red)';
|
|
2872
|
-
if (modifiedSet.has(d.id)) return 'var(--yellow)';
|
|
2873
|
-
if (affectedSet.has(d.id)) return 'var(--accent)';
|
|
2874
|
-
return '#30363d';
|
|
2875
|
-
}
|
|
2876
|
-
|
|
2877
|
-
const dDefs = dSvg.append('defs');
|
|
2878
|
-
dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
|
|
2879
|
-
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
2880
|
-
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
|
|
2881
|
-
|
|
2882
|
-
const dLink = dG.append('g').selectAll('line').data(DATA.links).join('line')
|
|
2883
|
-
.attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
|
|
2884
|
-
|
|
2885
|
-
const simNodes = DATA.nodes.map(d=>({...d}));
|
|
2886
|
-
const simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
|
|
2887
|
-
|
|
2888
|
-
const dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
|
|
2889
|
-
dNode.append('circle')
|
|
2890
|
-
.attr('r', d=>nodeRadius(d)*nodeScale)
|
|
2891
|
-
.attr('fill', diffColor)
|
|
2892
|
-
.attr('stroke', diffColor).attr('stroke-width', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?3:1)
|
|
2893
|
-
.attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2);
|
|
2894
|
-
dNode.append('text')
|
|
2895
|
-
.text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
|
|
2896
|
-
.attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
|
|
2897
|
-
.attr('fill', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?'var(--text)':'var(--text-muted)')
|
|
2898
|
-
.attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2)
|
|
2899
|
-
.attr('pointer-events','none');
|
|
2900
|
-
|
|
2901
|
-
const dSim = d3.forceSimulation(simNodes)
|
|
2902
|
-
.force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
|
|
2903
|
-
.force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
|
|
2904
|
-
.force('center', d3.forceCenter(0,0))
|
|
2905
|
-
.force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
|
|
2906
|
-
.on('tick', ()=>{
|
|
2907
|
-
dLink.each(function(d){
|
|
2908
|
-
const dx=d.target.x-d.source.x,dy=d.target.y-d.source.y,dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
2909
|
-
const rT=nodeRadius(d.target)*nodeScale,rS=nodeRadius(d.source)*nodeScale;
|
|
2910
|
-
d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
2911
|
-
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
2912
|
-
});
|
|
2913
|
-
dNode.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
2914
|
-
});
|
|
2915
|
-
|
|
2916
|
-
dNode.on('mouseover',(e,d)=>showTooltip(e,d)).on('mousemove',e=>positionTooltip(e)).on('mouseout',()=>scheduleHideTooltip());
|
|
2917
|
-
|
|
2918
|
-
setTimeout(()=>{
|
|
2919
|
-
const b=dG.node().getBBox();if(!b.width)return;
|
|
2920
|
-
const s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
|
|
2921
|
-
dSvg.call(dZoom.transform,d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s,H/2-(b.y+b.height/2)*s).scale(s));
|
|
2922
|
-
},1500);
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
// Hook into tab switching
|
|
2926
|
-
const origTabHandler = document.querySelectorAll('.tab');
|
|
2927
|
-
origTabHandler.forEach(tab=>{
|
|
2928
|
-
tab.addEventListener('click',()=>{
|
|
2929
|
-
if(tab.dataset.view==='diff-view'&&!diffBuilt){buildDiffView();diffBuilt=true;}
|
|
2930
|
-
});
|
|
2931
|
-
});
|
|
2932
|
-
}
|
|
2933
|
-
|
|
4482
|
+
${buildHierarchyJs()}
|
|
4483
|
+
${buildDiffJs(diffData)}
|
|
2934
4484
|
// \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
|
|
2935
4485
|
// INIT
|
|
2936
4486
|
// \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
|
|
@@ -2945,7 +4495,12 @@ applyI18n();
|
|
|
2945
4495
|
function startViewer(graph, options = {}) {
|
|
2946
4496
|
const port = options.port ?? 3e3;
|
|
2947
4497
|
const locale = options.locale ?? getLocale();
|
|
2948
|
-
const html = buildGraphPage(graph, {
|
|
4498
|
+
const html = buildGraphPage(graph, {
|
|
4499
|
+
locale,
|
|
4500
|
+
diff: options.diff,
|
|
4501
|
+
layerMetadata: options.layerMetadata,
|
|
4502
|
+
crossLayerEdges: options.crossLayerEdges
|
|
4503
|
+
});
|
|
2949
4504
|
const graphJson = JSON.stringify(graph);
|
|
2950
4505
|
const server = createServer((req, res) => {
|
|
2951
4506
|
if (req.url === "/api/graph") {
|
|
@@ -2964,14 +4519,14 @@ function startViewer(graph, options = {}) {
|
|
|
2964
4519
|
}
|
|
2965
4520
|
|
|
2966
4521
|
// src/utils/version.ts
|
|
2967
|
-
import { readFileSync as
|
|
2968
|
-
import { join as
|
|
4522
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
4523
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
2969
4524
|
import { fileURLToPath } from "url";
|
|
2970
4525
|
function loadVersion() {
|
|
2971
4526
|
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
2972
4527
|
for (let i = 0; i < 5; i++) {
|
|
2973
4528
|
try {
|
|
2974
|
-
const pkg = JSON.parse(
|
|
4529
|
+
const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
|
|
2975
4530
|
return pkg.version;
|
|
2976
4531
|
} catch {
|
|
2977
4532
|
dir = dirname2(dir);
|
|
@@ -2983,6 +4538,14 @@ var VERSION = loadVersion();
|
|
|
2983
4538
|
|
|
2984
4539
|
// src/cli/index.ts
|
|
2985
4540
|
var VALID_LANGUAGES = LANGUAGE_IDS;
|
|
4541
|
+
async function resolveGraphCli(opts) {
|
|
4542
|
+
return resolveGraph({
|
|
4543
|
+
targetDir: opts.target,
|
|
4544
|
+
projectRoot: opts.root,
|
|
4545
|
+
exclude: opts.exclude,
|
|
4546
|
+
language: opts.language
|
|
4547
|
+
});
|
|
4548
|
+
}
|
|
2986
4549
|
var program = new Command();
|
|
2987
4550
|
program.name("archtracker").description(
|
|
2988
4551
|
"Architecture & Dependency Tracker \u2014 Prevent missed architecture changes in AI-driven development"
|
|
@@ -2999,11 +4562,13 @@ program.command("init").description("Generate initial snapshot and save to .arch
|
|
|
2999
4562
|
try {
|
|
3000
4563
|
const language = validateLanguage(opts.language);
|
|
3001
4564
|
console.log(t("cli.analyzing"));
|
|
3002
|
-
const graph = await
|
|
4565
|
+
const { graph, multiLayer } = await resolveGraphCli({
|
|
4566
|
+
target: opts.target,
|
|
4567
|
+
root: opts.root,
|
|
3003
4568
|
exclude: opts.exclude,
|
|
3004
4569
|
language
|
|
3005
4570
|
});
|
|
3006
|
-
const snapshot = await saveSnapshot(opts.root, graph);
|
|
4571
|
+
const snapshot = await saveSnapshot(opts.root, graph, multiLayer);
|
|
3007
4572
|
console.log(t("cli.snapshotSaved"));
|
|
3008
4573
|
console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
|
|
3009
4574
|
console.log(t("cli.fileCount", { count: graph.totalFiles }));
|
|
@@ -3034,14 +4599,16 @@ program.command("analyze").description(
|
|
|
3034
4599
|
try {
|
|
3035
4600
|
const language = validateLanguage(opts.language);
|
|
3036
4601
|
console.log(t("cli.analyzing"));
|
|
3037
|
-
const graph = await
|
|
4602
|
+
const { graph, multiLayer } = await resolveGraphCli({
|
|
4603
|
+
target: opts.target,
|
|
4604
|
+
root: opts.root,
|
|
3038
4605
|
exclude: opts.exclude,
|
|
3039
4606
|
language
|
|
3040
4607
|
});
|
|
3041
4608
|
const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
|
|
3042
4609
|
console.log(report);
|
|
3043
4610
|
if (opts.save) {
|
|
3044
|
-
await saveSnapshot(opts.root, graph);
|
|
4611
|
+
await saveSnapshot(opts.root, graph, multiLayer);
|
|
3045
4612
|
console.log(t("analyze.snapshotSaved"));
|
|
3046
4613
|
}
|
|
3047
4614
|
} catch (error) {
|
|
@@ -3059,7 +4626,11 @@ program.command("check").description(
|
|
|
3059
4626
|
process.exit(1);
|
|
3060
4627
|
}
|
|
3061
4628
|
console.log(t("cli.analyzing"));
|
|
3062
|
-
const currentGraph = await
|
|
4629
|
+
const { graph: currentGraph } = await resolveGraphCli({
|
|
4630
|
+
target: opts.target,
|
|
4631
|
+
root: opts.root,
|
|
4632
|
+
language
|
|
4633
|
+
});
|
|
3063
4634
|
const diff = computeDiff(existingSnapshot.graph, currentGraph);
|
|
3064
4635
|
const report = formatDiffReport(diff);
|
|
3065
4636
|
console.log(report);
|
|
@@ -3079,8 +4650,12 @@ program.command("context").description(
|
|
|
3079
4650
|
let snapshot = await loadSnapshot(opts.root);
|
|
3080
4651
|
if (!snapshot) {
|
|
3081
4652
|
console.log(t("cli.autoGenerating"));
|
|
3082
|
-
const
|
|
3083
|
-
|
|
4653
|
+
const result = await resolveGraphCli({
|
|
4654
|
+
target: opts.target,
|
|
4655
|
+
root: opts.root,
|
|
4656
|
+
language
|
|
4657
|
+
});
|
|
4658
|
+
snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer);
|
|
3084
4659
|
}
|
|
3085
4660
|
const graph = snapshot.graph;
|
|
3086
4661
|
if (opts.json) {
|
|
@@ -3122,18 +4697,24 @@ program.command("serve").description(
|
|
|
3122
4697
|
const language = validateLanguage(opts.language);
|
|
3123
4698
|
console.log(t("web.starting"));
|
|
3124
4699
|
console.log(t("cli.analyzing"));
|
|
3125
|
-
let graph;
|
|
3126
4700
|
let diff = null;
|
|
4701
|
+
const result = await resolveGraphCli({
|
|
4702
|
+
target: opts.target,
|
|
4703
|
+
root: opts.root,
|
|
4704
|
+
exclude: opts.exclude,
|
|
4705
|
+
language
|
|
4706
|
+
});
|
|
3127
4707
|
const snapshot = await loadSnapshot(opts.root);
|
|
3128
4708
|
if (snapshot) {
|
|
3129
|
-
|
|
3130
|
-
diff = computeDiff(snapshot.graph, currentGraph);
|
|
3131
|
-
graph = currentGraph;
|
|
3132
|
-
} else {
|
|
3133
|
-
graph = await analyzeProject(opts.target, { exclude: opts.exclude, language });
|
|
4709
|
+
diff = computeDiff(snapshot.graph, result.graph);
|
|
3134
4710
|
}
|
|
3135
4711
|
const port = parseInt(opts.port, 10);
|
|
3136
|
-
const viewer = startViewer(graph, {
|
|
4712
|
+
const viewer = startViewer(result.graph, {
|
|
4713
|
+
port,
|
|
4714
|
+
diff,
|
|
4715
|
+
layerMetadata: result.layerMetadata,
|
|
4716
|
+
crossLayerEdges: result.crossLayerEdges
|
|
4717
|
+
});
|
|
3137
4718
|
console.log(t("web.listening", { port }));
|
|
3138
4719
|
console.log(t("web.stop"));
|
|
3139
4720
|
if (opts.watch) {
|
|
@@ -3144,9 +4725,18 @@ program.command("serve").description(
|
|
|
3144
4725
|
debounce = setTimeout(async () => {
|
|
3145
4726
|
try {
|
|
3146
4727
|
console.log(t("web.reloading"));
|
|
3147
|
-
const
|
|
4728
|
+
const newResult = await resolveGraphCli({
|
|
4729
|
+
target: opts.target,
|
|
4730
|
+
root: opts.root,
|
|
4731
|
+
exclude: opts.exclude,
|
|
4732
|
+
language
|
|
4733
|
+
});
|
|
3148
4734
|
viewer.close();
|
|
3149
|
-
startViewer(
|
|
4735
|
+
startViewer(newResult.graph, {
|
|
4736
|
+
port,
|
|
4737
|
+
layerMetadata: newResult.layerMetadata,
|
|
4738
|
+
crossLayerEdges: newResult.crossLayerEdges
|
|
4739
|
+
});
|
|
3150
4740
|
console.log(t("web.reloaded"));
|
|
3151
4741
|
} catch {
|
|
3152
4742
|
}
|
|
@@ -3178,15 +4768,53 @@ jobs:
|
|
|
3178
4768
|
- run: npx archtracker check --target ${opts.target} --ci
|
|
3179
4769
|
`;
|
|
3180
4770
|
try {
|
|
3181
|
-
const dir =
|
|
3182
|
-
await
|
|
3183
|
-
const path =
|
|
3184
|
-
await
|
|
4771
|
+
const dir = join8(".github", "workflows");
|
|
4772
|
+
await mkdir3(dir, { recursive: true });
|
|
4773
|
+
const path = join8(dir, "arch-check.yml");
|
|
4774
|
+
await writeFile3(path, workflow, "utf-8");
|
|
3185
4775
|
console.log(t("ci.generated", { path }));
|
|
3186
4776
|
} catch (error) {
|
|
3187
4777
|
handleError(error);
|
|
3188
4778
|
}
|
|
3189
4779
|
});
|
|
4780
|
+
var layersCmd = program.command("layers").description("Manage multi-layer architecture configuration");
|
|
4781
|
+
layersCmd.command("init").description("Create a template .archtracker/layers.json").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
|
|
4782
|
+
try {
|
|
4783
|
+
const existing = await loadLayerConfig(opts.root);
|
|
4784
|
+
if (existing) {
|
|
4785
|
+
console.log(t("layers.alreadyExists"));
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
const config = {
|
|
4789
|
+
version: "1.0",
|
|
4790
|
+
layers: [
|
|
4791
|
+
{ name: "Frontend", targetDir: "frontend", description: "UI layer" },
|
|
4792
|
+
{ name: "Backend", targetDir: "backend", description: "API layer" }
|
|
4793
|
+
]
|
|
4794
|
+
};
|
|
4795
|
+
await saveLayerConfig(opts.root, config);
|
|
4796
|
+
console.log(t("layers.created"));
|
|
4797
|
+
} catch (error) {
|
|
4798
|
+
handleError(error);
|
|
4799
|
+
}
|
|
4800
|
+
});
|
|
4801
|
+
layersCmd.command("list").description("List configured layers").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
|
|
4802
|
+
try {
|
|
4803
|
+
const config = await loadLayerConfig(opts.root);
|
|
4804
|
+
if (!config) {
|
|
4805
|
+
console.log(t("layers.notFound"));
|
|
4806
|
+
return;
|
|
4807
|
+
}
|
|
4808
|
+
console.log(t("layers.header", { count: config.layers.length }));
|
|
4809
|
+
for (const layer of config.layers) {
|
|
4810
|
+
const lang = layer.language ? ` [${layer.language}]` : "";
|
|
4811
|
+
const desc = layer.description ? ` \u2014 ${layer.description}` : "";
|
|
4812
|
+
console.log(` ${layer.name}: ${layer.targetDir}${lang}${desc}`);
|
|
4813
|
+
}
|
|
4814
|
+
} catch (error) {
|
|
4815
|
+
handleError(error);
|
|
4816
|
+
}
|
|
4817
|
+
});
|
|
3190
4818
|
function validateLanguage(lang) {
|
|
3191
4819
|
if (!lang) return void 0;
|
|
3192
4820
|
if (VALID_LANGUAGES.includes(lang)) return lang;
|