archtracker-mcp 0.5.0 → 0.7.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.ja.md +344 -0
- package/README.md +16 -321
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/cli/index.js +863 -1809
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.js +171 -109
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.js +629 -189
- package/dist/mcp/index.js.map +1 -1
- package/dist/web/viewer.js +1572 -0
- package/package.json +5 -3
- package/skills/arch-analyze/SKILL.md +2 -0
- package/skills/arch-check/SKILL.md +1 -0
- package/skills/arch-search/SKILL.md +4 -0
- package/skills/arch-snapshot/SKILL.md +1 -0
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 writeFile4, mkdir as mkdir4 } from "fs/promises";
|
|
7
|
+
import { join as join10 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/analyzer/analyze.ts
|
|
10
10
|
import { resolve as resolve3 } from "path";
|
|
@@ -1569,6 +1569,8 @@ var en = {
|
|
|
1569
1569
|
"diff.reasonRemoved": 'Dependency "{file}" was removed',
|
|
1570
1570
|
"diff.reasonModified": 'Dependency "{file}" had its dependencies changed',
|
|
1571
1571
|
"diff.reasonAdded": 'New dependency "{file}" was added',
|
|
1572
|
+
"diff.testSummary": " ... and {count} test/fixture file(s)",
|
|
1573
|
+
"diff.testAffectedSummary": " ... and {count} test/fixture-related review(s)",
|
|
1572
1574
|
// Search
|
|
1573
1575
|
"search.pathMatch": 'Path matches "{pattern}"',
|
|
1574
1576
|
"search.affected": 'May be affected by changes to "{file}" (via: {via})',
|
|
@@ -1630,6 +1632,12 @@ var en = {
|
|
|
1630
1632
|
"web.watching": "Watching {dir}/ for changes...",
|
|
1631
1633
|
"web.reloading": "File change detected, reloading...",
|
|
1632
1634
|
"web.reloaded": "Graph reloaded",
|
|
1635
|
+
// History
|
|
1636
|
+
"history.title": "# Snapshot History\n",
|
|
1637
|
+
"history.empty": "No snapshots found. Run `archtracker init` to create one.",
|
|
1638
|
+
"history.entry": " {ts} | {files} files, {edges} edges, {circular} circular{layers}",
|
|
1639
|
+
"history.count": "{count} snapshot(s) recorded",
|
|
1640
|
+
"history.snapshotNotFound": "Snapshot not found for timestamp: {ts}",
|
|
1633
1641
|
// Errors
|
|
1634
1642
|
"error.analyzer": "[Analysis Error] {message}",
|
|
1635
1643
|
"error.storage": "[Storage Error] {message}",
|
|
@@ -1662,6 +1670,8 @@ var ja = {
|
|
|
1662
1670
|
"diff.reasonRemoved": '\u4F9D\u5B58\u5148 "{file}" \u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F',
|
|
1663
1671
|
"diff.reasonModified": '\u4F9D\u5B58\u5148 "{file}" \u306E\u4F9D\u5B58\u95A2\u4FC2\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F',
|
|
1664
1672
|
"diff.reasonAdded": '\u65B0\u3057\u3044\u4F9D\u5B58\u5148 "{file}" \u304C\u8FFD\u52A0\u3055\u308C\u307E\u3057\u305F',
|
|
1673
|
+
"diff.testSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u30D5\u30A1\u30A4\u30EB",
|
|
1674
|
+
"diff.testAffectedSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u95A2\u9023\u306E\u78BA\u8A8D\u9805\u76EE",
|
|
1665
1675
|
// Search
|
|
1666
1676
|
"search.pathMatch": '\u30D1\u30B9\u304C "{pattern}" \u306B\u30DE\u30C3\u30C1',
|
|
1667
1677
|
"search.affected": '"{file}" \u306E\u5909\u66F4\u306B\u3088\u308A\u5F71\u97FF\u3092\u53D7\u3051\u308B\u53EF\u80FD\u6027\uFF08\u7D4C\u7531: {via}\uFF09',
|
|
@@ -1723,6 +1733,12 @@ var ja = {
|
|
|
1723
1733
|
"web.watching": "{dir}/ \u3092\u76E3\u8996\u4E2D...",
|
|
1724
1734
|
"web.reloading": "\u30D5\u30A1\u30A4\u30EB\u5909\u66F4\u3092\u691C\u51FA\u3001\u30EA\u30ED\u30FC\u30C9\u4E2D...",
|
|
1725
1735
|
"web.reloaded": "\u30B0\u30E9\u30D5\u3092\u66F4\u65B0\u3057\u307E\u3057\u305F",
|
|
1736
|
+
// History
|
|
1737
|
+
"history.title": "# \u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u5C65\u6B74\n",
|
|
1738
|
+
"history.empty": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002`archtracker init` \u3067\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
|
|
1739
|
+
"history.entry": " {ts} | {files}\u30D5\u30A1\u30A4\u30EB, {edges}\u30A8\u30C3\u30B8, \u5FAA\u74B0{circular}\u4EF6{layers}",
|
|
1740
|
+
"history.count": "{count}\u4EF6\u306E\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u8A18\u9332",
|
|
1741
|
+
"history.snapshotNotFound": "\u6307\u5B9A\u306E\u30BF\u30A4\u30E0\u30B9\u30BF\u30F3\u30D7\u306E\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {ts}",
|
|
1726
1742
|
// Errors
|
|
1727
1743
|
"error.analyzer": "[\u89E3\u6790\u30A8\u30E9\u30FC] {message}",
|
|
1728
1744
|
"error.storage": "[\u30B9\u30C8\u30EC\u30FC\u30B8\u30A8\u30E9\u30FC] {message}",
|
|
@@ -1807,14 +1823,14 @@ var LAYER_COLORS = [
|
|
|
1807
1823
|
"#ffa657",
|
|
1808
1824
|
"#7ee787"
|
|
1809
1825
|
];
|
|
1810
|
-
async function analyzeMultiLayer(projectRoot, layerDefs) {
|
|
1826
|
+
async function analyzeMultiLayer(projectRoot, layerDefs, globalExclude) {
|
|
1811
1827
|
const layers = {};
|
|
1812
1828
|
const layerMetadata = [];
|
|
1813
1829
|
for (let idx = 0; idx < layerDefs.length; idx++) {
|
|
1814
1830
|
const def = layerDefs[idx];
|
|
1815
1831
|
const targetDir = resolve4(projectRoot, def.targetDir);
|
|
1816
1832
|
const graph = await analyzeProject(targetDir, {
|
|
1817
|
-
exclude: def.exclude,
|
|
1833
|
+
exclude: def.exclude ?? globalExclude,
|
|
1818
1834
|
language: def.language
|
|
1819
1835
|
});
|
|
1820
1836
|
const language = def.language ?? await detectLanguage(targetDir) ?? "javascript";
|
|
@@ -2175,62 +2191,422 @@ function mergeLayerGraphs(projectRoot, layers) {
|
|
|
2175
2191
|
};
|
|
2176
2192
|
}
|
|
2177
2193
|
|
|
2178
|
-
// src/storage/
|
|
2179
|
-
import {
|
|
2194
|
+
// src/storage/layers.ts
|
|
2195
|
+
import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
|
|
2180
2196
|
import { join as join5 } from "path";
|
|
2181
2197
|
import { z } from "zod";
|
|
2198
|
+
var ARCHTRACKER_DIR = ".archtracker";
|
|
2199
|
+
var LAYERS_FILE = "layers.json";
|
|
2200
|
+
var LayerDefinitionSchema = z.object({
|
|
2201
|
+
name: z.string().min(1).regex(
|
|
2202
|
+
/^[a-zA-Z0-9_-]+$/,
|
|
2203
|
+
"Layer name must be alphanumeric (hyphens/underscores allowed)"
|
|
2204
|
+
),
|
|
2205
|
+
targetDir: z.string().min(1),
|
|
2206
|
+
language: z.enum(LANGUAGE_IDS).optional(),
|
|
2207
|
+
exclude: z.array(z.string()).optional(),
|
|
2208
|
+
color: z.string().optional(),
|
|
2209
|
+
description: z.string().optional()
|
|
2210
|
+
});
|
|
2211
|
+
var CrossLayerConnectionSchema = z.object({
|
|
2212
|
+
fromLayer: z.string(),
|
|
2213
|
+
fromFile: z.string(),
|
|
2214
|
+
toLayer: z.string(),
|
|
2215
|
+
toFile: z.string(),
|
|
2216
|
+
type: z.enum(["api-call", "event", "data-flow", "manual"]),
|
|
2217
|
+
label: z.string().optional()
|
|
2218
|
+
});
|
|
2219
|
+
var LayerConfigSchema = z.object({
|
|
2220
|
+
version: z.literal("1.0"),
|
|
2221
|
+
layers: z.array(LayerDefinitionSchema).min(1).refine(
|
|
2222
|
+
(layers) => {
|
|
2223
|
+
const names = layers.map((l) => l.name);
|
|
2224
|
+
return new Set(names).size === names.length;
|
|
2225
|
+
},
|
|
2226
|
+
{ message: "Layer names must be unique" }
|
|
2227
|
+
),
|
|
2228
|
+
connections: z.array(CrossLayerConnectionSchema).optional()
|
|
2229
|
+
});
|
|
2230
|
+
async function loadLayerConfig(projectRoot) {
|
|
2231
|
+
const filePath = join5(projectRoot, ARCHTRACKER_DIR, LAYERS_FILE);
|
|
2232
|
+
let raw;
|
|
2233
|
+
try {
|
|
2234
|
+
raw = await readFile2(filePath, "utf-8");
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
throw new Error(`Failed to read ${filePath}`);
|
|
2240
|
+
}
|
|
2241
|
+
let parsed;
|
|
2242
|
+
try {
|
|
2243
|
+
parsed = JSON.parse(raw);
|
|
2244
|
+
} catch {
|
|
2245
|
+
throw new Error(`Invalid JSON in ${filePath}`);
|
|
2246
|
+
}
|
|
2247
|
+
const result = LayerConfigSchema.safeParse(parsed);
|
|
2248
|
+
if (!result.success) {
|
|
2249
|
+
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
|
|
2250
|
+
throw new Error(`layers.json validation failed:
|
|
2251
|
+
${issues}`);
|
|
2252
|
+
}
|
|
2253
|
+
return result.data;
|
|
2254
|
+
}
|
|
2255
|
+
async function saveLayerConfig(projectRoot, config) {
|
|
2256
|
+
const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
|
|
2257
|
+
const filePath = join5(dirPath, LAYERS_FILE);
|
|
2258
|
+
await mkdir(dirPath, { recursive: true });
|
|
2259
|
+
await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2260
|
+
}
|
|
2261
|
+
function isNodeError(error) {
|
|
2262
|
+
return error instanceof Error && "code" in error;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// src/storage/graph-cache.ts
|
|
2266
|
+
import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, stat as stat4 } from "fs/promises";
|
|
2267
|
+
import { join as join6, resolve as resolve5 } from "path";
|
|
2268
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
2269
|
+
import { createHash } from "crypto";
|
|
2270
|
+
var CACHE_FILE = "graph.json";
|
|
2271
|
+
var ARCHTRACKER_DIR2 = ".archtracker";
|
|
2272
|
+
async function hashFile(filePath) {
|
|
2273
|
+
const content = await readFile3(filePath);
|
|
2274
|
+
return createHash("sha256").update(content).digest("hex");
|
|
2275
|
+
}
|
|
2276
|
+
async function collectFileFingerprints(dir, exclude = []) {
|
|
2277
|
+
const fingerprints = {};
|
|
2278
|
+
const excludeRegexes = exclude.map((p) => new RegExp(p));
|
|
2279
|
+
async function walk(currentDir) {
|
|
2280
|
+
let entries;
|
|
2281
|
+
try {
|
|
2282
|
+
entries = await readdir3(currentDir, { withFileTypes: true });
|
|
2283
|
+
} catch {
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
for (const entry of entries) {
|
|
2287
|
+
const fullPath = join6(currentDir, entry.name);
|
|
2288
|
+
const relativePath = fullPath.slice(dir.length + 1);
|
|
2289
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2290
|
+
if (excludeRegexes.some((r) => r.test(relativePath))) continue;
|
|
2291
|
+
if (entry.isDirectory()) {
|
|
2292
|
+
await walk(fullPath);
|
|
2293
|
+
} else if (entry.isFile()) {
|
|
2294
|
+
try {
|
|
2295
|
+
const s = await stat4(fullPath);
|
|
2296
|
+
const hash = await hashFile(fullPath);
|
|
2297
|
+
fingerprints[relativePath] = { mtime: s.mtimeMs, hash };
|
|
2298
|
+
} catch {
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
await walk(dir);
|
|
2304
|
+
return fingerprints;
|
|
2305
|
+
}
|
|
2306
|
+
async function saveGraphCache(projectRoot, graph, options, extra) {
|
|
2307
|
+
const absRoot = resolve5(projectRoot);
|
|
2308
|
+
const dir = join6(absRoot, ARCHTRACKER_DIR2);
|
|
2309
|
+
await mkdir2(dir, { recursive: true });
|
|
2310
|
+
let fingerprints;
|
|
2311
|
+
if (extra?.layerDirs?.length) {
|
|
2312
|
+
fingerprints = {};
|
|
2313
|
+
for (const layerDir of extra.layerDirs) {
|
|
2314
|
+
const layerPath = resolve5(absRoot, layerDir);
|
|
2315
|
+
const layerFp = await collectFileFingerprints(layerPath, options.exclude);
|
|
2316
|
+
for (const [key, value] of Object.entries(layerFp)) {
|
|
2317
|
+
fingerprints[`${layerDir}/${key}`] = value;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
} else {
|
|
2321
|
+
fingerprints = await collectFingerprintsForGraph(absRoot, options);
|
|
2322
|
+
}
|
|
2323
|
+
let layersJsonHash;
|
|
2324
|
+
try {
|
|
2325
|
+
layersJsonHash = await hashFile(join6(dir, "layers.json"));
|
|
2326
|
+
} catch {
|
|
2327
|
+
}
|
|
2328
|
+
const mtimes = {};
|
|
2329
|
+
for (const [k, v] of Object.entries(fingerprints)) {
|
|
2330
|
+
mtimes[k] = v.mtime;
|
|
2331
|
+
}
|
|
2332
|
+
const cache = {
|
|
2333
|
+
version: "1.0",
|
|
2334
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2335
|
+
options: {
|
|
2336
|
+
targetDir: options.targetDir,
|
|
2337
|
+
projectRoot: options.projectRoot,
|
|
2338
|
+
language: options.language,
|
|
2339
|
+
exclude: options.exclude
|
|
2340
|
+
},
|
|
2341
|
+
fileMtimes: mtimes,
|
|
2342
|
+
fileHashes: Object.fromEntries(
|
|
2343
|
+
Object.entries(fingerprints).map(([k, v]) => [k, v.hash])
|
|
2344
|
+
),
|
|
2345
|
+
graph,
|
|
2346
|
+
multiLayer: extra?.multiLayer,
|
|
2347
|
+
layerMetadata: extra?.layerMetadata,
|
|
2348
|
+
layerDirs: extra?.layerDirs,
|
|
2349
|
+
layersJsonHash
|
|
2350
|
+
};
|
|
2351
|
+
await writeFile2(join6(dir, CACHE_FILE), JSON.stringify(cache), "utf-8");
|
|
2352
|
+
}
|
|
2353
|
+
async function loadGraphCache(projectRoot) {
|
|
2354
|
+
const absRoot = resolve5(projectRoot);
|
|
2355
|
+
const filePath = join6(absRoot, ARCHTRACKER_DIR2, CACHE_FILE);
|
|
2356
|
+
try {
|
|
2357
|
+
const raw = await readFile3(filePath, "utf-8");
|
|
2358
|
+
const data = JSON.parse(raw);
|
|
2359
|
+
if (data?.version !== "1.0" || !data?.graph || !data?.fileMtimes) {
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
return data;
|
|
2363
|
+
} catch {
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
async function isGraphCacheValid(cache, projectRoot, options) {
|
|
2368
|
+
if (cache.options.targetDir !== options.targetDir || cache.options.projectRoot !== options.projectRoot || (cache.options.language ?? "") !== (options.language ?? "") || JSON.stringify(cache.options.exclude ?? []) !== JSON.stringify(options.exclude ?? [])) {
|
|
2369
|
+
return false;
|
|
2370
|
+
}
|
|
2371
|
+
const absRoot = resolve5(projectRoot);
|
|
2372
|
+
const targetPath = resolve5(absRoot, options.targetDir);
|
|
2373
|
+
const isMultiLayer = (cache.layerDirs?.length ?? 0) > 0;
|
|
2374
|
+
let currentLayersHash;
|
|
2375
|
+
try {
|
|
2376
|
+
currentLayersHash = await hashFile(join6(absRoot, ARCHTRACKER_DIR2, "layers.json"));
|
|
2377
|
+
} catch {
|
|
2378
|
+
}
|
|
2379
|
+
if ((cache.layersJsonHash ?? "") !== (currentLayersHash ?? "")) return false;
|
|
2380
|
+
let currentMtimes;
|
|
2381
|
+
if (isMultiLayer) {
|
|
2382
|
+
currentMtimes = {};
|
|
2383
|
+
for (const layerDir of cache.layerDirs) {
|
|
2384
|
+
const layerPath = resolve5(absRoot, layerDir);
|
|
2385
|
+
const dirMtimes = await collectFileMtimes(layerPath, options.exclude);
|
|
2386
|
+
for (const [key, value] of Object.entries(dirMtimes)) {
|
|
2387
|
+
currentMtimes[`${layerDir}/${key}`] = value;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
} else {
|
|
2391
|
+
currentMtimes = await collectFileMtimes(targetPath, options.exclude);
|
|
2392
|
+
}
|
|
2393
|
+
const cachedKeys = new Set(Object.keys(cache.fileMtimes));
|
|
2394
|
+
const currentKeys = new Set(Object.keys(currentMtimes));
|
|
2395
|
+
if (cachedKeys.size !== currentKeys.size) return false;
|
|
2396
|
+
for (const key of cachedKeys) {
|
|
2397
|
+
if (!currentKeys.has(key)) return false;
|
|
2398
|
+
}
|
|
2399
|
+
const mtimeChanged = [];
|
|
2400
|
+
for (const key of cachedKeys) {
|
|
2401
|
+
if (Math.abs(cache.fileMtimes[key] - currentMtimes[key]) > 1) {
|
|
2402
|
+
mtimeChanged.push(key);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
if (mtimeChanged.length === 0) return true;
|
|
2406
|
+
if (!cache.fileHashes) return false;
|
|
2407
|
+
for (const key of mtimeChanged) {
|
|
2408
|
+
const cachedHash = cache.fileHashes[key];
|
|
2409
|
+
if (!cachedHash) return false;
|
|
2410
|
+
try {
|
|
2411
|
+
const fullPath = isMultiLayer ? join6(absRoot, key) : join6(targetPath, key);
|
|
2412
|
+
const currentHash = await hashFile(fullPath);
|
|
2413
|
+
if (currentHash !== cachedHash) return false;
|
|
2414
|
+
} catch {
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return true;
|
|
2419
|
+
}
|
|
2420
|
+
async function collectFileMtimes(dir, exclude) {
|
|
2421
|
+
const mtimes = {};
|
|
2422
|
+
const excludeRegexes = (exclude ?? []).map((p) => new RegExp(p));
|
|
2423
|
+
async function walk(currentDir) {
|
|
2424
|
+
let entries;
|
|
2425
|
+
try {
|
|
2426
|
+
entries = await readdir3(currentDir, { withFileTypes: true });
|
|
2427
|
+
} catch {
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
for (const entry of entries) {
|
|
2431
|
+
const fullPath = join6(currentDir, entry.name);
|
|
2432
|
+
const relativePath = fullPath.slice(dir.length + 1);
|
|
2433
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2434
|
+
if (excludeRegexes.some((r) => r.test(relativePath))) continue;
|
|
2435
|
+
if (entry.isDirectory()) {
|
|
2436
|
+
await walk(fullPath);
|
|
2437
|
+
} else if (entry.isFile()) {
|
|
2438
|
+
try {
|
|
2439
|
+
const s = await stat4(fullPath);
|
|
2440
|
+
mtimes[relativePath] = s.mtimeMs;
|
|
2441
|
+
} catch {
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
await walk(dir);
|
|
2447
|
+
return mtimes;
|
|
2448
|
+
}
|
|
2449
|
+
async function collectFingerprintsForGraph(absRoot, options) {
|
|
2450
|
+
const targetPath = resolve5(absRoot, options.targetDir);
|
|
2451
|
+
return collectFileFingerprints(targetPath, options.exclude);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// src/analyzer/resolve.ts
|
|
2455
|
+
async function resolveGraph(opts) {
|
|
2456
|
+
if (!opts.noCache) {
|
|
2457
|
+
const cache = await loadGraphCache(opts.projectRoot);
|
|
2458
|
+
if (cache) {
|
|
2459
|
+
const valid = await isGraphCacheValid(cache, opts.projectRoot, {
|
|
2460
|
+
targetDir: opts.targetDir,
|
|
2461
|
+
projectRoot: opts.projectRoot,
|
|
2462
|
+
language: opts.language,
|
|
2463
|
+
exclude: opts.exclude
|
|
2464
|
+
});
|
|
2465
|
+
if (valid) {
|
|
2466
|
+
const result2 = {
|
|
2467
|
+
graph: cache.graph,
|
|
2468
|
+
multiLayer: cache.multiLayer,
|
|
2469
|
+
layerMetadata: cache.layerMetadata,
|
|
2470
|
+
fromCache: true
|
|
2471
|
+
};
|
|
2472
|
+
if (cache.multiLayer) {
|
|
2473
|
+
const layerConfig2 = await loadLayerConfig(opts.projectRoot);
|
|
2474
|
+
if (layerConfig2) {
|
|
2475
|
+
const autoConnections = detectCrossLayerConnections(
|
|
2476
|
+
cache.multiLayer.layers,
|
|
2477
|
+
layerConfig2.layers
|
|
2478
|
+
);
|
|
2479
|
+
const manualConnections = layerConfig2.connections ?? [];
|
|
2480
|
+
const manualKeys = new Set(manualConnections.map(
|
|
2481
|
+
(c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
|
|
2482
|
+
));
|
|
2483
|
+
result2.crossLayerEdges = [
|
|
2484
|
+
...manualConnections,
|
|
2485
|
+
...autoConnections.filter(
|
|
2486
|
+
(c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
|
|
2487
|
+
)
|
|
2488
|
+
];
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
return result2;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
const layerConfig = await loadLayerConfig(opts.projectRoot);
|
|
2496
|
+
let result;
|
|
2497
|
+
if (layerConfig) {
|
|
2498
|
+
const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers, opts.exclude);
|
|
2499
|
+
const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
|
|
2500
|
+
const manualConnections = layerConfig.connections ?? [];
|
|
2501
|
+
const manualKeys = new Set(manualConnections.map(
|
|
2502
|
+
(c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
|
|
2503
|
+
));
|
|
2504
|
+
const merged = [
|
|
2505
|
+
...manualConnections,
|
|
2506
|
+
...autoConnections.filter(
|
|
2507
|
+
(c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
|
|
2508
|
+
)
|
|
2509
|
+
];
|
|
2510
|
+
result = {
|
|
2511
|
+
graph: multi.merged,
|
|
2512
|
+
multiLayer: multi,
|
|
2513
|
+
layerMetadata: multi.layerMetadata,
|
|
2514
|
+
crossLayerEdges: merged
|
|
2515
|
+
};
|
|
2516
|
+
} else {
|
|
2517
|
+
const graph = await analyzeProject(opts.targetDir, {
|
|
2518
|
+
exclude: opts.exclude,
|
|
2519
|
+
language: opts.language
|
|
2520
|
+
});
|
|
2521
|
+
result = { graph };
|
|
2522
|
+
}
|
|
2523
|
+
try {
|
|
2524
|
+
await saveGraphCache(
|
|
2525
|
+
opts.projectRoot,
|
|
2526
|
+
result.graph,
|
|
2527
|
+
{
|
|
2528
|
+
targetDir: opts.targetDir,
|
|
2529
|
+
projectRoot: opts.projectRoot,
|
|
2530
|
+
language: opts.language,
|
|
2531
|
+
exclude: opts.exclude
|
|
2532
|
+
},
|
|
2533
|
+
{
|
|
2534
|
+
multiLayer: result.multiLayer,
|
|
2535
|
+
layerMetadata: result.layerMetadata,
|
|
2536
|
+
layerDirs: layerConfig?.layers.map((l) => l.targetDir)
|
|
2537
|
+
}
|
|
2538
|
+
);
|
|
2539
|
+
} catch {
|
|
2540
|
+
}
|
|
2541
|
+
return result;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// src/storage/snapshot.ts
|
|
2545
|
+
import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, readdir as readdir4, access } from "fs/promises";
|
|
2546
|
+
import { join as join7 } from "path";
|
|
2547
|
+
import { z as z2 } from "zod";
|
|
2182
2548
|
|
|
2183
2549
|
// src/types/schema.ts
|
|
2184
2550
|
var SCHEMA_VERSION = "1.1";
|
|
2185
2551
|
|
|
2186
2552
|
// src/storage/snapshot.ts
|
|
2187
|
-
var
|
|
2553
|
+
var ARCHTRACKER_DIR3 = ".archtracker";
|
|
2188
2554
|
var SNAPSHOT_FILE = "snapshot.json";
|
|
2189
|
-
var
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2555
|
+
var HISTORY_DIR = "history";
|
|
2556
|
+
var FileNodeSchema = z2.object({
|
|
2557
|
+
path: z2.string(),
|
|
2558
|
+
exists: z2.boolean(),
|
|
2559
|
+
dependencies: z2.array(z2.string()),
|
|
2560
|
+
dependents: z2.array(z2.string())
|
|
2194
2561
|
});
|
|
2195
|
-
var DependencyGraphSchema =
|
|
2196
|
-
rootDir:
|
|
2197
|
-
files:
|
|
2198
|
-
edges:
|
|
2199
|
-
source:
|
|
2200
|
-
target:
|
|
2201
|
-
type:
|
|
2562
|
+
var DependencyGraphSchema = z2.object({
|
|
2563
|
+
rootDir: z2.string(),
|
|
2564
|
+
files: z2.record(z2.string(), FileNodeSchema),
|
|
2565
|
+
edges: z2.array(z2.object({
|
|
2566
|
+
source: z2.string(),
|
|
2567
|
+
target: z2.string(),
|
|
2568
|
+
type: z2.enum(["static", "dynamic", "type-only"])
|
|
2202
2569
|
})),
|
|
2203
|
-
circularDependencies:
|
|
2204
|
-
totalFiles:
|
|
2205
|
-
totalEdges:
|
|
2570
|
+
circularDependencies: z2.array(z2.object({ cycle: z2.array(z2.string()) })),
|
|
2571
|
+
totalFiles: z2.number(),
|
|
2572
|
+
totalEdges: z2.number()
|
|
2206
2573
|
});
|
|
2207
|
-
var SnapshotSchema =
|
|
2208
|
-
version:
|
|
2209
|
-
timestamp:
|
|
2210
|
-
rootDir:
|
|
2574
|
+
var SnapshotSchema = z2.object({
|
|
2575
|
+
version: z2.enum([SCHEMA_VERSION, "1.0"]),
|
|
2576
|
+
timestamp: z2.string(),
|
|
2577
|
+
rootDir: z2.string(),
|
|
2211
2578
|
graph: DependencyGraphSchema
|
|
2212
2579
|
});
|
|
2213
|
-
async function saveSnapshot(projectRoot, graph, multiLayer) {
|
|
2214
|
-
const dirPath =
|
|
2215
|
-
const filePath =
|
|
2580
|
+
async function saveSnapshot(projectRoot, graph, multiLayer, analysisOptions) {
|
|
2581
|
+
const dirPath = join7(projectRoot, ARCHTRACKER_DIR3);
|
|
2582
|
+
const filePath = join7(dirPath, SNAPSHOT_FILE);
|
|
2216
2583
|
const snapshot = {
|
|
2217
2584
|
version: SCHEMA_VERSION,
|
|
2218
2585
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2219
2586
|
rootDir: graph.rootDir,
|
|
2220
2587
|
graph,
|
|
2221
|
-
...multiLayer ? { multiLayer } : {}
|
|
2588
|
+
...multiLayer ? { multiLayer } : {},
|
|
2589
|
+
...analysisOptions ? { analysisOptions } : {}
|
|
2222
2590
|
};
|
|
2223
|
-
await
|
|
2224
|
-
|
|
2591
|
+
await mkdir3(dirPath, { recursive: true });
|
|
2592
|
+
const json = JSON.stringify(snapshot, null, 2);
|
|
2593
|
+
await writeFile3(filePath, json, "utf-8");
|
|
2594
|
+
try {
|
|
2595
|
+
const historyPath = join7(dirPath, HISTORY_DIR);
|
|
2596
|
+
await mkdir3(historyPath, { recursive: true });
|
|
2597
|
+
const safeTs = snapshot.timestamp.replace(/[:.]/g, "-");
|
|
2598
|
+
await writeFile3(join7(historyPath, `${safeTs}.json`), json, "utf-8");
|
|
2599
|
+
} catch {
|
|
2600
|
+
}
|
|
2225
2601
|
return snapshot;
|
|
2226
2602
|
}
|
|
2227
2603
|
async function loadSnapshot(projectRoot) {
|
|
2228
|
-
const filePath =
|
|
2604
|
+
const filePath = join7(projectRoot, ARCHTRACKER_DIR3, SNAPSHOT_FILE);
|
|
2229
2605
|
let raw;
|
|
2230
2606
|
try {
|
|
2231
|
-
raw = await
|
|
2607
|
+
raw = await readFile4(filePath, "utf-8");
|
|
2232
2608
|
} catch (error) {
|
|
2233
|
-
if (
|
|
2609
|
+
if (isNodeError2(error) && error.code === "ENOENT") {
|
|
2234
2610
|
return null;
|
|
2235
2611
|
}
|
|
2236
2612
|
throw new StorageError(
|
|
@@ -2255,13 +2631,74 @@ async function loadSnapshot(projectRoot) {
|
|
|
2255
2631
|
}
|
|
2256
2632
|
return result.data;
|
|
2257
2633
|
}
|
|
2634
|
+
async function listSnapshots(projectRoot) {
|
|
2635
|
+
const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
|
|
2636
|
+
let entries;
|
|
2637
|
+
try {
|
|
2638
|
+
entries = await readdir4(historyPath);
|
|
2639
|
+
} catch {
|
|
2640
|
+
return [];
|
|
2641
|
+
}
|
|
2642
|
+
const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort().reverse();
|
|
2643
|
+
const summaries = [];
|
|
2644
|
+
for (const file of jsonFiles) {
|
|
2645
|
+
try {
|
|
2646
|
+
const raw = await readFile4(join7(historyPath, file), "utf-8");
|
|
2647
|
+
const parsed = JSON.parse(raw);
|
|
2648
|
+
const result = SnapshotSchema.safeParse(parsed);
|
|
2649
|
+
if (result.success) {
|
|
2650
|
+
const snap = result.data;
|
|
2651
|
+
summaries.push({
|
|
2652
|
+
timestamp: snap.timestamp,
|
|
2653
|
+
totalFiles: snap.graph.totalFiles,
|
|
2654
|
+
totalEdges: snap.graph.totalEdges,
|
|
2655
|
+
circularDeps: snap.graph.circularDependencies.length,
|
|
2656
|
+
hasMultiLayer: "multiLayer" in parsed && parsed.multiLayer != null
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
} catch {
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
return summaries;
|
|
2663
|
+
}
|
|
2664
|
+
async function loadSnapshotByTimestamp(projectRoot, timestamp) {
|
|
2665
|
+
const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
|
|
2666
|
+
let entries;
|
|
2667
|
+
try {
|
|
2668
|
+
entries = await readdir4(historyPath);
|
|
2669
|
+
} catch {
|
|
2670
|
+
return null;
|
|
2671
|
+
}
|
|
2672
|
+
const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort();
|
|
2673
|
+
const safeTs = timestamp.replace(/[:.]/g, "-");
|
|
2674
|
+
const exactMatch = jsonFiles.find((f) => f === `${safeTs}.json`);
|
|
2675
|
+
if (exactMatch) {
|
|
2676
|
+
return loadHistoryFile(join7(historyPath, exactMatch));
|
|
2677
|
+
}
|
|
2678
|
+
const prefixMatch = jsonFiles.find((f) => f.startsWith(safeTs.slice(0, 10)));
|
|
2679
|
+
if (prefixMatch) {
|
|
2680
|
+
return loadHistoryFile(join7(historyPath, prefixMatch));
|
|
2681
|
+
}
|
|
2682
|
+
return null;
|
|
2683
|
+
}
|
|
2684
|
+
async function loadHistoryFile(filePath) {
|
|
2685
|
+
try {
|
|
2686
|
+
const raw = await readFile4(filePath, "utf-8");
|
|
2687
|
+
const parsed = JSON.parse(raw);
|
|
2688
|
+
const result = SnapshotSchema.safeParse(parsed);
|
|
2689
|
+
if (result.success) return result.data;
|
|
2690
|
+
return null;
|
|
2691
|
+
} catch {
|
|
2692
|
+
return null;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2258
2695
|
var StorageError = class extends Error {
|
|
2259
2696
|
constructor(message, options) {
|
|
2260
2697
|
super(message, options);
|
|
2261
2698
|
this.name = "StorageError";
|
|
2262
2699
|
}
|
|
2263
2700
|
};
|
|
2264
|
-
function
|
|
2701
|
+
function isNodeError2(error) {
|
|
2265
2702
|
return error instanceof Error && "code" in error;
|
|
2266
2703
|
}
|
|
2267
2704
|
|
|
@@ -2316,6 +2753,17 @@ function computeDiff(oldGraph, newGraph) {
|
|
|
2316
2753
|
}
|
|
2317
2754
|
return { added, removed, modified, affectedDependents };
|
|
2318
2755
|
}
|
|
2756
|
+
function isTestOrFixture(path) {
|
|
2757
|
+
return /(__fixtures__|__tests__|__mocks__|\.test\.|\.spec\.|\.e2e\.)/.test(path);
|
|
2758
|
+
}
|
|
2759
|
+
function partition(arr, pred) {
|
|
2760
|
+
const yes = [];
|
|
2761
|
+
const no = [];
|
|
2762
|
+
for (const item of arr) {
|
|
2763
|
+
(pred(item) ? yes : no).push(item);
|
|
2764
|
+
}
|
|
2765
|
+
return [yes, no];
|
|
2766
|
+
}
|
|
2319
2767
|
function formatDiffReport(diff) {
|
|
2320
2768
|
const lines = [];
|
|
2321
2769
|
lines.push(t("diff.title"));
|
|
@@ -2324,32 +2772,51 @@ function formatDiffReport(diff) {
|
|
|
2324
2772
|
return lines.join("\n");
|
|
2325
2773
|
}
|
|
2326
2774
|
if (diff.added.length > 0) {
|
|
2775
|
+
const [testFiles, srcFiles] = partition(diff.added, isTestOrFixture);
|
|
2327
2776
|
lines.push(t("diff.added", { count: diff.added.length }));
|
|
2328
|
-
for (const f of
|
|
2777
|
+
for (const f of srcFiles) {
|
|
2329
2778
|
lines.push(` + ${f}`);
|
|
2330
2779
|
}
|
|
2780
|
+
if (testFiles.length > 0) {
|
|
2781
|
+
lines.push(t("diff.testSummary", { count: testFiles.length }));
|
|
2782
|
+
}
|
|
2331
2783
|
lines.push("");
|
|
2332
2784
|
}
|
|
2333
2785
|
if (diff.removed.length > 0) {
|
|
2786
|
+
const [testFiles, srcFiles] = partition(diff.removed, isTestOrFixture);
|
|
2334
2787
|
lines.push(t("diff.removed", { count: diff.removed.length }));
|
|
2335
|
-
for (const f of
|
|
2788
|
+
for (const f of srcFiles) {
|
|
2336
2789
|
lines.push(` - ${f}`);
|
|
2337
2790
|
}
|
|
2791
|
+
if (testFiles.length > 0) {
|
|
2792
|
+
lines.push(t("diff.testSummary", { count: testFiles.length }));
|
|
2793
|
+
}
|
|
2338
2794
|
lines.push("");
|
|
2339
2795
|
}
|
|
2340
2796
|
if (diff.modified.length > 0) {
|
|
2797
|
+
const [testFiles, srcFiles] = partition(diff.modified, isTestOrFixture);
|
|
2341
2798
|
lines.push(t("diff.modified", { count: diff.modified.length }));
|
|
2342
|
-
for (const f of
|
|
2799
|
+
for (const f of srcFiles) {
|
|
2343
2800
|
lines.push(` ~ ${f}`);
|
|
2344
2801
|
}
|
|
2802
|
+
if (testFiles.length > 0) {
|
|
2803
|
+
lines.push(t("diff.testSummary", { count: testFiles.length }));
|
|
2804
|
+
}
|
|
2345
2805
|
lines.push("");
|
|
2346
2806
|
}
|
|
2347
2807
|
if (diff.affectedDependents.length > 0) {
|
|
2808
|
+
const [testEntries, srcEntries] = partition(
|
|
2809
|
+
diff.affectedDependents,
|
|
2810
|
+
(a) => isTestOrFixture(a.file) || isTestOrFixture(a.dependsOn)
|
|
2811
|
+
);
|
|
2348
2812
|
lines.push(t("diff.affected", { count: diff.affectedDependents.length }));
|
|
2349
|
-
for (const a of
|
|
2813
|
+
for (const a of srcEntries) {
|
|
2350
2814
|
lines.push(` ! ${a.file}`);
|
|
2351
2815
|
lines.push(` ${a.reason}`);
|
|
2352
2816
|
}
|
|
2817
|
+
if (testEntries.length > 0) {
|
|
2818
|
+
lines.push(t("diff.testAffectedSummary", { count: testEntries.length }));
|
|
2819
|
+
}
|
|
2353
2820
|
lines.push("");
|
|
2354
2821
|
}
|
|
2355
2822
|
return lines.join("\n");
|
|
@@ -2362,121 +2829,17 @@ function arraysEqual(a, b) {
|
|
|
2362
2829
|
return true;
|
|
2363
2830
|
}
|
|
2364
2831
|
|
|
2365
|
-
// src/storage/layers.ts
|
|
2366
|
-
import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
2367
|
-
import { join as join6 } from "path";
|
|
2368
|
-
import { z as z2 } from "zod";
|
|
2369
|
-
var ARCHTRACKER_DIR2 = ".archtracker";
|
|
2370
|
-
var LAYERS_FILE = "layers.json";
|
|
2371
|
-
var LayerDefinitionSchema = z2.object({
|
|
2372
|
-
name: z2.string().min(1).regex(
|
|
2373
|
-
/^[a-zA-Z0-9_-]+$/,
|
|
2374
|
-
"Layer name must be alphanumeric (hyphens/underscores allowed)"
|
|
2375
|
-
),
|
|
2376
|
-
targetDir: z2.string().min(1),
|
|
2377
|
-
language: z2.enum(LANGUAGE_IDS).optional(),
|
|
2378
|
-
exclude: z2.array(z2.string()).optional(),
|
|
2379
|
-
color: z2.string().optional(),
|
|
2380
|
-
description: z2.string().optional()
|
|
2381
|
-
});
|
|
2382
|
-
var CrossLayerConnectionSchema = z2.object({
|
|
2383
|
-
fromLayer: z2.string(),
|
|
2384
|
-
fromFile: z2.string(),
|
|
2385
|
-
toLayer: z2.string(),
|
|
2386
|
-
toFile: z2.string(),
|
|
2387
|
-
type: z2.enum(["api-call", "event", "data-flow", "manual"]),
|
|
2388
|
-
label: z2.string().optional()
|
|
2389
|
-
});
|
|
2390
|
-
var LayerConfigSchema = z2.object({
|
|
2391
|
-
version: z2.literal("1.0"),
|
|
2392
|
-
layers: z2.array(LayerDefinitionSchema).min(1).refine(
|
|
2393
|
-
(layers) => {
|
|
2394
|
-
const names = layers.map((l) => l.name);
|
|
2395
|
-
return new Set(names).size === names.length;
|
|
2396
|
-
},
|
|
2397
|
-
{ message: "Layer names must be unique" }
|
|
2398
|
-
),
|
|
2399
|
-
connections: z2.array(CrossLayerConnectionSchema).optional()
|
|
2400
|
-
});
|
|
2401
|
-
async function loadLayerConfig(projectRoot) {
|
|
2402
|
-
const filePath = join6(projectRoot, ARCHTRACKER_DIR2, LAYERS_FILE);
|
|
2403
|
-
let raw;
|
|
2404
|
-
try {
|
|
2405
|
-
raw = await readFile3(filePath, "utf-8");
|
|
2406
|
-
} catch (error) {
|
|
2407
|
-
if (isNodeError2(error) && error.code === "ENOENT") {
|
|
2408
|
-
return null;
|
|
2409
|
-
}
|
|
2410
|
-
throw new Error(`Failed to read ${filePath}`);
|
|
2411
|
-
}
|
|
2412
|
-
let parsed;
|
|
2413
|
-
try {
|
|
2414
|
-
parsed = JSON.parse(raw);
|
|
2415
|
-
} catch {
|
|
2416
|
-
throw new Error(`Invalid JSON in ${filePath}`);
|
|
2417
|
-
}
|
|
2418
|
-
const result = LayerConfigSchema.safeParse(parsed);
|
|
2419
|
-
if (!result.success) {
|
|
2420
|
-
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
|
|
2421
|
-
throw new Error(`layers.json validation failed:
|
|
2422
|
-
${issues}`);
|
|
2423
|
-
}
|
|
2424
|
-
return result.data;
|
|
2425
|
-
}
|
|
2426
|
-
async function saveLayerConfig(projectRoot, config) {
|
|
2427
|
-
const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
|
|
2428
|
-
const filePath = join6(dirPath, LAYERS_FILE);
|
|
2429
|
-
await mkdir2(dirPath, { recursive: true });
|
|
2430
|
-
await writeFile2(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2431
|
-
}
|
|
2432
|
-
function isNodeError2(error) {
|
|
2433
|
-
return error instanceof Error && "code" in error;
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2436
2832
|
// src/web/server.ts
|
|
2437
2833
|
import { createServer } from "http";
|
|
2438
2834
|
|
|
2439
2835
|
// src/web/template.ts
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
id: f.path,
|
|
2448
|
-
deps: f.dependencies.length,
|
|
2449
|
-
dependents: f.dependents.length,
|
|
2450
|
-
dependencies: f.dependencies,
|
|
2451
|
-
dependentsList: f.dependents,
|
|
2452
|
-
isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
|
|
2453
|
-
dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".",
|
|
2454
|
-
layer: layers && f.path.includes("/") ? f.path.substring(0, f.path.indexOf("/")) : null
|
|
2455
|
-
}));
|
|
2456
|
-
const links = graph.edges.map((e) => ({
|
|
2457
|
-
source: e.source,
|
|
2458
|
-
target: e.target,
|
|
2459
|
-
type: e.type
|
|
2460
|
-
}));
|
|
2461
|
-
const circularFiles = /* @__PURE__ */ new Set();
|
|
2462
|
-
for (const c of graph.circularDependencies) {
|
|
2463
|
-
for (const f of c.cycle) circularFiles.add(f);
|
|
2464
|
-
}
|
|
2465
|
-
const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
|
|
2466
|
-
const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
|
|
2467
|
-
const diffData = diff ? JSON.stringify(diff) : "null";
|
|
2468
|
-
const layersData = layers ? JSON.stringify(layers) : "null";
|
|
2469
|
-
const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
|
|
2470
|
-
const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
|
|
2471
|
-
return (
|
|
2472
|
-
/* html */
|
|
2473
|
-
`<!DOCTYPE html>
|
|
2474
|
-
<html lang="${locale}">
|
|
2475
|
-
<head>
|
|
2476
|
-
<meta charset="utf-8">
|
|
2477
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2478
|
-
<title>${projectName} \u2014 Architecture Viewer</title>
|
|
2479
|
-
<style>
|
|
2836
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2837
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
2838
|
+
import { fileURLToPath } from "url";
|
|
2839
|
+
|
|
2840
|
+
// src/web/styles.ts
|
|
2841
|
+
function buildStyles() {
|
|
2842
|
+
return `<style>
|
|
2480
2843
|
:root {
|
|
2481
2844
|
--bg: #0d1117; --bg-card: #161b22; --bg-hover: #1c2129;
|
|
2482
2845
|
--border: #30363d; --border-active: #58a6ff;
|
|
@@ -2606,6 +2969,9 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2606
2969
|
#impact-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
|
|
2607
2970
|
#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); }
|
|
2608
2971
|
|
|
2972
|
+
/* \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 */
|
|
2973
|
+
#diff-focus-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
|
|
2974
|
+
|
|
2609
2975
|
/* \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 */
|
|
2610
2976
|
#help-bar { position: absolute; bottom: 12px; right: 12px; z-index: 10; font-size: 11px; color: var(--text-muted); background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; transition: background 0.3s; }
|
|
2611
2977
|
|
|
@@ -2615,1618 +2981,276 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
|
|
|
2615
2981
|
|
|
2616
2982
|
/* \u2500\u2500\u2500 Layer tabs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2617
2983
|
#layer-tabs { display: flex; gap: 2px; margin-left: 12px; padding-left: 12px; border-left: 1px solid var(--border); }
|
|
2618
|
-
.layer-tab { padding: 4px 10px; font-size: 11px; color: var(--text-dim); cursor: pointer; border-radius: 4px; border: 1px solid transparent; transition: all 0.15s; user-select: none; display: flex; align-items: center; gap: 5px; }
|
|
2619
|
-
.layer-tab:hover { color: var(--text); background: var(--bg-hover); }
|
|
2620
|
-
.layer-tab.active { border-color: var(--accent); color: var(--text); }
|
|
2621
|
-
.layer-tab .lt-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2622
|
-
|
|
2623
|
-
/* \u2500\u2500\u2500 Layer filter pills \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
2624
|
-
.layer-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 2px 9px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
|
|
2625
|
-
.layer-pill:hover { border-color: var(--text-dim); }
|
|
2626
|
-
.layer-pill.active { border-color: var(--accent); }
|
|
2627
|
-
.layer-pill .lp-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2628
|
-
.layer-pill .lp-count { color: var(--text-muted); font-size: 9px; font-weight: 400; }
|
|
2629
|
-
</style>
|
|
2630
|
-
</head>
|
|
2631
|
-
<body>
|
|
2632
|
-
|
|
2633
|
-
<!-- Tab bar -->
|
|
2634
|
-
<div id="tab-bar">
|
|
2635
|
-
<span class="logo" id="project-title" contenteditable="true" spellcheck="false" title="Click to edit project name"></span>
|
|
2636
|
-
<div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
|
|
2637
|
-
<div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
|
|
2638
|
-
<div class="tab" data-view="diff-view" id="diff-tab" style="display:none" data-i18n="tab.diff">Diff</div>
|
|
2639
|
-
<div id="layer-tabs"></div>
|
|
2640
|
-
<div class="tab-right">
|
|
2641
|
-
<div class="tab-stats">
|
|
2642
|
-
<span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
|
|
2643
|
-
<span><span data-i18n="stats.edges">Edges</span> <b id="s-edges">0</b></span>
|
|
2644
|
-
<span><span data-i18n="stats.circular">Circular</span> <b id="s-circular">0</b></span>
|
|
2645
|
-
</div>
|
|
2646
|
-
<button class="settings-btn" onclick="toggleSettings()" title="Settings">\u2699</button>
|
|
2647
|
-
</div>
|
|
2648
|
-
</div>
|
|
2649
|
-
|
|
2650
|
-
<!-- Settings panel -->
|
|
2651
|
-
<div id="settings-panel">
|
|
2652
|
-
<h3 data-i18n="settings.title">Settings</h3>
|
|
2653
|
-
<div class="setting-group">
|
|
2654
|
-
<label data-i18n="settings.theme">Theme</label>
|
|
2655
|
-
<div class="theme-toggle">
|
|
2656
|
-
<div class="theme-btn active" data-theme-val="dark" onclick="setTheme('dark')">\u{1F319} Dark</div>
|
|
2657
|
-
<div class="theme-btn" data-theme-val="light" onclick="setTheme('light')">\u2600\uFE0F Light</div>
|
|
2658
|
-
</div>
|
|
2659
|
-
</div>
|
|
2660
|
-
<div class="setting-group">
|
|
2661
|
-
<label data-i18n="settings.fontSize">Font Size</label>
|
|
2662
|
-
<input type="range" id="font-size-slider" min="10" max="18" value="13" oninput="setFontSize(this.value)">
|
|
2663
|
-
<div class="setting-value"><span id="font-size-val">13</span>px</div>
|
|
2664
|
-
</div>
|
|
2665
|
-
<div class="setting-group">
|
|
2666
|
-
<label data-i18n="settings.nodeSize">Node Size</label>
|
|
2667
|
-
<input type="range" id="node-size-slider" min="50" max="200" value="100" oninput="setNodeScale(this.value)">
|
|
2668
|
-
<div class="setting-value"><span id="node-size-val">100</span>%</div>
|
|
2669
|
-
</div>
|
|
2670
|
-
<div class="setting-group">
|
|
2671
|
-
<label data-i18n="settings.linkOpacity">Link Opacity</label>
|
|
2672
|
-
<input type="range" id="link-opacity-slider" min="10" max="100" value="40" oninput="setLinkOpacity(this.value)">
|
|
2673
|
-
<div class="setting-value"><span id="link-opacity-val">40</span>%</div>
|
|
2674
|
-
</div>
|
|
2675
|
-
<div class="setting-group">
|
|
2676
|
-
<label data-i18n="settings.gravity">Gravity</label>
|
|
2677
|
-
<input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
|
|
2678
|
-
<div class="setting-value"><span id="gravity-val">150</span></div>
|
|
2679
|
-
</div>
|
|
2680
|
-
<div id="layer-gravity-setting" class="setting-group" style="display:none">
|
|
2681
|
-
<label>Layer Cohesion</label>
|
|
2682
|
-
<input type="range" id="layer-gravity-slider" min="1" max="40" value="12" oninput="setLayerGravity(this.value)">
|
|
2683
|
-
<div class="setting-value"><span id="layer-gravity-val">12</span></div>
|
|
2684
|
-
</div>
|
|
2685
|
-
<div class="setting-group">
|
|
2686
|
-
<label data-i18n="settings.language">Language</label>
|
|
2687
|
-
<div class="theme-toggle">
|
|
2688
|
-
<div class="theme-btn lang-btn" data-lang="en" onclick="setLang('en')">English</div>
|
|
2689
|
-
<div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
|
|
2690
|
-
</div>
|
|
2691
|
-
</div>
|
|
2692
|
-
<div id="cross-layer-setting" class="setting-group" style="display:none">
|
|
2693
|
-
<label>Cross-layer Links</label>
|
|
2694
|
-
<div class="theme-toggle">
|
|
2695
|
-
<div class="theme-btn active" id="cross-link-toggle" onclick="toggleCrossLinks()">ON</div>
|
|
2696
|
-
</div>
|
|
2697
|
-
</div>
|
|
2698
|
-
<div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
|
2699
|
-
<label data-i18n="settings.export">Export</label>
|
|
2700
|
-
<div class="theme-toggle">
|
|
2701
|
-
<div class="theme-btn" onclick="exportSVG()">SVG</div>
|
|
2702
|
-
<div class="theme-btn" onclick="exportPNG()">PNG</div>
|
|
2703
|
-
</div>
|
|
2704
|
-
</div>
|
|
2705
|
-
</div>
|
|
2706
|
-
|
|
2707
|
-
<!-- Graph View -->
|
|
2708
|
-
<div id="graph-view" class="view active">
|
|
2709
|
-
<svg id="graph-svg"></svg>
|
|
2710
|
-
<div id="hud">
|
|
2711
|
-
<div class="hud-panel" id="search-box">
|
|
2712
|
-
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style="color:var(--text-muted)"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>
|
|
2713
|
-
<input id="search" type="text" data-i18n-placeholder="search.placeholder" placeholder="Search files..." autocomplete="off">
|
|
2714
|
-
<kbd>/</kbd>
|
|
2715
|
-
</div>
|
|
2716
|
-
<div class="hud-panel" id="legend-panel">
|
|
2717
|
-
<div id="layer-legend"></div>
|
|
2718
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
2719
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
2720
|
-
<div class="legend-item"><div class="legend-dot" style="border:2px solid var(--yellow);width:6px;height:6px"></div> <span data-i18n="legend.highCoupling">High coupling</span></div>
|
|
2721
|
-
<div class="legend-item" style="margin-top:4px;font-size:11px;gap:3px"><span style="color:var(--accent)">\u2014\u2192</span> <span data-i18n="legend.imports">imports</span> <span style="margin-left:6px;color:var(--green)">\u2190\u2014</span> <span data-i18n="legend.importedBy">imported by</span></div>
|
|
2722
|
-
</div>
|
|
2723
|
-
</div>
|
|
2724
|
-
<div id="detail">
|
|
2725
|
-
<button class="close-btn" onclick="closeDetail()">\u2715</button>
|
|
2726
|
-
<div class="detail-name" id="d-name"></div>
|
|
2727
|
-
<div class="detail-meta" id="d-meta"></div>
|
|
2728
|
-
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
|
|
2729
|
-
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
|
|
2730
|
-
</div>
|
|
2731
|
-
<div id="filter-bar">
|
|
2732
|
-
<div id="filter-dir-panel"></div>
|
|
2733
|
-
<div id="filter-layer-row"></div>
|
|
2734
|
-
</div>
|
|
2735
|
-
<div id="zoom-ctrl">
|
|
2736
|
-
<button onclick="zoomIn()" title="Zoom in">+</button>
|
|
2737
|
-
<button onclick="zoomOut()" title="Zoom out">\u2212</button>
|
|
2738
|
-
<button onclick="zoomFit()" title="Fit">\u22A1</button>
|
|
2739
|
-
<button id="impact-btn" onclick="toggleImpactMode()" title="Impact simulation" style="font-size:12px;margin-top:4px" data-i18n="impact.btn">Impact</button>
|
|
2740
|
-
</div>
|
|
2741
|
-
<div id="impact-badge"></div>
|
|
2742
|
-
<div id="help-bar" data-i18n="help.graph">Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search</div>
|
|
2743
|
-
</div>
|
|
2744
|
-
|
|
2745
|
-
<!-- Hierarchy View -->
|
|
2746
|
-
<div id="hier-view" class="view">
|
|
2747
|
-
<svg id="hier-svg"></svg>
|
|
2748
|
-
<div id="hier-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
|
|
2749
|
-
<div class="hud-panel" id="hier-legend">
|
|
2750
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
2751
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
2752
|
-
<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>
|
|
2753
|
-
</div>
|
|
2754
|
-
</div>
|
|
2755
|
-
<div id="hier-detail">
|
|
2756
|
-
<button class="close-btn" onclick="closeHierDetail()">\u2715</button>
|
|
2757
|
-
<div class="detail-name" id="hd-name"></div>
|
|
2758
|
-
<div class="detail-meta" id="hd-meta"></div>
|
|
2759
|
-
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
|
|
2760
|
-
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
|
|
2761
|
-
</div>
|
|
2762
|
-
<div id="hier-filter-bar" style="position:absolute;bottom:12px;left:12px;right:120px;z-index:10;display:none;">
|
|
2763
|
-
<div id="hier-filter-row" style="display:flex;flex-wrap:wrap;gap:4px;"></div>
|
|
2764
|
-
</div>
|
|
2765
|
-
<div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
|
|
2766
|
-
</div>
|
|
2767
|
-
|
|
2768
|
-
<!-- Diff View -->
|
|
2769
|
-
<div id="diff-view" class="view">
|
|
2770
|
-
<svg id="diff-svg"></svg>
|
|
2771
|
-
<div id="diff-legend" style="position:absolute;top:12px;left:12px;z-index:10;">
|
|
2772
|
-
<div class="hud-panel">
|
|
2773
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div> <span data-i18n="diff.addedLabel">Added</span></div>
|
|
2774
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="diff.removedLabel">Removed</span></div>
|
|
2775
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div> <span data-i18n="diff.modifiedLabel">Modified</span></div>
|
|
2776
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> <span data-i18n="diff.affectedLabel">Affected</span></div>
|
|
2777
|
-
</div>
|
|
2778
|
-
</div>
|
|
2779
|
-
<div id="help-bar" style="position:absolute" data-i18n="help.diff">Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected</div>
|
|
2780
|
-
</div>
|
|
2781
|
-
|
|
2782
|
-
<!-- Tooltip (shared, interactive) -->
|
|
2783
|
-
<div id="tooltip">
|
|
2784
|
-
<div class="tt-name" id="tt-name"></div>
|
|
2785
|
-
<div>
|
|
2786
|
-
<span class="tt-badge tt-out" id="tt-dep-count"></span> <span data-i18n="tooltip.imports">imports</span>
|
|
2787
|
-
<span class="tt-badge tt-in" id="tt-dpt-count" style="margin-left:6px"></span> <span data-i18n="tooltip.importedBy">imported by</span>
|
|
2788
|
-
</div>
|
|
2789
|
-
<div class="tt-section" id="tt-details"></div>
|
|
2790
|
-
</div>
|
|
2791
|
-
|
|
2792
|
-
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
2793
|
-
<script>
|
|
2794
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2795
|
-
// i18n
|
|
2796
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2797
|
-
const I18N = {
|
|
2798
|
-
en: {
|
|
2799
|
-
'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
|
|
2800
|
-
'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
|
|
2801
|
-
'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
|
|
2802
|
-
'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
|
|
2803
|
-
'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
|
|
2804
|
-
'search.placeholder': 'Search files...',
|
|
2805
|
-
'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
|
|
2806
|
-
'legend.imports': 'imports', 'legend.importedBy': 'imported by',
|
|
2807
|
-
'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
|
|
2808
|
-
'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
|
|
2809
|
-
'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
|
|
2810
|
-
'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
|
|
2811
|
-
'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
|
|
2812
|
-
'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
|
|
2813
|
-
'tab.diff': 'Diff',
|
|
2814
|
-
'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
|
|
2815
|
-
},
|
|
2816
|
-
ja: {
|
|
2817
|
-
'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
|
|
2818
|
-
'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
|
|
2819
|
-
'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
|
|
2820
|
-
'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',
|
|
2821
|
-
'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',
|
|
2822
|
-
'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
|
|
2823
|
-
'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
|
|
2824
|
-
'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
|
|
2825
|
-
'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
|
|
2826
|
-
'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
|
|
2827
|
-
'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
|
|
2828
|
-
'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',
|
|
2829
|
-
'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
|
|
2830
|
-
'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
|
|
2831
|
-
'tab.diff': '\u5DEE\u5206',
|
|
2832
|
-
'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
|
|
2833
|
-
}
|
|
2834
|
-
};
|
|
2835
|
-
let currentLang = '${locale}';
|
|
2836
|
-
function applyI18n() {
|
|
2837
|
-
const msgs = I18N[currentLang] || I18N.en;
|
|
2838
|
-
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
2839
|
-
const key = el.getAttribute('data-i18n');
|
|
2840
|
-
if (msgs[key]) el.textContent = msgs[key];
|
|
2841
|
-
});
|
|
2842
|
-
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
2843
|
-
const key = el.getAttribute('data-i18n-placeholder');
|
|
2844
|
-
if (msgs[key]) el.placeholder = msgs[key];
|
|
2845
|
-
});
|
|
2846
|
-
document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
|
|
2847
|
-
}
|
|
2848
|
-
window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
|
|
2849
|
-
function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
|
|
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
|
-
// SETTINGS (persisted to localStorage)
|
|
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 STORAGE_KEY = 'archtracker-settings';
|
|
2855
|
-
function saveSettings() {
|
|
2856
|
-
const s = { theme: document.body.getAttribute('data-theme') || 'dark', fontSize: document.getElementById('font-size-val').textContent, nodeSize: document.getElementById('node-size-val').textContent, linkOpacity: document.getElementById('link-opacity-val').textContent, gravity: document.getElementById('gravity-val').textContent, layerGravity: document.getElementById('layer-gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
|
|
2857
|
-
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
|
|
2858
|
-
}
|
|
2859
|
-
function loadSettings() {
|
|
2860
|
-
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; } catch(e) { return null; }
|
|
2861
|
-
}
|
|
2862
|
-
|
|
2863
|
-
let nodeScale = 1, baseLinkOpacity = 0.4;
|
|
2864
|
-
window.toggleSettings = () => document.getElementById('settings-panel').classList.toggle('open');
|
|
2865
|
-
window.setTheme = (theme) => {
|
|
2866
|
-
document.body.setAttribute('data-theme', theme === 'light' ? 'light' : '');
|
|
2867
|
-
document.querySelectorAll('.theme-btn[data-theme-val]').forEach(b => b.classList.toggle('active', b.dataset.themeVal === theme));
|
|
2868
|
-
saveSettings();
|
|
2869
|
-
};
|
|
2870
|
-
window.setFontSize = (v) => {
|
|
2871
|
-
document.getElementById('font-size-val').textContent = v;
|
|
2872
|
-
const scale = v / 13;
|
|
2873
|
-
if (typeof node !== 'undefined') {
|
|
2874
|
-
node.select('text').attr('font-size', d => (d.dependents>=3?12:10) * scale);
|
|
2875
|
-
}
|
|
2876
|
-
saveSettings();
|
|
2877
|
-
};
|
|
2878
|
-
window.setNodeScale = (v) => {
|
|
2879
|
-
nodeScale = v / 100;
|
|
2880
|
-
document.getElementById('node-size-val').textContent = v;
|
|
2881
|
-
if (typeof node !== 'undefined') {
|
|
2882
|
-
node.select('circle').attr('r', d => nodeRadius(d) * nodeScale);
|
|
2883
|
-
node.select('text').attr('dx', d => nodeRadius(d) * nodeScale + 4);
|
|
2884
|
-
simulation.force('collision', d3.forceCollide().radius(d => nodeRadius(d) * nodeScale + 4));
|
|
2885
|
-
simulation.alpha(0.3).restart();
|
|
2886
|
-
}
|
|
2887
|
-
saveSettings();
|
|
2888
|
-
};
|
|
2889
|
-
window.setLinkOpacity = (v) => {
|
|
2890
|
-
baseLinkOpacity = v / 100;
|
|
2891
|
-
document.getElementById('link-opacity-val').textContent = v;
|
|
2892
|
-
if (typeof link !== 'undefined') link.attr('opacity', baseLinkOpacity);
|
|
2893
|
-
saveSettings();
|
|
2894
|
-
};
|
|
2895
|
-
let gravityStrength = 150;
|
|
2896
|
-
window.setGravity = (v) => {
|
|
2897
|
-
gravityStrength = +v;
|
|
2898
|
-
document.getElementById('gravity-val').textContent = v;
|
|
2899
|
-
if (typeof simulation !== 'undefined') {
|
|
2900
|
-
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
|
|
2901
|
-
simulation.alpha(0.5).restart();
|
|
2902
|
-
}
|
|
2903
|
-
saveSettings();
|
|
2904
|
-
};
|
|
2905
|
-
let layerGravity = 12;
|
|
2906
|
-
window.setLayerGravity = (v) => {
|
|
2907
|
-
layerGravity = +v;
|
|
2908
|
-
document.getElementById('layer-gravity-val').textContent = v;
|
|
2909
|
-
if (typeof simulation !== 'undefined' && typeof applyLayerFilter === 'function') {
|
|
2910
|
-
applyLayerFilter();
|
|
2911
|
-
}
|
|
2912
|
-
saveSettings();
|
|
2913
|
-
};
|
|
2914
|
-
|
|
2915
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2916
|
-
// EXPORT
|
|
2917
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2918
|
-
window.exportSVG = () => {
|
|
2919
|
-
const activeView = document.querySelector('.view.active svg');
|
|
2920
|
-
if (!activeView) return;
|
|
2921
|
-
const clone = activeView.cloneNode(true);
|
|
2922
|
-
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
2923
|
-
const blob = new Blob([clone.outerHTML], {type: 'image/svg+xml'});
|
|
2924
|
-
const a = document.createElement('a');
|
|
2925
|
-
a.href = URL.createObjectURL(blob);
|
|
2926
|
-
a.download = (document.getElementById('project-title').textContent || 'graph') + '.svg';
|
|
2927
|
-
a.click(); URL.revokeObjectURL(a.href);
|
|
2928
|
-
};
|
|
2929
|
-
window.exportPNG = () => {
|
|
2930
|
-
const activeView = document.querySelector('.view.active svg');
|
|
2931
|
-
if (!activeView) return;
|
|
2932
|
-
const clone = activeView.cloneNode(true);
|
|
2933
|
-
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
2934
|
-
const svgStr = new XMLSerializer().serializeToString(clone);
|
|
2935
|
-
const canvas = document.createElement('canvas');
|
|
2936
|
-
const bbox = activeView.getBoundingClientRect();
|
|
2937
|
-
canvas.width = bbox.width * 2; canvas.height = bbox.height * 2;
|
|
2938
|
-
const ctx = canvas.getContext('2d');
|
|
2939
|
-
ctx.scale(2, 2);
|
|
2940
|
-
const img = new Image();
|
|
2941
|
-
img.onload = () => { ctx.drawImage(img, 0, 0); const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = (document.getElementById('project-title').textContent || 'graph') + '.png'; a.click(); };
|
|
2942
|
-
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
|
|
2943
|
-
};
|
|
2944
|
-
|
|
2945
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2946
|
-
// DATA
|
|
2947
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2948
|
-
const DATA = ${graphData};
|
|
2949
|
-
const LAYERS = ${layersData};
|
|
2950
|
-
const CROSS_EDGES = ${crossEdgesData};
|
|
2951
|
-
const W = window.innerWidth, H = window.innerHeight - 44;
|
|
2952
|
-
const circularSet = new Set(DATA.circularFiles);
|
|
2953
|
-
|
|
2954
|
-
// Project title (editable)
|
|
2955
|
-
const titleEl = document.getElementById('project-title');
|
|
2956
|
-
titleEl.textContent = DATA.projectName;
|
|
2957
|
-
titleEl.addEventListener('blur', () => { if (!titleEl.textContent.trim()) titleEl.textContent = DATA.projectName; document.title = titleEl.textContent + ' \u2014 Architecture Viewer'; saveSettings(); });
|
|
2958
|
-
titleEl.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
|
|
2959
|
-
|
|
2960
|
-
// Restore saved settings \u2014 phase 1: non-graph settings (before graph init)
|
|
2961
|
-
const _savedSettings = loadSettings();
|
|
2962
|
-
if (_savedSettings) {
|
|
2963
|
-
if (_savedSettings.theme) setTheme(_savedSettings.theme);
|
|
2964
|
-
if (_savedSettings.lang) { currentLang = _savedSettings.lang; applyI18n(); }
|
|
2965
|
-
if (_savedSettings.projectTitle) { titleEl.textContent = _savedSettings.projectTitle; document.title = _savedSettings.projectTitle + ' \u2014 Architecture Viewer'; }
|
|
2966
|
-
// Set slider positions (visual only \u2014 graph not built yet)
|
|
2967
|
-
if (_savedSettings.fontSize) { document.getElementById('font-size-slider').value = _savedSettings.fontSize; document.getElementById('font-size-val').textContent = _savedSettings.fontSize; }
|
|
2968
|
-
if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
|
|
2969
|
-
if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
|
|
2970
|
-
if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
|
|
2971
|
-
if (_savedSettings.layerGravity) { document.getElementById('layer-gravity-slider').value = _savedSettings.layerGravity; document.getElementById('layer-gravity-val').textContent = _savedSettings.layerGravity; layerGravity = +_savedSettings.layerGravity; }
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
document.getElementById('s-files').textContent = DATA.nodes.length;
|
|
2975
|
-
document.getElementById('s-edges').textContent = DATA.links.length;
|
|
2976
|
-
document.getElementById('s-circular').textContent = DATA.circularFiles.length;
|
|
2977
|
-
|
|
2978
|
-
const dirColor = d3.scaleOrdinal()
|
|
2979
|
-
.domain(DATA.dirs)
|
|
2980
|
-
.range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
|
|
2981
|
-
|
|
2982
|
-
// Layer color map (from LAYERS metadata)
|
|
2983
|
-
const layerColorMap = {};
|
|
2984
|
-
let activeLayerFilter = null; // DEPRECATED \u2014 kept for backward compat, always null with multi-select tabs
|
|
2985
|
-
const activeLayers = new Set(); // empty = no filter (show all); non-empty = show only selected
|
|
2986
|
-
if (LAYERS) {
|
|
2987
|
-
LAYERS.forEach(l => { layerColorMap[l.name] = l.color; });
|
|
2988
|
-
document.getElementById('layer-gravity-setting').style.display = '';
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2991
|
-
function nodeColor(d) {
|
|
2992
|
-
if (circularSet.has(d.id)) return '#f97583';
|
|
2993
|
-
if (d.isOrphan) return '#484f58';
|
|
2994
|
-
// Layer coloring: all-visible or multi-select \u2192 layer colors; single-select \u2192 dir colors
|
|
2995
|
-
if (LAYERS && d.layer && layerColorMap[d.layer] && activeLayers.size !== 1) return layerColorMap[d.layer];
|
|
2996
|
-
return dirColor(d.dir);
|
|
2997
|
-
}
|
|
2998
|
-
function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
|
|
2999
|
-
function fileName(id) { return id.split('/').pop(); }
|
|
3000
|
-
|
|
3001
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3002
|
-
// TAB SWITCHING
|
|
3003
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3004
|
-
let hierBuilt = false;
|
|
3005
|
-
let hierRelayout = null;
|
|
3006
|
-
let hierSyncFromTab = null;
|
|
3007
|
-
document.querySelectorAll('.tab').forEach(tab => {
|
|
3008
|
-
tab.addEventListener('click', () => {
|
|
3009
|
-
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
3010
|
-
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
3011
|
-
tab.classList.add('active');
|
|
3012
|
-
document.getElementById(tab.dataset.view).classList.add('active');
|
|
3013
|
-
if (tab.dataset.view === 'hier-view') {
|
|
3014
|
-
if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
|
|
3015
|
-
if (hierSyncFromTab) { hierSyncFromTab(null); hierRelayout(); }
|
|
3016
|
-
}
|
|
3017
|
-
});
|
|
3018
|
-
});
|
|
3019
|
-
|
|
3020
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3021
|
-
// TOOLTIP \u2014 delayed hide + interactive
|
|
3022
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3023
|
-
const tooltip = document.getElementById('tooltip');
|
|
3024
|
-
let tooltipHideTimer = null;
|
|
3025
|
-
let tooltipLocked = false;
|
|
3026
|
-
|
|
3027
|
-
function showTooltip(e, d) {
|
|
3028
|
-
clearTimeout(tooltipHideTimer);
|
|
3029
|
-
document.getElementById('tt-name').textContent = d.id;
|
|
3030
|
-
document.getElementById('tt-dep-count').textContent = d.deps;
|
|
3031
|
-
document.getElementById('tt-dpt-count').textContent = d.dependents;
|
|
3032
|
-
const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+x+'</div>');
|
|
3033
|
-
const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+x+'</div>');
|
|
3034
|
-
document.getElementById('tt-details').innerHTML = [...out, ...inc].join('');
|
|
3035
|
-
tooltip.style.display = 'block';
|
|
3036
|
-
positionTooltip(e);
|
|
3037
|
-
}
|
|
3038
|
-
function positionTooltip(e) {
|
|
3039
|
-
const gap = 24;
|
|
3040
|
-
const tw = 420, th = tooltip.offsetHeight || 200;
|
|
3041
|
-
// Prefer placing to the right and above the cursor so it doesn't cover nodes below
|
|
3042
|
-
let x = e.clientX + gap;
|
|
3043
|
-
let y = e.clientY - th - 12;
|
|
3044
|
-
// If no room on the right, flip left
|
|
3045
|
-
if (x + tw > window.innerWidth) x = e.clientX - tw - gap;
|
|
3046
|
-
// If no room above, place below the cursor with gap
|
|
3047
|
-
if (y < 50) y = e.clientY + gap;
|
|
3048
|
-
// Final clamp
|
|
3049
|
-
if (y + th > window.innerHeight) y = window.innerHeight - th - 8;
|
|
3050
|
-
if (x < 8) x = 8;
|
|
3051
|
-
tooltip.style.left = x + 'px';
|
|
3052
|
-
tooltip.style.top = y + 'px';
|
|
3053
|
-
}
|
|
3054
|
-
function scheduleHideTooltip() {
|
|
3055
|
-
clearTimeout(tooltipHideTimer);
|
|
3056
|
-
tooltipHideTimer = setTimeout(() => {
|
|
3057
|
-
if (!tooltipLocked) {
|
|
3058
|
-
tooltip.style.display = 'none';
|
|
3059
|
-
if (!pinnedNode) resetGraphHighlight();
|
|
3060
|
-
}
|
|
3061
|
-
}, 250);
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
// Keep tooltip visible when mouse enters it
|
|
3065
|
-
tooltip.addEventListener('mouseenter', () => {
|
|
3066
|
-
clearTimeout(tooltipHideTimer);
|
|
3067
|
-
tooltipLocked = true;
|
|
3068
|
-
});
|
|
3069
|
-
tooltip.addEventListener('mouseleave', () => {
|
|
3070
|
-
tooltipLocked = false;
|
|
3071
|
-
scheduleHideTooltip();
|
|
3072
|
-
});
|
|
3073
|
-
|
|
3074
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3075
|
-
// GRAPH VIEW
|
|
3076
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3077
|
-
const svg = d3.select('#graph-svg').attr('width', W).attr('height', H);
|
|
3078
|
-
const g = svg.append('g');
|
|
3079
|
-
const zoom = d3.zoom().scaleExtent([0.05, 10]).on('zoom', e => g.attr('transform', e.transform));
|
|
3080
|
-
svg.call(zoom);
|
|
3081
|
-
svg.call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(0.7));
|
|
3082
|
-
|
|
3083
|
-
window.zoomIn = () => svg.transition().duration(300).call(zoom.scaleBy, 1.4);
|
|
3084
|
-
window.zoomOut = () => svg.transition().duration(300).call(zoom.scaleBy, 0.7);
|
|
3085
|
-
window.zoomFit = () => {
|
|
3086
|
-
const b = g.node().getBBox(); if (!b.width) return;
|
|
3087
|
-
const s = Math.min(W/(b.width+80), H/(b.height+80))*0.9;
|
|
3088
|
-
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s, H/2-(b.y+b.height/2)*s).scale(s));
|
|
3089
|
-
};
|
|
3090
|
-
|
|
3091
|
-
// Defs
|
|
3092
|
-
const defs = svg.append('defs');
|
|
3093
|
-
['#30363d','#58a6ff','#3fb950'].forEach((c,i) => {
|
|
3094
|
-
defs.append('marker').attr('id','arrow-'+i).attr('viewBox','0 -4 8 8')
|
|
3095
|
-
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
3096
|
-
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',c);
|
|
3097
|
-
});
|
|
3098
|
-
|
|
3099
|
-
// Links
|
|
3100
|
-
const link = g.append('g').selectAll('line').data(DATA.links).join('line')
|
|
3101
|
-
.attr('stroke', d => d.type==='type-only'?'#1f3d5c':'#30363d')
|
|
3102
|
-
.attr('stroke-width',1)
|
|
3103
|
-
.attr('stroke-dasharray', d => d.type==='type-only'?'4,3':d.type==='dynamic'?'6,3':null)
|
|
3104
|
-
.attr('marker-end','url(#arrow-0)')
|
|
3105
|
-
.attr('opacity', baseLinkOpacity);
|
|
3106
|
-
|
|
3107
|
-
// Cross-layer links (from layers.json connections)
|
|
3108
|
-
defs.append('marker').attr('id','arrow-cross').attr('viewBox','0 -4 8 8')
|
|
3109
|
-
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
3110
|
-
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#f0883e');
|
|
3111
|
-
|
|
3112
|
-
const crossLinkData = (CROSS_EDGES || []).map(e => ({
|
|
3113
|
-
source: e.fromLayer + '/' + e.fromFile,
|
|
3114
|
-
target: e.toLayer + '/' + e.toFile,
|
|
3115
|
-
sourceLayer: e.fromLayer,
|
|
3116
|
-
targetLayer: e.toLayer,
|
|
3117
|
-
type: e.type || 'api-call',
|
|
3118
|
-
label: e.label || e.type || '',
|
|
3119
|
-
})).filter(e => DATA.nodes.some(n => n.id === e.source) && DATA.nodes.some(n => n.id === e.target));
|
|
3120
|
-
|
|
3121
|
-
const crossLinkG = g.append('g');
|
|
3122
|
-
const crossLink = crossLinkG.selectAll('line').data(crossLinkData).join('line')
|
|
3123
|
-
.attr('stroke', '#f0883e')
|
|
3124
|
-
.attr('stroke-width', 2)
|
|
3125
|
-
.attr('stroke-dasharray', '8,4')
|
|
3126
|
-
.attr('marker-end', 'url(#arrow-cross)')
|
|
3127
|
-
.attr('opacity', 0.7);
|
|
3128
|
-
const crossLabel = crossLinkG.selectAll('text').data(crossLinkData).join('text')
|
|
3129
|
-
.text(d => d.label)
|
|
3130
|
-
.attr('font-size', 9)
|
|
3131
|
-
.attr('fill', '#f0883e')
|
|
3132
|
-
.attr('text-anchor', 'middle')
|
|
3133
|
-
.attr('opacity', 0.8)
|
|
3134
|
-
.attr('pointer-events', 'none');
|
|
3135
|
-
|
|
3136
|
-
// Nodes
|
|
3137
|
-
const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
|
|
3138
|
-
.attr('cursor','pointer')
|
|
3139
|
-
.call(d3.drag().on('start',dragStart).on('drag',dragging).on('end',dragEnd));
|
|
3140
|
-
|
|
3141
|
-
node.append('circle')
|
|
3142
|
-
.attr('r', d => nodeRadius(d) * nodeScale)
|
|
3143
|
-
.attr('fill', nodeColor)
|
|
3144
|
-
.attr('stroke', d => d.deps>=5?'var(--yellow)':nodeColor(d))
|
|
3145
|
-
.attr('stroke-width', d => d.deps>=5?2.5:1.5)
|
|
3146
|
-
.attr('stroke-opacity', d => d.deps>=5?0.8:0.3);
|
|
3147
|
-
|
|
3148
|
-
node.append('text')
|
|
3149
|
-
.text(d => fileName(d.id).replace(/\\.tsx?$/,''))
|
|
3150
|
-
.attr('dx', d => nodeRadius(d)*nodeScale+4)
|
|
3151
|
-
.attr('dy',3.5)
|
|
3152
|
-
.attr('font-size', d => d.dependents>=3?12:10)
|
|
3153
|
-
.attr('font-weight', d => d.dependents>=3?600:400)
|
|
3154
|
-
.attr('fill', d => d.dependents>=3?'var(--text)':'var(--text-dim)')
|
|
3155
|
-
.attr('opacity', d => d.dependents>=1||d.deps>=3?1:0.5)
|
|
3156
|
-
.attr('pointer-events','none');
|
|
3157
|
-
|
|
3158
|
-
// Simulation
|
|
3159
|
-
const simulation = d3.forceSimulation(DATA.nodes)
|
|
3160
|
-
.force('link', d3.forceLink(DATA.links).id(d=>d.id).distance(70).strength(0.25))
|
|
3161
|
-
.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500))
|
|
3162
|
-
.force('center', d3.forceCenter(0,0))
|
|
3163
|
-
.force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
|
|
3164
|
-
.force('x', d3.forceX(0).strength(0.03))
|
|
3165
|
-
.force('y', d3.forceY(0).strength(0.03))
|
|
3166
|
-
.on('tick', () => {
|
|
3167
|
-
link.each(function(d) {
|
|
3168
|
-
const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
|
|
3169
|
-
const dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
3170
|
-
const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
|
|
3171
|
-
d3.select(this)
|
|
3172
|
-
.attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
3173
|
-
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
3174
|
-
});
|
|
3175
|
-
node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
3176
|
-
});
|
|
3177
|
-
|
|
3178
|
-
// \u2500\u2500\u2500 Layer convex hulls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3179
|
-
let hullGroup = null;
|
|
3180
|
-
const activeDirs = new Set(DATA.dirs);
|
|
3181
|
-
const dirCounts = {};
|
|
3182
|
-
DATA.nodes.forEach(n => dirCounts[n.dir] = (dirCounts[n.dir] || 0) + 1);
|
|
3183
|
-
var applyLayerFilter = null; // hoisted for dir-filter integration
|
|
3184
|
-
|
|
3185
|
-
if (LAYERS && LAYERS.length > 0) {
|
|
3186
|
-
// \u2500\u2500\u2500 Water droplet physics: intra-layer cohesion + inter-layer separation \u2500\u2500\u2500
|
|
3187
|
-
const allLayerCount = LAYERS.length;
|
|
3188
|
-
const allBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(allLayerCount));
|
|
3189
|
-
// Pre-compute full-circle positions for all layers (used when no filter)
|
|
3190
|
-
const allLayerCenters = {};
|
|
3191
|
-
LAYERS.forEach((l, idx) => {
|
|
3192
|
-
const angle = (2 * Math.PI * idx) / allLayerCount - Math.PI / 2;
|
|
3193
|
-
allLayerCenters[l.name] = { x: Math.cos(angle) * allBaseRadius, y: Math.sin(angle) * allBaseRadius };
|
|
3194
|
-
});
|
|
3195
|
-
|
|
3196
|
-
// Dynamic center calculation: compact when multi-selecting, full spread when all
|
|
3197
|
-
function getLayerCenters() {
|
|
3198
|
-
if (activeLayers.size <= 1) return allLayerCenters; // 0 = all, 1 = single (centered)
|
|
3199
|
-
// Multi-select: arrange only selected layers compactly on a smaller circle
|
|
3200
|
-
const selected = LAYERS.filter(l => activeLayers.has(l.name));
|
|
3201
|
-
const count = selected.length;
|
|
3202
|
-
const compactRadius = Math.max(40, Math.min(W, H) * 0.03 * Math.sqrt(count));
|
|
3203
|
-
const centers = {};
|
|
3204
|
-
selected.forEach((l, idx) => {
|
|
3205
|
-
const angle = (2 * Math.PI * idx) / count - Math.PI / 2;
|
|
3206
|
-
centers[l.name] = { x: Math.cos(angle) * compactRadius, y: Math.sin(angle) * compactRadius };
|
|
3207
|
-
});
|
|
3208
|
-
return centers;
|
|
3209
|
-
}
|
|
3210
|
-
|
|
3211
|
-
// Replace default centering forces with per-layer positioning
|
|
3212
|
-
const layerStrength = layerGravity / 100;
|
|
3213
|
-
simulation.force('x', null).force('y', null).force('center', null);
|
|
3214
|
-
simulation.force('layerX', d3.forceX(d => allLayerCenters[d.layer]?.x || 0).strength(d => d.layer ? layerStrength : 0.03));
|
|
3215
|
-
simulation.force('layerY', d3.forceY(d => allLayerCenters[d.layer]?.y || 0).strength(d => d.layer ? layerStrength : 0.03));
|
|
3216
|
-
|
|
3217
|
-
// Custom clustering force \u2014 surface tension pulling nodes toward their layer centroid
|
|
3218
|
-
function clusterForce() {
|
|
3219
|
-
let nodes;
|
|
3220
|
-
function force(alpha) {
|
|
3221
|
-
const centroids = {};
|
|
3222
|
-
const counts = {};
|
|
3223
|
-
nodes.forEach(n => {
|
|
3224
|
-
if (!n.layer) return;
|
|
3225
|
-
if (!centroids[n.layer]) { centroids[n.layer] = {x: 0, y: 0}; counts[n.layer] = 0; }
|
|
3226
|
-
centroids[n.layer].x += n.x;
|
|
3227
|
-
centroids[n.layer].y += n.y;
|
|
3228
|
-
counts[n.layer]++;
|
|
3229
|
-
});
|
|
3230
|
-
Object.keys(centroids).forEach(k => {
|
|
3231
|
-
centroids[k].x /= counts[k];
|
|
3232
|
-
centroids[k].y /= counts[k];
|
|
3233
|
-
});
|
|
3234
|
-
// Pull each node toward its layer centroid (surface tension)
|
|
3235
|
-
const strength = 0.2;
|
|
3236
|
-
nodes.forEach(n => {
|
|
3237
|
-
if (!n.layer || !centroids[n.layer]) return;
|
|
3238
|
-
n.vx += (centroids[n.layer].x - n.x) * alpha * strength;
|
|
3239
|
-
n.vy += (centroids[n.layer].y - n.y) * alpha * strength;
|
|
3240
|
-
});
|
|
3241
|
-
}
|
|
3242
|
-
force.initialize = (n) => { nodes = n; };
|
|
3243
|
-
return force;
|
|
3244
|
-
}
|
|
3245
|
-
simulation.force('cluster', clusterForce());
|
|
3246
|
-
|
|
3247
|
-
// Boost link strength for intra-layer edges (tighter connections within a layer)
|
|
3248
|
-
simulation.force('link').strength(l => {
|
|
3249
|
-
const sLayer = (l.source.layer ?? l.source);
|
|
3250
|
-
const tLayer = (l.target.layer ?? l.target);
|
|
3251
|
-
return sLayer === tLayer ? 0.4 : 0.1;
|
|
3252
|
-
});
|
|
3253
|
-
|
|
3254
|
-
hullGroup = g.insert('g', ':first-child');
|
|
3255
|
-
|
|
3256
|
-
function updateHulls() {
|
|
3257
|
-
if (!hullGroup) return;
|
|
3258
|
-
hullGroup.selectAll('*').remove();
|
|
3259
|
-
// Show hulls always (filter to selected layers when focused)
|
|
3260
|
-
|
|
3261
|
-
LAYERS.forEach(layer => {
|
|
3262
|
-
if (activeLayers.size > 0 && !activeLayers.has(layer.name)) return;
|
|
3263
|
-
const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
|
|
3264
|
-
if (layerNodes.length === 0) return;
|
|
3265
|
-
|
|
3266
|
-
const points = [];
|
|
3267
|
-
layerNodes.forEach(n => {
|
|
3268
|
-
if (n.x == null || n.y == null) return;
|
|
3269
|
-
const r = nodeRadius(n) * nodeScale + 30;
|
|
3270
|
-
// Add expanded points for a nicer hull shape
|
|
3271
|
-
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
3272
|
-
points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
|
|
3273
|
-
}
|
|
3274
|
-
});
|
|
3275
|
-
|
|
3276
|
-
if (points.length < 3) {
|
|
3277
|
-
// Fallback: circle for 1-2 nodes
|
|
3278
|
-
const cx = layerNodes.reduce((s, n) => s + (n.x || 0), 0) / layerNodes.length;
|
|
3279
|
-
const cy = layerNodes.reduce((s, n) => s + (n.y || 0), 0) / layerNodes.length;
|
|
3280
|
-
const maxR = Math.max(60, ...layerNodes.map(n => {
|
|
3281
|
-
const dx = (n.x || 0) - cx, dy = (n.y || 0) - cy;
|
|
3282
|
-
return Math.sqrt(dx*dx + dy*dy) + nodeRadius(n) * nodeScale + 30;
|
|
3283
|
-
}));
|
|
3284
|
-
hullGroup.append('circle')
|
|
3285
|
-
.attr('cx', cx).attr('cy', cy).attr('r', maxR)
|
|
3286
|
-
.attr('class', 'layer-hull')
|
|
3287
|
-
.attr('fill', layer.color).attr('stroke', layer.color);
|
|
3288
|
-
hullGroup.append('text')
|
|
3289
|
-
.attr('class', 'layer-hull-label')
|
|
3290
|
-
.attr('x', cx).attr('y', cy - maxR - 8)
|
|
3291
|
-
.attr('text-anchor', 'middle')
|
|
3292
|
-
.attr('fill', layer.color)
|
|
3293
|
-
.text(layer.name);
|
|
3294
|
-
return;
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
const hull = d3.polygonHull(points);
|
|
3298
|
-
if (!hull) return;
|
|
3299
|
-
|
|
3300
|
-
// Smooth the hull with a cardinal closed curve
|
|
3301
|
-
hullGroup.append('path')
|
|
3302
|
-
.attr('class', 'layer-hull')
|
|
3303
|
-
.attr('d', d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5))(hull))
|
|
3304
|
-
.attr('fill', layer.color).attr('stroke', layer.color);
|
|
3305
|
-
|
|
3306
|
-
// Label at the top of the hull
|
|
3307
|
-
const topPt = hull.reduce((best, p) => p[1] < best[1] ? p : best, hull[0]);
|
|
3308
|
-
hullGroup.append('text')
|
|
3309
|
-
.attr('class', 'layer-hull-label')
|
|
3310
|
-
.attr('x', topPt[0]).attr('y', topPt[1] - 10)
|
|
3311
|
-
.attr('text-anchor', 'middle')
|
|
3312
|
-
.attr('fill', layer.color)
|
|
3313
|
-
.text(layer.name);
|
|
3314
|
-
});
|
|
3315
|
-
}
|
|
3316
|
-
|
|
3317
|
-
// Update hulls + cross-layer links on each tick
|
|
3318
|
-
simulation.on('tick', () => {
|
|
3319
|
-
// Regular links
|
|
3320
|
-
link.each(function(d) {
|
|
3321
|
-
const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
|
|
3322
|
-
const dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
3323
|
-
const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
|
|
3324
|
-
d3.select(this)
|
|
3325
|
-
.attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
3326
|
-
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
3327
|
-
});
|
|
3328
|
-
node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
|
|
3329
|
-
// Cross-layer links \u2014 resolve node positions by ID
|
|
3330
|
-
if (crossLinkData.length > 0) {
|
|
3331
|
-
const nodeById = {};
|
|
3332
|
-
DATA.nodes.forEach(n => { nodeById[n.id] = n; });
|
|
3333
|
-
crossLink.each(function(d) {
|
|
3334
|
-
const sN = nodeById[d.source], tN = nodeById[d.target];
|
|
3335
|
-
if (!sN || !tN) return;
|
|
3336
|
-
const dx = tN.x - sN.x, dy = tN.y - sN.y;
|
|
3337
|
-
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
3338
|
-
const rS = nodeRadius(sN) * nodeScale, rT = nodeRadius(tN) * nodeScale;
|
|
3339
|
-
d3.select(this)
|
|
3340
|
-
.attr('x1', sN.x + (dx/dist)*rS).attr('y1', sN.y + (dy/dist)*rS)
|
|
3341
|
-
.attr('x2', tN.x - (dx/dist)*rT).attr('y2', tN.y - (dy/dist)*rT);
|
|
3342
|
-
});
|
|
3343
|
-
crossLabel.each(function(d) {
|
|
3344
|
-
const sN = nodeById[d.source], tN = nodeById[d.target];
|
|
3345
|
-
if (!sN || !tN) return;
|
|
3346
|
-
d3.select(this).attr('x', (sN.x + tN.x) / 2).attr('y', (sN.y + tN.y) / 2 - 6);
|
|
3347
|
-
});
|
|
3348
|
-
}
|
|
3349
|
-
updateHulls();
|
|
3350
|
-
});
|
|
3351
|
-
|
|
3352
|
-
// \u2500\u2500\u2500 Layer legend \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3353
|
-
const layerLegend = document.getElementById('layer-legend');
|
|
3354
|
-
LAYERS.forEach(layer => {
|
|
3355
|
-
const item = document.createElement('div');
|
|
3356
|
-
item.className = 'legend-item';
|
|
3357
|
-
item.innerHTML = '<div class="legend-dot" style="background:' + layer.color + '"></div> ' + layer.name;
|
|
3358
|
-
layerLegend.appendChild(item);
|
|
3359
|
-
});
|
|
3360
|
-
// Cross-layer edge legend
|
|
3361
|
-
if (CROSS_EDGES && CROSS_EDGES.length > 0) {
|
|
3362
|
-
const crossItem = document.createElement('div');
|
|
3363
|
-
crossItem.className = 'legend-item';
|
|
3364
|
-
crossItem.innerHTML = '<span style="color:#f0883e;font-size:11px">- - \u2192</span> Cross-layer link';
|
|
3365
|
-
layerLegend.appendChild(crossItem);
|
|
3366
|
-
}
|
|
3367
|
-
// Add separator
|
|
3368
|
-
const sep = document.createElement('hr');
|
|
3369
|
-
sep.style.cssText = 'border:none;border-top:1px solid var(--border);margin:6px 0;';
|
|
3370
|
-
layerLegend.appendChild(sep);
|
|
3371
|
-
|
|
3372
|
-
// \u2500\u2500\u2500 Layer tabs (multi-select toggles in tab bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3373
|
-
const layerTabsEl = document.getElementById('layer-tabs');
|
|
3374
|
-
const allTab = document.createElement('div');
|
|
3375
|
-
allTab.className = 'layer-tab active';
|
|
3376
|
-
allTab.textContent = 'All';
|
|
3377
|
-
allTab.onclick = () => {
|
|
3378
|
-
activeLayers.clear();
|
|
3379
|
-
syncLayerTabUI();
|
|
3380
|
-
applyLayerFilter();
|
|
3381
|
-
if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
|
|
3382
|
-
};
|
|
3383
|
-
layerTabsEl.appendChild(allTab);
|
|
3384
|
-
|
|
3385
|
-
LAYERS.forEach(layer => {
|
|
3386
|
-
const tab = document.createElement('div');
|
|
3387
|
-
tab.className = 'layer-tab';
|
|
3388
|
-
tab.dataset.layer = layer.name;
|
|
3389
|
-
tab.innerHTML = '<div class="lt-dot" style="background:' + layer.color + '"></div>' + layer.name;
|
|
3390
|
-
tab.onclick = (e) => {
|
|
3391
|
-
if (e.shiftKey) {
|
|
3392
|
-
// Shift+click: solo this layer
|
|
3393
|
-
activeLayers.clear();
|
|
3394
|
-
activeLayers.add(layer.name);
|
|
3395
|
-
} else {
|
|
3396
|
-
// Toggle
|
|
3397
|
-
if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
|
|
3398
|
-
else activeLayers.add(layer.name);
|
|
3399
|
-
}
|
|
3400
|
-
syncLayerTabUI();
|
|
3401
|
-
applyLayerFilter();
|
|
3402
|
-
if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
|
|
3403
|
-
};
|
|
3404
|
-
layerTabsEl.appendChild(tab);
|
|
3405
|
-
});
|
|
3406
|
-
|
|
3407
|
-
function syncLayerTabUI() {
|
|
3408
|
-
allTab.classList.toggle('active', activeLayers.size === 0);
|
|
3409
|
-
layerTabsEl.querySelectorAll('.layer-tab[data-layer]').forEach(t => {
|
|
3410
|
-
t.classList.toggle('active', activeLayers.has(t.dataset.layer));
|
|
3411
|
-
});
|
|
3412
|
-
// Also sync the filter bar layer pills
|
|
3413
|
-
layerRowEl.querySelectorAll('.layer-pill[data-layer]').forEach(p => {
|
|
3414
|
-
p.classList.toggle('active', activeLayers.has(p.dataset.layer));
|
|
3415
|
-
});
|
|
3416
|
-
}
|
|
3417
|
-
|
|
3418
|
-
applyLayerFilter = function() {
|
|
3419
|
-
const isSingleLayer = activeLayers.size === 1;
|
|
3420
|
-
const hasLayerFilter = activeLayers.size > 0;
|
|
3421
|
-
node.attr('display', d => {
|
|
3422
|
-
if (!activeDirs.has(d.dir)) return 'none';
|
|
3423
|
-
if (hasLayerFilter && !activeLayers.has(d.layer)) return 'none';
|
|
3424
|
-
return null;
|
|
3425
|
-
});
|
|
3426
|
-
link.attr('display', l => {
|
|
3427
|
-
const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3428
|
-
const sN = DATA.nodes.find(n => n.id === s), tN = DATA.nodes.find(n => n.id === t);
|
|
3429
|
-
if (!sN || !tN) return 'none';
|
|
3430
|
-
if (!activeDirs.has(sN.dir) || !activeDirs.has(tN.dir)) return 'none';
|
|
3431
|
-
if (hasLayerFilter && (!activeLayers.has(sN.layer) || !activeLayers.has(tN.layer))) return 'none';
|
|
3432
|
-
return null;
|
|
3433
|
-
});
|
|
3434
|
-
// Refresh node colors: single-layer = dir-based, multi-layer = layer-based
|
|
3435
|
-
node.select('circle')
|
|
3436
|
-
.attr('fill', nodeColor)
|
|
3437
|
-
.attr('stroke', d => d.deps >= 5 ? 'var(--yellow)' : nodeColor(d));
|
|
3438
|
-
// Cross-layer links: respect user toggle + layer filter
|
|
3439
|
-
if (typeof crossLink !== 'undefined') {
|
|
3440
|
-
if (!crossLinksUserEnabled || isSingleLayer) {
|
|
3441
|
-
crossLink.attr('display', 'none');
|
|
3442
|
-
crossLabel.attr('display', 'none');
|
|
3443
|
-
} else if (hasLayerFilter) {
|
|
3444
|
-
crossLink.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
|
|
3445
|
-
crossLabel.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
|
|
3446
|
-
} else {
|
|
3447
|
-
crossLink.attr('display', null);
|
|
3448
|
-
crossLabel.attr('display', null);
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
// Update stats
|
|
3452
|
-
const visibleNodes = DATA.nodes.filter(d => {
|
|
3453
|
-
if (!activeDirs.has(d.dir)) return false;
|
|
3454
|
-
if (hasLayerFilter && !activeLayers.has(d.layer)) return false;
|
|
3455
|
-
return true;
|
|
3456
|
-
});
|
|
3457
|
-
const visibleIds = new Set(visibleNodes.map(n => n.id));
|
|
3458
|
-
const visibleEdges = DATA.links.filter(l => {
|
|
3459
|
-
const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
|
|
3460
|
-
return visibleIds.has(s) && visibleIds.has(t);
|
|
3461
|
-
});
|
|
3462
|
-
document.getElementById('s-files').textContent = visibleNodes.length;
|
|
3463
|
-
document.getElementById('s-edges').textContent = visibleEdges.length;
|
|
3464
|
-
const visCirc = DATA.circularFiles.filter(f => visibleIds.has(f));
|
|
3465
|
-
document.getElementById('s-circular').textContent = visCirc.length;
|
|
3466
|
-
updateHulls();
|
|
3467
|
-
// Adjust physics: single-layer = centered, multi-select = compact, all = full spread
|
|
3468
|
-
const lStrength = layerGravity / 100;
|
|
3469
|
-
if (isSingleLayer) {
|
|
3470
|
-
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength * 3).distanceMax(800));
|
|
3471
|
-
simulation.force('layerX', d3.forceX(0).strength(0.03));
|
|
3472
|
-
simulation.force('layerY', d3.forceY(0).strength(0.03));
|
|
3473
|
-
} else {
|
|
3474
|
-
const centers = getLayerCenters();
|
|
3475
|
-
simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
|
|
3476
|
-
simulation.force('layerX', d3.forceX(d => centers[d.layer]?.x || 0).strength(d => d.layer ? lStrength : 0.03));
|
|
3477
|
-
simulation.force('layerY', d3.forceY(d => centers[d.layer]?.y || 0).strength(d => d.layer ? lStrength : 0.03));
|
|
3478
|
-
}
|
|
3479
|
-
simulation.alpha(0.6).restart();
|
|
3480
|
-
// Zoom to fit visible nodes after simulation settles
|
|
3481
|
-
setTimeout(() => zoomFit(), 600);
|
|
3482
|
-
}
|
|
3483
|
-
|
|
3484
|
-
// \u2500\u2500\u2500 Layer filter pills (new grouped bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3485
|
-
const layerRowEl = document.getElementById('filter-layer-row');
|
|
3486
|
-
const dirPanelEl = document.getElementById('filter-dir-panel');
|
|
3487
|
-
|
|
3488
|
-
// Dir toggle button
|
|
3489
|
-
const dirToggle = document.createElement('div');
|
|
3490
|
-
dirToggle.id = 'filter-dir-toggle';
|
|
3491
|
-
dirToggle.textContent = '\u25B8 Dirs';
|
|
3492
|
-
dirToggle.onclick = () => {
|
|
3493
|
-
dirToggle.classList.toggle('open');
|
|
3494
|
-
dirPanelEl.classList.toggle('open');
|
|
3495
|
-
dirToggle.textContent = dirPanelEl.classList.contains('open') ? '\u25BE Dirs' : '\u25B8 Dirs';
|
|
3496
|
-
};
|
|
3497
|
-
layerRowEl.appendChild(dirToggle);
|
|
3498
|
-
|
|
3499
|
-
// Cross-layer link toggle (in settings sidebar)
|
|
3500
|
-
let crossLinksUserEnabled = true;
|
|
3501
|
-
if (crossLinkData.length > 0) {
|
|
3502
|
-
document.getElementById('cross-layer-setting').style.display = '';
|
|
3503
|
-
window.toggleCrossLinks = () => {
|
|
3504
|
-
crossLinksUserEnabled = !crossLinksUserEnabled;
|
|
3505
|
-
const btn = document.getElementById('cross-link-toggle');
|
|
3506
|
-
btn.textContent = crossLinksUserEnabled ? 'ON' : 'OFF';
|
|
3507
|
-
btn.classList.toggle('active', crossLinksUserEnabled);
|
|
3508
|
-
applyLayerFilter();
|
|
3509
|
-
};
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
LAYERS.forEach(layer => {
|
|
3513
|
-
const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
|
|
3514
|
-
const pill = document.createElement('div');
|
|
3515
|
-
pill.className = 'layer-pill';
|
|
3516
|
-
pill.dataset.layer = layer.name;
|
|
3517
|
-
pill.innerHTML = '<div class="lp-dot" style="background:' + layer.color + '"></div>' + layer.name + ' <span class="lp-count">' + layerNodes.length + '</span>';
|
|
3518
|
-
pill.onclick = () => {
|
|
3519
|
-
if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
|
|
3520
|
-
else activeLayers.add(layer.name);
|
|
3521
|
-
syncLayerTabUI();
|
|
3522
|
-
applyLayerFilter();
|
|
3523
|
-
};
|
|
3524
|
-
pill.onmouseenter = () => {
|
|
3525
|
-
if (pinnedNode) return;
|
|
3526
|
-
node.select('circle').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.1);
|
|
3527
|
-
node.select('text').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.05);
|
|
3528
|
-
};
|
|
3529
|
-
pill.onmouseleave = () => {
|
|
3530
|
-
if (pinnedNode) return;
|
|
3531
|
-
node.select('circle').transition().duration(150).attr('opacity', 1);
|
|
3532
|
-
node.select('text').transition().duration(150).attr('opacity', d => d.dependents >= 1 || d.deps >= 3 ? 1 : 0.5);
|
|
3533
|
-
};
|
|
3534
|
-
layerRowEl.appendChild(pill);
|
|
3535
|
-
|
|
3536
|
-
// Build dir group in panel for this layer
|
|
3537
|
-
const layerDirs = [...new Set(layerNodes.map(n => n.dir))].sort();
|
|
3538
|
-
if (layerDirs.length > 0) {
|
|
3539
|
-
const group = document.createElement('div');
|
|
3540
|
-
group.className = 'dir-group';
|
|
3541
|
-
const label = document.createElement('div');
|
|
3542
|
-
label.className = 'dir-group-label';
|
|
3543
|
-
label.innerHTML = '<div class="dg-dot" style="background:' + layer.color + '"></div>' + layer.name;
|
|
3544
|
-
group.appendChild(label);
|
|
3545
|
-
const pillsWrap = document.createElement('div');
|
|
3546
|
-
pillsWrap.className = 'dir-group-pills';
|
|
3547
|
-
layerDirs.forEach(dir => {
|
|
3548
|
-
const dp = document.createElement('div');
|
|
3549
|
-
dp.className = 'filter-pill active';
|
|
3550
|
-
const shortDir = dir.includes('/') ? dir.substring(dir.indexOf('/') + 1) : dir;
|
|
3551
|
-
dp.innerHTML = '<div class="pill-dot" style="background:' + dirColor(dir) + '"></div>' + (shortDir || '.') + ' <span class="pill-count">' + (dirCounts[dir] || 0) + '</span>';
|
|
3552
|
-
dp.onclick = () => {
|
|
3553
|
-
if (activeDirs.has(dir)) { activeDirs.delete(dir); dp.classList.remove('active'); }
|
|
3554
|
-
else { activeDirs.add(dir); dp.classList.add('active'); }
|
|
3555
|
-
applyLayerFilter();
|
|
3556
|
-
};
|
|
3557
|
-
pillsWrap.appendChild(dp);
|
|
3558
|
-
});
|
|
3559
|
-
group.appendChild(pillsWrap);
|
|
3560
|
-
dirPanelEl.appendChild(group);
|
|
3561
|
-
}
|
|
3562
|
-
});
|
|
3563
|
-
|
|
3564
|
-
// Override applyFilter to respect layers
|
|
3565
|
-
window._origApplyFilter = applyFilter;
|
|
3566
|
-
}
|
|
3567
|
-
|
|
3568
|
-
setTimeout(()=>zoomFit(), 1500);
|
|
3569
|
-
|
|
3570
|
-
// Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
|
|
3571
|
-
if (_savedSettings) {
|
|
3572
|
-
if (_savedSettings.fontSize) setFontSize(_savedSettings.fontSize);
|
|
3573
|
-
}
|
|
3574
|
-
|
|
3575
|
-
// \u2500\u2500\u2500 Highlight helper \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
|
|
3576
|
-
let pinnedNode = null;
|
|
3577
|
-
|
|
3578
|
-
function highlightNode(d) {
|
|
3579
|
-
const conn = new Set([d.id]);
|
|
3580
|
-
DATA.links.forEach(l => { const s=l.source.id??l.source,t=l.target.id??l.target; if(s===d.id)conn.add(t); if(t===d.id)conn.add(s); });
|
|
3581
|
-
node.select('circle').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.1);
|
|
3582
|
-
node.select('text').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.05);
|
|
3583
|
-
link.transition().duration(150)
|
|
3584
|
-
.attr('opacity',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return s===d.id||t===d.id?0.9:0.03;})
|
|
3585
|
-
.attr('stroke',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(s===d.id)return'#58a6ff';if(t===d.id)return'#3fb950';return l.type==='type-only'?'#1f3d5c':'#30363d';})
|
|
3586
|
-
.attr('stroke-width',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return s===d.id||t===d.id?2:1;})
|
|
3587
|
-
.attr('marker-end',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(s===d.id)return'url(#arrow-1)';if(t===d.id)return'url(#arrow-2)';return'url(#arrow-0)';});
|
|
3588
|
-
}
|
|
3589
|
-
|
|
3590
|
-
function resetGraphHighlight() {
|
|
3591
|
-
pinnedNode = null;
|
|
3592
|
-
node.select('circle').transition().duration(200).attr('opacity',1);
|
|
3593
|
-
node.select('text').transition().duration(200).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
|
|
3594
|
-
link.transition().duration(200)
|
|
3595
|
-
.attr('opacity',baseLinkOpacity)
|
|
3596
|
-
.attr('stroke',d=>d.type==='type-only'?'#1f3d5c':'#30363d')
|
|
3597
|
-
.attr('stroke-width',1).attr('marker-end','url(#arrow-0)');
|
|
3598
|
-
}
|
|
3599
|
-
|
|
3600
|
-
// \u2500\u2500\u2500 Hover \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\u2500\u2500\u2500\u2500
|
|
3601
|
-
node.on('mouseover', (e,d) => {
|
|
3602
|
-
showTooltip(e,d);
|
|
3603
|
-
if (!pinnedNode) highlightNode(d);
|
|
3604
|
-
})
|
|
3605
|
-
.on('mousemove', e=>positionTooltip(e))
|
|
3606
|
-
.on('mouseout', () => { scheduleHideTooltip(); if (!pinnedNode) { /* highlight resets via scheduleHideTooltip */ } });
|
|
3607
|
-
|
|
3608
|
-
// \u2500\u2500\u2500 Click: pin highlight + detail panel \u2500\u2500\u2500\u2500\u2500
|
|
3609
|
-
node.on('click', (e,d) => {
|
|
3610
|
-
e.stopPropagation();
|
|
3611
|
-
pinnedNode = d;
|
|
3612
|
-
highlightNode(d);
|
|
3613
|
-
showDetail(d);
|
|
3614
|
-
});
|
|
3615
|
-
svg.on('click', () => {
|
|
3616
|
-
resetGraphHighlight();
|
|
3617
|
-
tooltip.style.display = 'none';
|
|
3618
|
-
tooltipLocked = false;
|
|
3619
|
-
closeDetail();
|
|
3620
|
-
});
|
|
3621
|
-
|
|
3622
|
-
function showDetail(d) {
|
|
3623
|
-
const p=document.getElementById('detail');
|
|
3624
|
-
document.getElementById('d-name').textContent=d.id;
|
|
3625
|
-
document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+d.dir+'<br>'+i('detail.dependencies')+': '+d.deps+' \xB7 '+i('detail.dependents')+': '+d.dependents;
|
|
3626
|
-
const deptL=document.getElementById('d-dependents'), depsL=document.getElementById('d-deps');
|
|
3627
|
-
deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li onclick="focusNode(\\''+x+'\\')">\u2190 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
3628
|
-
depsL.innerHTML=(d.dependencies||[]).map(x=>'<li onclick="focusNode(\\''+x+'\\')">\u2192 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
3629
|
-
p.classList.add('open');
|
|
3630
|
-
}
|
|
3631
|
-
window.closeDetail=()=>document.getElementById('detail').classList.remove('open');
|
|
3632
|
-
window.focusNode=(id)=>{
|
|
3633
|
-
const n=DATA.nodes.find(x=>x.id===id); if(!n)return; showDetail(n);
|
|
3634
|
-
svg.transition().duration(500).call(zoom.transform,d3.zoomIdentity.translate(W/2-n.x*1.5,H/2-n.y*1.5).scale(1.5));
|
|
3635
|
-
};
|
|
3636
|
-
|
|
3637
|
-
// Drag
|
|
3638
|
-
function dragStart(e,d){if(!e.active)simulation.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}
|
|
3639
|
-
function dragging(e,d){d.fx=e.x;d.fy=e.y;}
|
|
3640
|
-
function dragEnd(e,d){if(!e.active)simulation.alphaTarget(0);}
|
|
3641
|
-
|
|
3642
|
-
// \u2500\u2500\u2500 Search \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\u2500\u2500\u2500
|
|
3643
|
-
const searchInput=document.getElementById('search');
|
|
3644
|
-
document.addEventListener('keydown',e=>{
|
|
3645
|
-
if(e.key==='/'&&document.activeElement!==searchInput){e.preventDefault();searchInput.focus();}
|
|
3646
|
-
if(e.key==='Escape'){searchInput.value='';searchInput.blur();resetGraphHighlight();}
|
|
3647
|
-
});
|
|
3648
|
-
searchInput.addEventListener('input',e=>{
|
|
3649
|
-
const q=e.target.value.toLowerCase();
|
|
3650
|
-
if(!q){resetGraphHighlight();return;}
|
|
3651
|
-
node.select('circle').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.06);
|
|
3652
|
-
node.select('text').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.04);
|
|
3653
|
-
link.attr('opacity',0.03);
|
|
3654
|
-
});
|
|
3655
|
-
|
|
3656
|
-
// \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
|
|
3657
|
-
if (!LAYERS) {
|
|
3658
|
-
// Non-layer mode: flat pills in filter-layer-row
|
|
3659
|
-
const filterRowEl=document.getElementById('filter-layer-row');
|
|
3660
|
-
DATA.dirs.forEach(dir=>{
|
|
3661
|
-
const pill=document.createElement('div');
|
|
3662
|
-
pill.className='filter-pill active';
|
|
3663
|
-
pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
|
|
3664
|
-
pill.onclick=()=>{
|
|
3665
|
-
if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
|
|
3666
|
-
else{activeDirs.add(dir);pill.classList.add('active');}
|
|
3667
|
-
applyFilter();
|
|
3668
|
-
};
|
|
3669
|
-
pill.onmouseenter=()=>{
|
|
3670
|
-
if(pinnedNode)return;
|
|
3671
|
-
node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
|
|
3672
|
-
node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
|
|
3673
|
-
};
|
|
3674
|
-
pill.onmouseleave=()=>{
|
|
3675
|
-
if(pinnedNode)return;
|
|
3676
|
-
node.select('circle').transition().duration(150).attr('opacity',1);
|
|
3677
|
-
node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
|
|
3678
|
-
};
|
|
3679
|
-
filterRowEl.appendChild(pill);
|
|
3680
|
-
});
|
|
3681
|
-
}
|
|
3682
|
-
function applyFilter(){
|
|
3683
|
-
if (LAYERS) {
|
|
3684
|
-
// Delegate to layer-aware filter
|
|
3685
|
-
if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
|
|
3686
|
-
}
|
|
3687
|
-
node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
|
|
3688
|
-
link.attr('display',l=>{
|
|
3689
|
-
const s=l.source.id??l.source,t=l.target.id??l.target;
|
|
3690
|
-
const sD=DATA.nodes.find(n=>n.id===s)?.dir,tD=DATA.nodes.find(n=>n.id===t)?.dir;
|
|
3691
|
-
return activeDirs.has(sD)&&activeDirs.has(tD)?null:'none';
|
|
3692
|
-
});
|
|
3693
|
-
}
|
|
3694
|
-
|
|
3695
|
-
// \u2500\u2500\u2500 Impact simulation mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3696
|
-
let impactMode=false;
|
|
3697
|
-
const impactBadge=document.getElementById('impact-badge');
|
|
3698
|
-
window.toggleImpactMode=()=>{
|
|
3699
|
-
impactMode=!impactMode;
|
|
3700
|
-
document.getElementById('impact-btn').classList.toggle('active',impactMode);
|
|
3701
|
-
if(!impactMode){impactBadge.style.display='none';resetGraphHighlight();}
|
|
3702
|
-
};
|
|
3703
|
-
function getTransitiveDependents(startId){
|
|
3704
|
-
const result=new Set();const queue=[startId];
|
|
3705
|
-
const revMap={};
|
|
3706
|
-
DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!revMap[t])revMap[t]=[];revMap[t].push(s);});
|
|
3707
|
-
while(queue.length){const id=queue.shift();if(result.has(id))continue;result.add(id);(revMap[id]||[]).forEach(x=>queue.push(x));}
|
|
3708
|
-
return result;
|
|
3709
|
-
}
|
|
3710
|
-
// Override click in impact mode
|
|
3711
|
-
const origClick=node.on('click');
|
|
3712
|
-
node.on('click',(e,d)=>{
|
|
3713
|
-
if(!impactMode){e.stopPropagation();pinnedNode=d;highlightNode(d);showDetail(d);return;}
|
|
3714
|
-
e.stopPropagation();
|
|
3715
|
-
const affected=getTransitiveDependents(d.id);
|
|
3716
|
-
node.select('circle').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.06)
|
|
3717
|
-
.attr('stroke',n=>affected.has(n.id)&&n.id!==d.id?'var(--red)':n.deps>=5?'var(--yellow)':nodeColor(n))
|
|
3718
|
-
.attr('stroke-width',n=>affected.has(n.id)?3:1.5);
|
|
3719
|
-
node.select('text').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.04);
|
|
3720
|
-
link.transition().duration(200).attr('opacity',l=>{
|
|
3721
|
-
const s=l.source.id??l.source,t=l.target.id??l.target;
|
|
3722
|
-
return affected.has(s)&&affected.has(t)?0.8:0.03;
|
|
3723
|
-
}).attr('stroke',l=>{
|
|
3724
|
-
const s=l.source.id??l.source,t=l.target.id??l.target;
|
|
3725
|
-
return affected.has(s)&&affected.has(t)?'var(--red)':l.type==='type-only'?'#1f3d5c':'#30363d';
|
|
3726
|
-
});
|
|
3727
|
-
impactBadge.textContent=d.id.split('/').pop()+' \u2192 '+(affected.size-1)+' '+i('impact.transitive');
|
|
3728
|
-
impactBadge.style.display='block';
|
|
3729
|
-
});
|
|
3730
|
-
|
|
3731
|
-
window.addEventListener('resize',()=>{
|
|
3732
|
-
const w=window.innerWidth,h=window.innerHeight-44;
|
|
3733
|
-
svg.attr('width',w).attr('height',h);
|
|
3734
|
-
});
|
|
3735
|
-
|
|
3736
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3737
|
-
// HIERARCHY VIEW
|
|
3738
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
3739
|
-
function buildHierarchy(){
|
|
3740
|
-
const hSvg=d3.select('#hier-svg');
|
|
3741
|
-
const hG=hSvg.append('g');
|
|
3742
|
-
const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
|
|
3743
|
-
hSvg.call(hZoom);
|
|
3744
|
-
|
|
3745
|
-
const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
|
|
3746
|
-
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);});
|
|
3747
|
-
|
|
3748
|
-
const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
|
|
3749
|
-
const layers={};const visited=new Set();
|
|
3750
|
-
const queue=entryPoints.map(id=>({id,layer:0}));
|
|
3751
|
-
DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
|
|
3752
|
-
|
|
3753
|
-
while(queue.length>0){
|
|
3754
|
-
const{id,layer}=queue.shift();
|
|
3755
|
-
if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
|
|
3756
|
-
layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
|
|
3757
|
-
(importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
|
|
3758
|
-
}
|
|
3759
|
-
DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
|
|
3760
|
-
|
|
3761
|
-
const maxLayer=Math.max(0,...Object.values(layers));
|
|
3762
|
-
const layerGroups={};
|
|
3763
|
-
for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
|
|
3764
|
-
Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
|
|
3765
|
-
Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
|
|
3766
|
-
|
|
3767
|
-
const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
|
|
3768
|
-
const positions={};let maxRowWidth=0;
|
|
3769
|
-
for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
|
|
3770
|
-
for(let layer=0;layer<=maxLayer;layer++){
|
|
3771
|
-
const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
|
|
3772
|
-
items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
|
|
3773
|
-
}
|
|
3774
|
-
|
|
3775
|
-
const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
|
|
3776
|
-
hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
|
|
3777
|
-
|
|
3778
|
-
const linkG=hG.append('g');
|
|
3779
|
-
DATA.links.forEach(l=>{
|
|
3780
|
-
const sId=l.source.id??l.source,tId=l.target.id??l.target;
|
|
3781
|
-
const s=positions[sId],t=positions[tId]; if(!s||!t)return;
|
|
3782
|
-
const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
|
|
3783
|
-
linkG.append('path').attr('class','hier-link')
|
|
3784
|
-
.attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
|
|
3785
|
-
.attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
|
|
3786
|
-
.attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
|
|
3787
|
-
.attr('data-source',sId).attr('data-target',tId);
|
|
3788
|
-
});
|
|
3789
|
-
|
|
3790
|
-
hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
|
|
3791
|
-
.attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
|
|
3792
|
-
.append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
|
|
3793
|
-
linkG.selectAll('path').attr('marker-end','url(#harrow)');
|
|
3794
|
-
|
|
3795
|
-
for(let layer=0;layer<=maxLayer;layer++){
|
|
3796
|
-
if(!layerGroups[layer].length)continue;
|
|
3797
|
-
hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
|
|
3798
|
-
.attr('data-depth-idx',layer)
|
|
3799
|
-
.attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
|
|
3800
|
-
}
|
|
3801
|
-
|
|
3802
|
-
const nodeG=hG.append('g');
|
|
3803
|
-
DATA.nodes.forEach(n=>{
|
|
3804
|
-
const pos=positions[n.id]; if(!pos)return;
|
|
3805
|
-
const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
|
|
3806
|
-
gn.append('rect').attr('width',boxW).attr('height',boxH)
|
|
3807
|
-
.attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
|
|
3808
|
-
.attr('stroke-width',circularSet.has(n.id)?2:1.5);
|
|
3809
|
-
gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
|
|
3810
|
-
.text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\u2026':fileName(n.id));
|
|
3811
|
-
gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
|
|
3812
|
-
.attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
|
|
3813
|
-
.text(n.dependents>0?'\u2191'+n.dependents:'');
|
|
3814
|
-
gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
|
|
3815
|
-
.attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
|
|
3816
|
-
|
|
3817
|
-
gn.node().__data_id=n.id;
|
|
3818
|
-
gn.on('mouseover',e=>{
|
|
3819
|
-
showTooltip(e,n);
|
|
3820
|
-
if (!hierPinned) hierHighlight(n.id);
|
|
3821
|
-
})
|
|
3822
|
-
.on('mousemove',e=>positionTooltip(e))
|
|
3823
|
-
.on('mouseout',()=>{
|
|
3824
|
-
scheduleHideTooltip();
|
|
3825
|
-
if (!hierPinned) hierResetHighlight();
|
|
3826
|
-
})
|
|
3827
|
-
.on('click',(e)=>{
|
|
3828
|
-
e.stopPropagation();
|
|
3829
|
-
hierPinned=n.id;
|
|
3830
|
-
hierHighlight(n.id);
|
|
3831
|
-
showHierDetail(n);
|
|
3832
|
-
});
|
|
3833
|
-
});
|
|
3834
|
-
|
|
3835
|
-
// Hierarchy highlight helpers
|
|
3836
|
-
let hierPinned=null;
|
|
3837
|
-
function hierHighlight(nId){
|
|
3838
|
-
linkG.selectAll('path')
|
|
3839
|
-
.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)';})
|
|
3840
|
-
.attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
|
|
3841
|
-
.attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
|
|
3842
|
-
nodeG.selectAll('.hier-node').attr('opacity',function(){
|
|
3843
|
-
const id=this.__data_id; if(id===nId)return 1;
|
|
3844
|
-
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);});
|
|
3845
|
-
return connected?1:0.3;
|
|
3846
|
-
});
|
|
3847
|
-
}
|
|
3848
|
-
function hierResetHighlight(){
|
|
3849
|
-
hierPinned=null;
|
|
3850
|
-
linkG.selectAll('path')
|
|
3851
|
-
.attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
|
|
3852
|
-
.attr('stroke-width',1).attr('opacity',1);
|
|
3853
|
-
nodeG.selectAll('.hier-node').attr('opacity',1);
|
|
3854
|
-
}
|
|
3855
|
-
function showHierDetail(n){
|
|
3856
|
-
const p=document.getElementById('hier-detail');
|
|
3857
|
-
document.getElementById('hd-name').textContent=n.id;
|
|
3858
|
-
document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+n.dir+'<br>'+i('detail.dependencies')+': '+n.deps+' \xB7 '+i('detail.dependents')+': '+n.dependents;
|
|
3859
|
-
document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\u2190 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
3860
|
-
document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\u2192 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
|
|
3861
|
-
p.classList.add('open');
|
|
3862
|
-
}
|
|
3863
|
-
window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
|
|
3864
|
-
|
|
3865
|
-
// Click on empty space to deselect
|
|
3866
|
-
hSvg.on('click',()=>{closeHierDetail();});
|
|
3867
|
-
|
|
3868
|
-
// Hierarchy filters \u2014 layer pills or dir pills
|
|
3869
|
-
const hFilterRow=document.getElementById('hier-filter-row');
|
|
3870
|
-
const hFilterBar=document.getElementById('hier-filter-bar');
|
|
3871
|
-
if (hFilterBar) hFilterBar.style.display='';
|
|
3872
|
-
const hActiveLayers=new Set(); // empty = show all (same as graph view)
|
|
3873
|
-
|
|
3874
|
-
function hierRelayoutInner() {
|
|
3875
|
-
function isVisible(nId) {
|
|
3876
|
-
var nd = nodeMap[nId];
|
|
3877
|
-
if (!nd) return false;
|
|
3878
|
-
if (LAYERS && nd.layer && hActiveLayers.size > 0 && !hActiveLayers.has(nd.layer)) return false;
|
|
3879
|
-
return true;
|
|
3880
|
-
}
|
|
3881
|
-
|
|
3882
|
-
// Build visible layer groups and compact Y positions
|
|
3883
|
-
var visibleDepths = [];
|
|
3884
|
-
var visLayerGroups = {};
|
|
3885
|
-
for (var depth = 0; depth <= maxLayer; depth++) {
|
|
3886
|
-
var visItems = layerGroups[depth].filter(function(id) { return isVisible(id); });
|
|
3887
|
-
if (visItems.length > 0) {
|
|
3888
|
-
visLayerGroups[depth] = visItems;
|
|
3889
|
-
visibleDepths.push(depth);
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
|
|
3893
|
-
// Recalculate positions for visible nodes (compacted)
|
|
3894
|
-
var newPositions = {};
|
|
3895
|
-
var newMaxRowWidth = 0;
|
|
3896
|
-
visibleDepths.forEach(function(depth) {
|
|
3897
|
-
newMaxRowWidth = Math.max(newMaxRowWidth, visLayerGroups[depth].length * (boxW + gapX) - gapX);
|
|
3898
|
-
});
|
|
3899
|
-
visibleDepths.forEach(function(depth, yIdx) {
|
|
3900
|
-
var items = visLayerGroups[depth];
|
|
3901
|
-
var rowWidth = items.length * (boxW + gapX) - gapX;
|
|
3902
|
-
var startX = padX + (newMaxRowWidth - rowWidth) / 2;
|
|
3903
|
-
items.forEach(function(id, idx) {
|
|
3904
|
-
newPositions[id] = { x: startX + idx * (boxW + gapX), y: padY + yIdx * (boxH + gapY) };
|
|
3905
|
-
});
|
|
3906
|
-
});
|
|
3907
|
-
|
|
3908
|
-
// Update SVG size
|
|
3909
|
-
var newTotalW = (newMaxRowWidth || 0) + padX * 2;
|
|
3910
|
-
var newTotalH = padY * 2 + Math.max(1, visibleDepths.length) * (boxH + gapY);
|
|
3911
|
-
hSvg.attr('width', Math.max(newTotalW, W)).attr('height', Math.max(newTotalH, H));
|
|
3912
|
-
|
|
3913
|
-
// Update nodes: hide/show + transition positions
|
|
3914
|
-
nodeG.selectAll('.hier-node').each(function() {
|
|
3915
|
-
var nId = this.__data_id;
|
|
3916
|
-
var el = d3.select(this);
|
|
3917
|
-
if (!isVisible(nId) || !newPositions[nId]) {
|
|
3918
|
-
el.attr('display', 'none');
|
|
3919
|
-
} else {
|
|
3920
|
-
el.attr('display', null)
|
|
3921
|
-
.transition().duration(300)
|
|
3922
|
-
.attr('transform', 'translate(' + newPositions[nId].x + ',' + newPositions[nId].y + ')');
|
|
3923
|
-
}
|
|
3924
|
-
});
|
|
3925
|
-
|
|
3926
|
-
// Update links: show only if both endpoints visible, recalculate bezier
|
|
3927
|
-
linkG.selectAll('path').each(function() {
|
|
3928
|
-
var sId = this.getAttribute('data-source');
|
|
3929
|
-
var tId = this.getAttribute('data-target');
|
|
3930
|
-
var el = d3.select(this);
|
|
3931
|
-
if (!isVisible(sId) || !isVisible(tId) || !newPositions[sId] || !newPositions[tId]) {
|
|
3932
|
-
el.attr('display', 'none');
|
|
3933
|
-
} else {
|
|
3934
|
-
var s = newPositions[sId], t = newPositions[tId];
|
|
3935
|
-
var x1 = s.x + boxW / 2, y1 = s.y + boxH;
|
|
3936
|
-
var x2 = t.x + boxW / 2, y2 = t.y;
|
|
3937
|
-
var midY = (y1 + y2) / 2;
|
|
3938
|
-
el.attr('display', null)
|
|
3939
|
-
.transition().duration(300)
|
|
3940
|
-
.attr('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2);
|
|
3941
|
-
}
|
|
3942
|
-
});
|
|
3943
|
-
|
|
3944
|
-
// Update depth labels: hide empty depths, reposition visible ones
|
|
3945
|
-
hG.selectAll('.hier-layer-label').each(function() {
|
|
3946
|
-
var depthIdx = +this.getAttribute('data-depth-idx');
|
|
3947
|
-
var el = d3.select(this);
|
|
3948
|
-
var yIdx = visibleDepths.indexOf(depthIdx);
|
|
3949
|
-
if (yIdx === -1) {
|
|
3950
|
-
el.attr('display', 'none');
|
|
3951
|
-
} else {
|
|
3952
|
-
el.attr('display', null)
|
|
3953
|
-
.transition().duration(300)
|
|
3954
|
-
.attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
|
|
3955
|
-
}
|
|
3956
|
-
});
|
|
3957
|
-
|
|
3958
|
-
// Close detail panel if pinned node became hidden
|
|
3959
|
-
if (hierPinned && !isVisible(hierPinned)) {
|
|
3960
|
-
closeHierDetail();
|
|
3961
|
-
}
|
|
3962
|
-
}
|
|
3963
|
-
|
|
3964
|
-
function hierSyncFromTabInner() {
|
|
3965
|
-
if (!LAYERS) return;
|
|
3966
|
-
hActiveLayers.clear();
|
|
3967
|
-
activeLayers.forEach(function(name) { hActiveLayers.add(name); });
|
|
3968
|
-
// Sync pill UI
|
|
3969
|
-
hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
|
|
3970
|
-
var ln = p.dataset.layer;
|
|
3971
|
-
if (ln === 'all') {
|
|
3972
|
-
p.classList.toggle('active', hActiveLayers.size === 0);
|
|
3973
|
-
} else {
|
|
3974
|
-
p.classList.toggle('active', hActiveLayers.has(ln));
|
|
3975
|
-
}
|
|
3976
|
-
});
|
|
3977
|
-
}
|
|
3978
|
-
|
|
3979
|
-
if (LAYERS) {
|
|
3980
|
-
// "All" button
|
|
3981
|
-
const allPill=document.createElement('div');
|
|
3982
|
-
allPill.className='layer-pill active';
|
|
3983
|
-
allPill.style.fontWeight='400';
|
|
3984
|
-
allPill.textContent='All';
|
|
3985
|
-
allPill.dataset.layer='all';
|
|
3986
|
-
allPill.onclick=()=>{
|
|
3987
|
-
hActiveLayers.clear();
|
|
3988
|
-
hFilterRow.querySelectorAll('.layer-pill').forEach(p=>p.classList.remove('active'));
|
|
3989
|
-
allPill.classList.add('active');
|
|
3990
|
-
hierRelayoutInner();
|
|
3991
|
-
};
|
|
3992
|
-
hFilterRow.appendChild(allPill);
|
|
3993
|
-
|
|
3994
|
-
LAYERS.forEach(layer => {
|
|
3995
|
-
const pill=document.createElement('div');
|
|
3996
|
-
pill.className='layer-pill';
|
|
3997
|
-
pill.dataset.layer=layer.name;
|
|
3998
|
-
const count=DATA.nodes.filter(n=>n.layer===layer.name).length;
|
|
3999
|
-
pill.innerHTML='<div class="lp-dot" style="background:'+layer.color+'"></div>'+layer.name+' <span class="lp-count">'+count+'</span>';
|
|
4000
|
-
pill.onclick=(e)=>{
|
|
4001
|
-
if (e.shiftKey) {
|
|
4002
|
-
hActiveLayers.clear();
|
|
4003
|
-
hActiveLayers.add(layer.name);
|
|
4004
|
-
} else {
|
|
4005
|
-
if (hActiveLayers.has(layer.name)) hActiveLayers.delete(layer.name);
|
|
4006
|
-
else hActiveLayers.add(layer.name);
|
|
4007
|
-
}
|
|
4008
|
-
// Sync pill UI
|
|
4009
|
-
hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
|
|
4010
|
-
var ln = p.dataset.layer;
|
|
4011
|
-
if (ln === 'all') p.classList.toggle('active', hActiveLayers.size === 0);
|
|
4012
|
-
else p.classList.toggle('active', hActiveLayers.has(ln));
|
|
4013
|
-
});
|
|
4014
|
-
hierRelayoutInner();
|
|
4015
|
-
};
|
|
4016
|
-
hFilterRow.appendChild(pill);
|
|
4017
|
-
});
|
|
4018
|
-
} else {
|
|
4019
|
-
const hActiveDirs=new Set(DATA.dirs);
|
|
4020
|
-
DATA.dirs.forEach(dir=>{
|
|
4021
|
-
const pill=document.createElement('div');
|
|
4022
|
-
pill.className='filter-pill active';
|
|
4023
|
-
pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
|
|
4024
|
-
pill.onclick=()=>{
|
|
4025
|
-
if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
|
|
4026
|
-
else{hActiveDirs.add(dir);pill.classList.add('active');}
|
|
4027
|
-
nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
|
|
4028
|
-
};
|
|
4029
|
-
hFilterRow.appendChild(pill);
|
|
4030
|
-
});
|
|
4031
|
-
}
|
|
4032
|
-
|
|
4033
|
-
// Assign function pointers for cross-view sync
|
|
4034
|
-
hierRelayout = hierRelayoutInner;
|
|
4035
|
-
hierSyncFromTab = hierSyncFromTabInner;
|
|
4036
|
-
|
|
4037
|
-
hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
|
|
4038
|
-
Math.max(0,(W-totalW)/2),20
|
|
4039
|
-
).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
|
|
4040
|
-
|
|
4041
|
-
// If a layer tab was already selected, sync hierarchy on first build
|
|
4042
|
-
if (activeLayerFilter) {
|
|
4043
|
-
hierSyncFromTabInner(activeLayerFilter);
|
|
4044
|
-
hierRelayoutInner();
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
|
|
4048
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
4049
|
-
// DIFF VIEW
|
|
4050
|
-
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
4051
|
-
const DIFF = ${diffData};
|
|
4052
|
-
if (DIFF) {
|
|
4053
|
-
document.getElementById('diff-tab').style.display = '';
|
|
4054
|
-
const addedSet = new Set(DIFF.added||[]);
|
|
4055
|
-
const removedSet = new Set(DIFF.removed||[]);
|
|
4056
|
-
const modifiedSet = new Set(DIFF.modified||[]);
|
|
4057
|
-
const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
|
|
4058
|
-
|
|
4059
|
-
let diffBuilt = false;
|
|
4060
|
-
function buildDiffView() {
|
|
4061
|
-
const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
|
|
4062
|
-
const dG = dSvg.append('g');
|
|
4063
|
-
const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
|
|
4064
|
-
dSvg.call(dZoom);
|
|
4065
|
-
|
|
4066
|
-
function diffColor(d) {
|
|
4067
|
-
if (addedSet.has(d.id)) return 'var(--green)';
|
|
4068
|
-
if (removedSet.has(d.id)) return 'var(--red)';
|
|
4069
|
-
if (modifiedSet.has(d.id)) return 'var(--yellow)';
|
|
4070
|
-
if (affectedSet.has(d.id)) return 'var(--accent)';
|
|
4071
|
-
return '#30363d';
|
|
4072
|
-
}
|
|
4073
|
-
|
|
4074
|
-
const dDefs = dSvg.append('defs');
|
|
4075
|
-
dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
|
|
4076
|
-
.attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
|
|
4077
|
-
.append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
|
|
4078
|
-
|
|
4079
|
-
const simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
|
|
4080
|
-
const simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
|
|
2984
|
+
.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; }
|
|
2985
|
+
.layer-tab:hover { color: var(--text); background: var(--bg-hover); }
|
|
2986
|
+
.layer-tab.active { border-color: var(--accent); color: var(--text); }
|
|
2987
|
+
.layer-tab .lt-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
4081
2988
|
|
|
4082
|
-
|
|
4083
|
-
|
|
2989
|
+
/* \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 */
|
|
2990
|
+
.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; }
|
|
2991
|
+
.layer-pill:hover { border-color: var(--text-dim); }
|
|
2992
|
+
.layer-pill.active { border-color: var(--accent); }
|
|
2993
|
+
.layer-pill .lp-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
2994
|
+
.layer-pill .lp-count { color: var(--text-muted); font-size: 9px; font-weight: 400; }
|
|
2995
|
+
</style>`;
|
|
2996
|
+
}
|
|
4084
2997
|
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
2998
|
+
// src/web/viewer-html.ts
|
|
2999
|
+
function buildViewerHtml() {
|
|
3000
|
+
return `
|
|
3001
|
+
<!-- Tab bar -->
|
|
3002
|
+
<div id="tab-bar">
|
|
3003
|
+
<span class="logo" id="project-title" contenteditable="true" spellcheck="false" title="Click to edit project name"></span>
|
|
3004
|
+
<div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
|
|
3005
|
+
<div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
|
|
3006
|
+
<div class="tab" data-view="diff-view" id="diff-tab" style="display:none" data-i18n="tab.diff">Diff</div>
|
|
3007
|
+
<div id="layer-tabs"></div>
|
|
3008
|
+
<div class="tab-right">
|
|
3009
|
+
<div class="tab-stats">
|
|
3010
|
+
<span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
|
|
3011
|
+
<span><span data-i18n="stats.edges">Edges</span> <b id="s-edges">0</b></span>
|
|
3012
|
+
<span><span data-i18n="stats.circular">Circular</span> <b id="s-circular">0</b></span>
|
|
3013
|
+
</div>
|
|
3014
|
+
<button class="settings-btn" onclick="toggleSettings()" title="Settings">\u2699</button>
|
|
3015
|
+
</div>
|
|
3016
|
+
</div>
|
|
4097
3017
|
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
3018
|
+
<!-- Settings panel -->
|
|
3019
|
+
<div id="settings-panel">
|
|
3020
|
+
<h3 data-i18n="settings.title">Settings</h3>
|
|
3021
|
+
<div class="setting-group">
|
|
3022
|
+
<label data-i18n="settings.theme">Theme</label>
|
|
3023
|
+
<div class="theme-toggle">
|
|
3024
|
+
<div class="theme-btn active" data-theme-val="dark" onclick="setTheme('dark')">\u{1F319} Dark</div>
|
|
3025
|
+
<div class="theme-btn" data-theme-val="light" onclick="setTheme('light')">\u2600\uFE0F Light</div>
|
|
3026
|
+
</div>
|
|
3027
|
+
</div>
|
|
3028
|
+
<div class="setting-group">
|
|
3029
|
+
<label data-i18n="settings.fontSize">Font Size</label>
|
|
3030
|
+
<input type="range" id="font-size-slider" min="10" max="18" value="13" oninput="setFontSize(this.value)">
|
|
3031
|
+
<div class="setting-value"><span id="font-size-val">13</span>px</div>
|
|
3032
|
+
</div>
|
|
3033
|
+
<div class="setting-group">
|
|
3034
|
+
<label data-i18n="settings.nodeSize">Node Size</label>
|
|
3035
|
+
<input type="range" id="node-size-slider" min="50" max="200" value="100" oninput="setNodeScale(this.value)">
|
|
3036
|
+
<div class="setting-value"><span id="node-size-val">100</span>%</div>
|
|
3037
|
+
</div>
|
|
3038
|
+
<div class="setting-group">
|
|
3039
|
+
<label data-i18n="settings.linkOpacity">Link Opacity</label>
|
|
3040
|
+
<input type="range" id="link-opacity-slider" min="10" max="100" value="40" oninput="setLinkOpacity(this.value)">
|
|
3041
|
+
<div class="setting-value"><span id="link-opacity-val">40</span>%</div>
|
|
3042
|
+
</div>
|
|
3043
|
+
<div class="setting-group">
|
|
3044
|
+
<label data-i18n="settings.gravity">Gravity</label>
|
|
3045
|
+
<input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
|
|
3046
|
+
<div class="setting-value"><span id="gravity-val">150</span></div>
|
|
3047
|
+
</div>
|
|
3048
|
+
<div id="layer-gravity-setting" class="setting-group" style="display:none">
|
|
3049
|
+
<label>Layer Cohesion</label>
|
|
3050
|
+
<input type="range" id="layer-gravity-slider" min="1" max="40" value="12" oninput="setLayerGravity(this.value)">
|
|
3051
|
+
<div class="setting-value"><span id="layer-gravity-val">12</span></div>
|
|
3052
|
+
</div>
|
|
3053
|
+
<div class="setting-group">
|
|
3054
|
+
<label data-i18n="settings.language">Language</label>
|
|
3055
|
+
<div class="theme-toggle">
|
|
3056
|
+
<div class="theme-btn lang-btn" data-lang="en" onclick="setLang('en')">English</div>
|
|
3057
|
+
<div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
|
|
3058
|
+
</div>
|
|
3059
|
+
</div>
|
|
3060
|
+
<div id="cross-layer-setting" class="setting-group" style="display:none">
|
|
3061
|
+
<label>Cross-layer Links</label>
|
|
3062
|
+
<div class="theme-toggle">
|
|
3063
|
+
<div class="theme-btn active" id="cross-link-toggle" onclick="toggleCrossLinks()">ON</div>
|
|
3064
|
+
</div>
|
|
3065
|
+
</div>
|
|
3066
|
+
<div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
|
3067
|
+
<label data-i18n="settings.export">Export</label>
|
|
3068
|
+
<div class="theme-toggle">
|
|
3069
|
+
<div class="theme-btn" onclick="exportSVG()">SVG</div>
|
|
3070
|
+
<div class="theme-btn" onclick="exportPNG()">PNG</div>
|
|
3071
|
+
</div>
|
|
3072
|
+
</div>
|
|
3073
|
+
</div>
|
|
4103
3074
|
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
3075
|
+
<!-- Graph View -->
|
|
3076
|
+
<div id="graph-view" class="view active">
|
|
3077
|
+
<svg id="graph-svg"></svg>
|
|
3078
|
+
<div id="hud">
|
|
3079
|
+
<div class="hud-panel" id="search-box">
|
|
3080
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style="color:var(--text-muted)"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>
|
|
3081
|
+
<input id="search" type="text" data-i18n-placeholder="search.placeholder" placeholder="Search files..." autocomplete="off">
|
|
3082
|
+
<kbd>/</kbd>
|
|
3083
|
+
</div>
|
|
3084
|
+
<div class="hud-panel" id="legend-panel">
|
|
3085
|
+
<div id="layer-legend"></div>
|
|
3086
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
3087
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
3088
|
+
<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>
|
|
3089
|
+
<div class="legend-item" style="margin-top:4px;font-size:11px;gap:3px"><span style="color:var(--accent)">\u2014\u2192</span> <span data-i18n="legend.imports">imports</span> <span style="margin-left:6px;color:var(--green)">\u2190\u2014</span> <span data-i18n="legend.importedBy">imported by</span></div>
|
|
3090
|
+
</div>
|
|
3091
|
+
</div>
|
|
3092
|
+
<div id="detail">
|
|
3093
|
+
<button class="close-btn" onclick="closeDetail()">\u2715</button>
|
|
3094
|
+
<div class="detail-name" id="d-name"></div>
|
|
3095
|
+
<div class="detail-meta" id="d-meta"></div>
|
|
3096
|
+
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
|
|
3097
|
+
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
|
|
3098
|
+
</div>
|
|
3099
|
+
<div id="filter-bar">
|
|
3100
|
+
<div id="filter-dir-panel"></div>
|
|
3101
|
+
<div id="filter-layer-row"></div>
|
|
3102
|
+
</div>
|
|
3103
|
+
<div id="zoom-ctrl">
|
|
3104
|
+
<button onclick="zoomIn()" title="Zoom in">+</button>
|
|
3105
|
+
<button onclick="zoomOut()" title="Zoom out">\u2212</button>
|
|
3106
|
+
<button onclick="zoomFit()" title="Fit">\u229E</button>
|
|
3107
|
+
<button id="impact-btn" onclick="toggleImpactMode()" title="Impact simulation" style="font-size:12px;margin-top:4px" data-i18n="impact.btn">Impact</button>
|
|
3108
|
+
</div>
|
|
3109
|
+
<div id="impact-badge"></div>
|
|
3110
|
+
<div id="help-bar" data-i18n="help.graph">Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search</div>
|
|
3111
|
+
</div>
|
|
4141
3112
|
|
|
4142
|
-
|
|
4143
|
-
|
|
3113
|
+
<!-- Hierarchy View -->
|
|
3114
|
+
<div id="hier-view" class="view">
|
|
3115
|
+
<svg id="hier-svg"></svg>
|
|
3116
|
+
<div id="hier-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
|
|
3117
|
+
<div class="hud-panel" id="hier-legend">
|
|
3118
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
|
|
3119
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
|
|
3120
|
+
<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>
|
|
3121
|
+
</div>
|
|
3122
|
+
</div>
|
|
3123
|
+
<div id="hier-detail">
|
|
3124
|
+
<button class="close-btn" onclick="closeHierDetail()">\u2715</button>
|
|
3125
|
+
<div class="detail-name" id="hd-name"></div>
|
|
3126
|
+
<div class="detail-meta" id="hd-meta"></div>
|
|
3127
|
+
<div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
|
|
3128
|
+
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
|
|
3129
|
+
</div>
|
|
3130
|
+
<div id="hier-filter-bar" style="position:absolute;bottom:12px;left:12px;right:120px;z-index:10;display:none;">
|
|
3131
|
+
<div id="hier-filter-row" style="display:flex;flex-wrap:wrap;gap:4px;"></div>
|
|
3132
|
+
</div>
|
|
3133
|
+
<div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
|
|
3134
|
+
</div>
|
|
4144
3135
|
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
3136
|
+
<!-- Diff View -->
|
|
3137
|
+
<div id="diff-view" class="view">
|
|
3138
|
+
<svg id="diff-svg"></svg>
|
|
3139
|
+
<div id="diff-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
|
|
3140
|
+
<div class="hud-panel">
|
|
3141
|
+
<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>
|
|
3142
|
+
<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>
|
|
3143
|
+
<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>
|
|
3144
|
+
<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>
|
|
3145
|
+
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;">
|
|
3146
|
+
<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>
|
|
3147
|
+
</div>
|
|
3148
|
+
</div>
|
|
3149
|
+
</div>
|
|
3150
|
+
<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;">
|
|
3151
|
+
<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>
|
|
3152
|
+
<div class="detail-name" id="dd-name"></div>
|
|
3153
|
+
<div id="dd-status" style="margin:6px 0;font-size:12px;font-weight:600;"></div>
|
|
3154
|
+
<div class="detail-meta" id="dd-meta"></div>
|
|
3155
|
+
<div class="detail-section"><h4 data-i18n="diff.affectedByChange">Affected by this change</h4><ul class="detail-list" id="dd-affected"></ul></div>
|
|
3156
|
+
<div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="dd-deps"></ul></div>
|
|
3157
|
+
</div>
|
|
3158
|
+
<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>
|
|
3159
|
+
</div>
|
|
4148
3160
|
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
3161
|
+
<!-- Tooltip (shared, interactive) -->
|
|
3162
|
+
<div id="tooltip">
|
|
3163
|
+
<div class="tt-name" id="tt-name"></div>
|
|
3164
|
+
<div>
|
|
3165
|
+
<span class="tt-badge tt-out" id="tt-dep-count"></span> <span data-i18n="tooltip.imports">imports</span>
|
|
3166
|
+
<span class="tt-badge tt-in" id="tt-dpt-count" style="margin-left:6px"></span> <span data-i18n="tooltip.importedBy">imported by</span>
|
|
3167
|
+
</div>
|
|
3168
|
+
<div class="tt-section" id="tt-details"></div>
|
|
3169
|
+
</div>`;
|
|
3170
|
+
}
|
|
4156
3171
|
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
4162
|
-
points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
|
|
4163
|
-
}
|
|
4164
|
-
});
|
|
3172
|
+
// src/utils/html-escape.ts
|
|
3173
|
+
function escapeHtml(s) {
|
|
3174
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3175
|
+
}
|
|
4165
3176
|
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
}
|
|
4184
|
-
}
|
|
4185
|
-
// Layer name label
|
|
4186
|
-
var lx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
|
|
4187
|
-
var ly = Math.min.apply(null, layerNodes.map(function(n) { return n.y||0; })) - 25;
|
|
4188
|
-
dHullGroup.append('text')
|
|
4189
|
-
.attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
|
|
4190
|
-
.attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
|
|
4191
|
-
.attr('font-size', 12).attr('font-weight', 600).text(layer.name);
|
|
4192
|
-
});
|
|
3177
|
+
// src/web/template.ts
|
|
3178
|
+
var _viewerJs = null;
|
|
3179
|
+
function getViewerJs() {
|
|
3180
|
+
if (!_viewerJs) {
|
|
3181
|
+
const thisDir = dirname2(fileURLToPath(import.meta.url));
|
|
3182
|
+
const candidates = [
|
|
3183
|
+
join8(thisDir, "..", "web", "viewer.js"),
|
|
3184
|
+
// dist/mcp/ or dist/cli/ → dist/web/
|
|
3185
|
+
join8(thisDir, "..", "..", "dist", "web", "viewer.js")
|
|
3186
|
+
// src/web/ → dist/web/
|
|
3187
|
+
];
|
|
3188
|
+
for (const p of candidates) {
|
|
3189
|
+
try {
|
|
3190
|
+
_viewerJs = readFileSync3(p, "utf-8");
|
|
3191
|
+
break;
|
|
3192
|
+
} catch {
|
|
3193
|
+
}
|
|
4193
3194
|
}
|
|
4194
|
-
|
|
4195
|
-
var dTickCount = 0;
|
|
4196
|
-
dSim.on('tick', function() {
|
|
4197
|
-
dLink.each(function(d) {
|
|
4198
|
-
var dx=d.target.x-d.source.x, dy=d.target.y-d.source.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
4199
|
-
var rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
|
|
4200
|
-
d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
|
|
4201
|
-
.attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
|
|
4202
|
-
});
|
|
4203
|
-
dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
|
|
4204
|
-
if (++dTickCount % 3 === 0) updateDiffHulls();
|
|
4205
|
-
});
|
|
4206
|
-
|
|
4207
|
-
dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
|
|
4208
|
-
|
|
4209
|
-
setTimeout(function() {
|
|
4210
|
-
var b=dG.node().getBBox(); if(!b.width) return;
|
|
4211
|
-
var s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
|
|
4212
|
-
dSvg.call(dZoom.transform,d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s,H/2-(b.y+b.height/2)*s).scale(s));
|
|
4213
|
-
},1500);
|
|
3195
|
+
if (!_viewerJs) throw new Error("viewer.js not found. Run 'npm run build:client' first.");
|
|
4214
3196
|
}
|
|
4215
|
-
|
|
4216
|
-
// Hook into tab switching
|
|
4217
|
-
const origTabHandler = document.querySelectorAll('.tab');
|
|
4218
|
-
origTabHandler.forEach(tab=>{
|
|
4219
|
-
tab.addEventListener('click',()=>{
|
|
4220
|
-
if(tab.dataset.view==='diff-view'&&!diffBuilt){buildDiffView();diffBuilt=true;}
|
|
4221
|
-
});
|
|
4222
|
-
});
|
|
3197
|
+
return _viewerJs;
|
|
4223
3198
|
}
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
3199
|
+
function buildGraphPage(graph, options = {}) {
|
|
3200
|
+
const locale = options.locale ?? "en";
|
|
3201
|
+
const diff = options.diff ?? null;
|
|
3202
|
+
const layers = options.layerMetadata ?? null;
|
|
3203
|
+
const crossEdges = options.crossLayerEdges ?? null;
|
|
3204
|
+
const files = Object.values(graph.files);
|
|
3205
|
+
const nodes = files.map((f) => ({
|
|
3206
|
+
id: f.path,
|
|
3207
|
+
deps: f.dependencies.length,
|
|
3208
|
+
dependents: f.dependents.length,
|
|
3209
|
+
dependencies: f.dependencies,
|
|
3210
|
+
dependentsList: f.dependents,
|
|
3211
|
+
isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
|
|
3212
|
+
dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".",
|
|
3213
|
+
layer: layers && f.path.includes("/") ? f.path.substring(0, f.path.indexOf("/")) : null
|
|
3214
|
+
}));
|
|
3215
|
+
const links = graph.edges.map((e) => ({
|
|
3216
|
+
source: e.source,
|
|
3217
|
+
target: e.target,
|
|
3218
|
+
type: e.type
|
|
3219
|
+
}));
|
|
3220
|
+
const circularFiles = /* @__PURE__ */ new Set();
|
|
3221
|
+
for (const c of graph.circularDependencies) {
|
|
3222
|
+
for (const f of c.cycle) circularFiles.add(f);
|
|
3223
|
+
}
|
|
3224
|
+
const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
|
|
3225
|
+
const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
|
|
3226
|
+
const diffData = diff ? JSON.stringify(diff) : "null";
|
|
3227
|
+
const layersData = layers ? JSON.stringify(layers) : "null";
|
|
3228
|
+
const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
|
|
3229
|
+
const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
|
|
3230
|
+
const viewerJs = getViewerJs();
|
|
3231
|
+
return (
|
|
3232
|
+
/* html */
|
|
3233
|
+
`<!DOCTYPE html>
|
|
3234
|
+
<html lang="${locale}">
|
|
3235
|
+
<head>
|
|
3236
|
+
<meta charset="utf-8">
|
|
3237
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3238
|
+
<title>${escapeHtml(projectName)} \u2014 Architecture Viewer</title>
|
|
3239
|
+
${buildStyles()}
|
|
3240
|
+
</head>
|
|
3241
|
+
<body>
|
|
3242
|
+
${buildViewerHtml()}
|
|
3243
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
3244
|
+
<script>
|
|
3245
|
+
window.__ARCH = {
|
|
3246
|
+
data: ${graphData},
|
|
3247
|
+
layers: ${layersData},
|
|
3248
|
+
crossEdges: ${crossEdgesData},
|
|
3249
|
+
diff: ${diffData},
|
|
3250
|
+
locale: '${locale}'
|
|
3251
|
+
};
|
|
4229
3252
|
</script>
|
|
3253
|
+
<script>${viewerJs}</script>
|
|
4230
3254
|
</body>
|
|
4231
3255
|
</html>`
|
|
4232
3256
|
);
|
|
@@ -4260,17 +3284,17 @@ function startViewer(graph, options = {}) {
|
|
|
4260
3284
|
}
|
|
4261
3285
|
|
|
4262
3286
|
// src/utils/version.ts
|
|
4263
|
-
import { readFileSync as
|
|
4264
|
-
import { join as
|
|
4265
|
-
import { fileURLToPath } from "url";
|
|
3287
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
3288
|
+
import { join as join9, dirname as dirname3 } from "path";
|
|
3289
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4266
3290
|
function loadVersion() {
|
|
4267
|
-
let dir =
|
|
3291
|
+
let dir = dirname3(fileURLToPath2(import.meta.url));
|
|
4268
3292
|
for (let i = 0; i < 5; i++) {
|
|
4269
3293
|
try {
|
|
4270
|
-
const pkg = JSON.parse(
|
|
3294
|
+
const pkg = JSON.parse(readFileSync4(join9(dir, "package.json"), "utf-8"));
|
|
4271
3295
|
return pkg.version;
|
|
4272
3296
|
} catch {
|
|
4273
|
-
dir =
|
|
3297
|
+
dir = dirname3(dir);
|
|
4274
3298
|
}
|
|
4275
3299
|
}
|
|
4276
3300
|
return "0.0.0";
|
|
@@ -4279,36 +3303,14 @@ var VERSION = loadVersion();
|
|
|
4279
3303
|
|
|
4280
3304
|
// src/cli/index.ts
|
|
4281
3305
|
var VALID_LANGUAGES = LANGUAGE_IDS;
|
|
4282
|
-
async function
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
if (layerConfig) {
|
|
4287
|
-
const multi = await analyzeMultiLayer(opts.root, layerConfig.layers);
|
|
4288
|
-
const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
|
|
4289
|
-
const manualConnections = layerConfig.connections ?? [];
|
|
4290
|
-
const manualKeys = new Set(manualConnections.map(
|
|
4291
|
-
(c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
|
|
4292
|
-
));
|
|
4293
|
-
const merged = [
|
|
4294
|
-
...manualConnections,
|
|
4295
|
-
...autoConnections.filter(
|
|
4296
|
-
(c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
|
|
4297
|
-
)
|
|
4298
|
-
];
|
|
4299
|
-
return {
|
|
4300
|
-
graph: multi.merged,
|
|
4301
|
-
multiLayer: multi,
|
|
4302
|
-
layerMetadata: multi.layerMetadata,
|
|
4303
|
-
crossLayerEdges: merged
|
|
4304
|
-
};
|
|
4305
|
-
}
|
|
4306
|
-
}
|
|
4307
|
-
const graph = await analyzeProject(opts.target, {
|
|
3306
|
+
async function resolveGraphCli(opts) {
|
|
3307
|
+
return resolveGraph({
|
|
3308
|
+
targetDir: opts.target,
|
|
3309
|
+
projectRoot: opts.root,
|
|
4308
3310
|
exclude: opts.exclude,
|
|
4309
|
-
language: opts.language
|
|
3311
|
+
language: opts.language,
|
|
3312
|
+
noCache: opts.noCache
|
|
4310
3313
|
});
|
|
4311
|
-
return { graph };
|
|
4312
3314
|
}
|
|
4313
3315
|
var program = new Command();
|
|
4314
3316
|
program.name("archtracker").description(
|
|
@@ -4326,13 +3328,17 @@ program.command("init").description("Generate initial snapshot and save to .arch
|
|
|
4326
3328
|
try {
|
|
4327
3329
|
const language = validateLanguage(opts.language);
|
|
4328
3330
|
console.log(t("cli.analyzing"));
|
|
4329
|
-
const { graph, multiLayer } = await
|
|
3331
|
+
const { graph, multiLayer } = await resolveGraphCli({
|
|
4330
3332
|
target: opts.target,
|
|
4331
3333
|
root: opts.root,
|
|
4332
3334
|
exclude: opts.exclude,
|
|
4333
3335
|
language
|
|
4334
3336
|
});
|
|
4335
|
-
const snapshot = await saveSnapshot(opts.root, graph, multiLayer
|
|
3337
|
+
const snapshot = await saveSnapshot(opts.root, graph, multiLayer, {
|
|
3338
|
+
targetDir: opts.target,
|
|
3339
|
+
language,
|
|
3340
|
+
exclude: opts.exclude
|
|
3341
|
+
});
|
|
4336
3342
|
console.log(t("cli.snapshotSaved"));
|
|
4337
3343
|
console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
|
|
4338
3344
|
console.log(t("cli.fileCount", { count: graph.totalFiles }));
|
|
@@ -4359,20 +3365,25 @@ program.command("analyze").description(
|
|
|
4359
3365
|
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option(
|
|
4360
3366
|
"-e, --exclude <patterns...>",
|
|
4361
3367
|
"Exclude patterns (regex)"
|
|
4362
|
-
).option("-n, --top <number>", "Number of top components to show", "10").option("--save", "Also save a snapshot after analysis").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
3368
|
+
).option("-n, --top <number>", "Number of top components to show", "10").option("--save", "Also save a snapshot after analysis").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
4363
3369
|
try {
|
|
4364
3370
|
const language = validateLanguage(opts.language);
|
|
4365
3371
|
console.log(t("cli.analyzing"));
|
|
4366
|
-
const { graph, multiLayer } = await
|
|
3372
|
+
const { graph, multiLayer } = await resolveGraphCli({
|
|
4367
3373
|
target: opts.target,
|
|
4368
3374
|
root: opts.root,
|
|
4369
3375
|
exclude: opts.exclude,
|
|
4370
|
-
language
|
|
3376
|
+
language,
|
|
3377
|
+
noCache: opts.noCache
|
|
4371
3378
|
});
|
|
4372
3379
|
const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
|
|
4373
3380
|
console.log(report);
|
|
4374
3381
|
if (opts.save) {
|
|
4375
|
-
await saveSnapshot(opts.root, graph, multiLayer
|
|
3382
|
+
await saveSnapshot(opts.root, graph, multiLayer, {
|
|
3383
|
+
targetDir: opts.target,
|
|
3384
|
+
language,
|
|
3385
|
+
exclude: opts.exclude
|
|
3386
|
+
});
|
|
4376
3387
|
console.log(t("analyze.snapshotSaved"));
|
|
4377
3388
|
}
|
|
4378
3389
|
} catch (error) {
|
|
@@ -4381,19 +3392,29 @@ program.command("analyze").description(
|
|
|
4381
3392
|
});
|
|
4382
3393
|
program.command("check").description(
|
|
4383
3394
|
"Compare snapshot with current code and report change impacts"
|
|
4384
|
-
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--ci", "CI mode: exit code 1 if affected files exist").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
3395
|
+
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--ci", "CI mode: exit code 1 if affected files exist").option("--from <timestamp>", "Compare from a historical snapshot (use 'archtracker history' to see timestamps)").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
4385
3396
|
try {
|
|
4386
3397
|
const language = validateLanguage(opts.language);
|
|
4387
|
-
|
|
4388
|
-
if (
|
|
4389
|
-
|
|
4390
|
-
|
|
3398
|
+
let existingSnapshot;
|
|
3399
|
+
if (opts.from) {
|
|
3400
|
+
existingSnapshot = await loadSnapshotByTimestamp(opts.root, opts.from);
|
|
3401
|
+
if (!existingSnapshot) {
|
|
3402
|
+
console.log(t("history.snapshotNotFound", { ts: opts.from }));
|
|
3403
|
+
process.exit(1);
|
|
3404
|
+
}
|
|
3405
|
+
} else {
|
|
3406
|
+
existingSnapshot = await loadSnapshot(opts.root);
|
|
3407
|
+
if (!existingSnapshot) {
|
|
3408
|
+
console.log(t("cli.noSnapshot"));
|
|
3409
|
+
process.exit(1);
|
|
3410
|
+
}
|
|
4391
3411
|
}
|
|
4392
3412
|
console.log(t("cli.analyzing"));
|
|
4393
|
-
const { graph: currentGraph } = await
|
|
3413
|
+
const { graph: currentGraph } = await resolveGraphCli({
|
|
4394
3414
|
target: opts.target,
|
|
4395
3415
|
root: opts.root,
|
|
4396
|
-
language
|
|
3416
|
+
language,
|
|
3417
|
+
noCache: opts.noCache
|
|
4397
3418
|
});
|
|
4398
3419
|
const diff = computeDiff(existingSnapshot.graph, currentGraph);
|
|
4399
3420
|
const report = formatDiffReport(diff);
|
|
@@ -4408,18 +3429,22 @@ program.command("check").description(
|
|
|
4408
3429
|
});
|
|
4409
3430
|
program.command("context").description(
|
|
4410
3431
|
"Display current architecture context (for AI session initialization)"
|
|
4411
|
-
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--json", "Output in JSON format").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
3432
|
+
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--json", "Output in JSON format").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
4412
3433
|
try {
|
|
4413
3434
|
const language = validateLanguage(opts.language);
|
|
4414
3435
|
let snapshot = await loadSnapshot(opts.root);
|
|
4415
3436
|
if (!snapshot) {
|
|
4416
3437
|
console.log(t("cli.autoGenerating"));
|
|
4417
|
-
const result = await
|
|
3438
|
+
const result = await resolveGraphCli({
|
|
4418
3439
|
target: opts.target,
|
|
4419
3440
|
root: opts.root,
|
|
3441
|
+
language,
|
|
3442
|
+
noCache: opts.noCache
|
|
3443
|
+
});
|
|
3444
|
+
snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer, {
|
|
3445
|
+
targetDir: opts.target,
|
|
4420
3446
|
language
|
|
4421
3447
|
});
|
|
4422
|
-
snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer);
|
|
4423
3448
|
}
|
|
4424
3449
|
const graph = snapshot.graph;
|
|
4425
3450
|
if (opts.json) {
|
|
@@ -4456,17 +3481,18 @@ program.command("serve").description(
|
|
|
4456
3481
|
).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("-p, --port <number>", "Port number", "3000").option(
|
|
4457
3482
|
"-e, --exclude <patterns...>",
|
|
4458
3483
|
"Exclude patterns (regex)"
|
|
4459
|
-
).option("-w, --watch", "Watch for file changes and auto-reload").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
3484
|
+
).option("-w, --watch", "Watch for file changes and auto-reload").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
|
|
4460
3485
|
try {
|
|
4461
3486
|
const language = validateLanguage(opts.language);
|
|
4462
3487
|
console.log(t("web.starting"));
|
|
4463
3488
|
console.log(t("cli.analyzing"));
|
|
4464
3489
|
let diff = null;
|
|
4465
|
-
const result = await
|
|
3490
|
+
const result = await resolveGraphCli({
|
|
4466
3491
|
target: opts.target,
|
|
4467
3492
|
root: opts.root,
|
|
4468
3493
|
exclude: opts.exclude,
|
|
4469
|
-
language
|
|
3494
|
+
language,
|
|
3495
|
+
noCache: opts.noCache
|
|
4470
3496
|
});
|
|
4471
3497
|
const snapshot = await loadSnapshot(opts.root);
|
|
4472
3498
|
if (snapshot) {
|
|
@@ -4489,11 +3515,13 @@ program.command("serve").description(
|
|
|
4489
3515
|
debounce = setTimeout(async () => {
|
|
4490
3516
|
try {
|
|
4491
3517
|
console.log(t("web.reloading"));
|
|
4492
|
-
const newResult = await
|
|
3518
|
+
const newResult = await resolveGraphCli({
|
|
4493
3519
|
target: opts.target,
|
|
4494
3520
|
root: opts.root,
|
|
4495
3521
|
exclude: opts.exclude,
|
|
4496
|
-
language
|
|
3522
|
+
language,
|
|
3523
|
+
noCache: true
|
|
3524
|
+
// Watch mode always needs fresh analysis
|
|
4497
3525
|
});
|
|
4498
3526
|
viewer.close();
|
|
4499
3527
|
startViewer(newResult.graph, {
|
|
@@ -4532,15 +3560,41 @@ jobs:
|
|
|
4532
3560
|
- run: npx archtracker check --target ${opts.target} --ci
|
|
4533
3561
|
`;
|
|
4534
3562
|
try {
|
|
4535
|
-
const dir =
|
|
4536
|
-
await
|
|
4537
|
-
const path =
|
|
4538
|
-
await
|
|
3563
|
+
const dir = join10(".github", "workflows");
|
|
3564
|
+
await mkdir4(dir, { recursive: true });
|
|
3565
|
+
const path = join10(dir, "arch-check.yml");
|
|
3566
|
+
await writeFile4(path, workflow, "utf-8");
|
|
4539
3567
|
console.log(t("ci.generated", { path }));
|
|
4540
3568
|
} catch (error) {
|
|
4541
3569
|
handleError(error);
|
|
4542
3570
|
}
|
|
4543
3571
|
});
|
|
3572
|
+
program.command("history").description("List all saved architecture snapshots").option("-r, --root <dir>", "Project root", ".").option("-n, --limit <number>", "Max entries to show", "20").action(async (opts) => {
|
|
3573
|
+
try {
|
|
3574
|
+
const snapshots = await listSnapshots(opts.root);
|
|
3575
|
+
if (snapshots.length === 0) {
|
|
3576
|
+
console.log(t("history.empty"));
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
const limit = parseInt(opts.limit, 10);
|
|
3580
|
+
const shown = snapshots.slice(0, limit);
|
|
3581
|
+
console.log(t("history.title"));
|
|
3582
|
+
console.log(t("history.count", { count: snapshots.length }));
|
|
3583
|
+
console.log("");
|
|
3584
|
+
for (const s of shown) {
|
|
3585
|
+
const layers = s.hasMultiLayer ? " [multi-layer]" : "";
|
|
3586
|
+
console.log(t("history.entry", {
|
|
3587
|
+
ts: s.timestamp,
|
|
3588
|
+
files: s.totalFiles,
|
|
3589
|
+
edges: s.totalEdges,
|
|
3590
|
+
circular: s.circularDeps,
|
|
3591
|
+
layers
|
|
3592
|
+
}));
|
|
3593
|
+
}
|
|
3594
|
+
} catch (error) {
|
|
3595
|
+
handleError(error);
|
|
3596
|
+
}
|
|
3597
|
+
});
|
|
4544
3598
|
var layersCmd = program.command("layers").description("Manage multi-layer architecture configuration");
|
|
4545
3599
|
layersCmd.command("init").description("Create a template .archtracker/layers.json").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
|
|
4546
3600
|
try {
|