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/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 writeFile3, mkdir as mkdir3 } from "fs/promises";
7
- import { join as join8 } from "path";
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/snapshot.ts
2179
- import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
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 ARCHTRACKER_DIR = ".archtracker";
2553
+ var ARCHTRACKER_DIR3 = ".archtracker";
2188
2554
  var SNAPSHOT_FILE = "snapshot.json";
2189
- var FileNodeSchema = z.object({
2190
- path: z.string(),
2191
- exists: z.boolean(),
2192
- dependencies: z.array(z.string()),
2193
- dependents: z.array(z.string())
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 = z.object({
2196
- rootDir: z.string(),
2197
- files: z.record(z.string(), FileNodeSchema),
2198
- edges: z.array(z.object({
2199
- source: z.string(),
2200
- target: z.string(),
2201
- type: z.enum(["static", "dynamic", "type-only"])
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: z.array(z.object({ cycle: z.array(z.string()) })),
2204
- totalFiles: z.number(),
2205
- totalEdges: z.number()
2570
+ circularDependencies: z2.array(z2.object({ cycle: z2.array(z2.string()) })),
2571
+ totalFiles: z2.number(),
2572
+ totalEdges: z2.number()
2206
2573
  });
2207
- var SnapshotSchema = z.object({
2208
- version: z.enum([SCHEMA_VERSION, "1.0"]),
2209
- timestamp: z.string(),
2210
- rootDir: z.string(),
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 = join5(projectRoot, ARCHTRACKER_DIR);
2215
- const filePath = join5(dirPath, SNAPSHOT_FILE);
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 mkdir(dirPath, { recursive: true });
2224
- await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
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 = join5(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
2604
+ const filePath = join7(projectRoot, ARCHTRACKER_DIR3, SNAPSHOT_FILE);
2229
2605
  let raw;
2230
2606
  try {
2231
- raw = await readFile2(filePath, "utf-8");
2607
+ raw = await readFile4(filePath, "utf-8");
2232
2608
  } catch (error) {
2233
- if (isNodeError(error) && error.code === "ENOENT") {
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 isNodeError(error) {
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 diff.added) {
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 diff.removed) {
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 diff.modified) {
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 diff.affectedDependents) {
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
- function buildGraphPage(graph, options = {}) {
2441
- const locale = options.locale ?? "en";
2442
- const diff = options.diff ?? null;
2443
- const layers = options.layerMetadata ?? null;
2444
- const crossEdges = options.crossLayerEdges ?? null;
2445
- const files = Object.values(graph.files);
2446
- const nodes = files.map((f) => ({
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
- const dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
4083
- .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
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
- const dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
4086
- dNode.append('circle')
4087
- .attr('r', d=>nodeRadius(d)*nodeScale)
4088
- .attr('fill', diffColor)
4089
- .attr('stroke', diffColor).attr('stroke-width', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?3:1)
4090
- .attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2);
4091
- dNode.append('text')
4092
- .text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
4093
- .attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
4094
- .attr('fill', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?'var(--text)':'var(--text-muted)')
4095
- .attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2)
4096
- .attr('pointer-events','none');
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
- const dSim = d3.forceSimulation(simNodes)
4099
- .force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
4100
- .force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
4101
- .force('center', d3.forceCenter(0,0))
4102
- .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4));
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
- // Layer-aware physics for diff view (same pattern as graph view)
4105
- var dHullGroup = null;
4106
- if (LAYERS && LAYERS.length > 0) {
4107
- var dLayerCenters = {};
4108
- var dLayerCount = LAYERS.length;
4109
- var dBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(dLayerCount));
4110
- LAYERS.forEach(function(l, idx) {
4111
- var angle = (2 * Math.PI * idx) / dLayerCount - Math.PI / 2;
4112
- dLayerCenters[l.name] = { x: Math.cos(angle) * dBaseRadius, y: Math.sin(angle) * dBaseRadius };
4113
- });
4114
- dSim.force('center', null);
4115
- dSim.force('layerX', d3.forceX(function(d) { return dLayerCenters[d.layer]?.x || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
4116
- dSim.force('layerY', d3.forceY(function(d) { return dLayerCenters[d.layer]?.y || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
4117
- dSim.force('link').strength(function(l) {
4118
- var sL = l.source.layer ?? l.source, tL = l.target.layer ?? l.target;
4119
- return sL === tL ? 0.4 : 0.1;
4120
- });
4121
- // Cluster force for diff view
4122
- dSim.force('cluster', (function() {
4123
- var ns;
4124
- function f(alpha) {
4125
- var centroids = {}, counts = {};
4126
- ns.forEach(function(n) {
4127
- if (!n.layer) return;
4128
- if (!centroids[n.layer]) { centroids[n.layer] = {x:0,y:0}; counts[n.layer] = 0; }
4129
- centroids[n.layer].x += n.x; centroids[n.layer].y += n.y; counts[n.layer]++;
4130
- });
4131
- Object.keys(centroids).forEach(function(k) { centroids[k].x /= counts[k]; centroids[k].y /= counts[k]; });
4132
- ns.forEach(function(n) {
4133
- if (!n.layer || !centroids[n.layer]) return;
4134
- n.vx += (centroids[n.layer].x - n.x) * alpha * 0.2;
4135
- n.vy += (centroids[n.layer].y - n.y) * alpha * 0.2;
4136
- });
4137
- }
4138
- f.initialize = function(n) { ns = n; };
4139
- return f;
4140
- })());
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
- dHullGroup = dG.insert('g', ':first-child');
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
- function isDiffNode(id) {
4146
- return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
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
- function updateDiffHulls() {
4150
- if (!dHullGroup) return;
4151
- dHullGroup.selectAll('*').remove();
4152
- LAYERS.forEach(function(layer) {
4153
- var layerNodes = simNodes.filter(function(n) { return n.layer === layer.name; });
4154
- if (layerNodes.length === 0) return;
4155
- var hasDiff = layerNodes.some(function(n) { return isDiffNode(n.id); });
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
- var points = [];
4158
- layerNodes.forEach(function(n) {
4159
- if (n.x == null || n.y == null) return;
4160
- var r = nodeRadius(n) * nodeScale + 30;
4161
- for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
4162
- points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
4163
- }
4164
- });
3172
+ // src/utils/html-escape.ts
3173
+ function escapeHtml(s) {
3174
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3175
+ }
4165
3176
 
4166
- var fillOp = hasDiff ? 0.15 : 0.06;
4167
- var strokeOp = hasDiff ? 0.6 : 0.2;
4168
- var sw = hasDiff ? 2.5 : 1;
4169
- if (points.length < 6) {
4170
- var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4171
- var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
4172
- dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
4173
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
4174
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
4175
- } else {
4176
- var hull = d3.polygonHull(points);
4177
- if (hull) {
4178
- dHullGroup.append('path')
4179
- .attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
4180
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
4181
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
4182
- .attr('stroke-dasharray', hasDiff ? null : '6,3');
4183
- }
4184
- }
4185
- // Layer name label
4186
- var lx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4187
- var ly = Math.min.apply(null, layerNodes.map(function(n) { return n.y||0; })) - 25;
4188
- dHullGroup.append('text')
4189
- .attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
4190
- .attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
4191
- .attr('font-size', 12).attr('font-weight', 600).text(layer.name);
4192
- });
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
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
4226
- // INIT
4227
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
4228
- applyI18n();
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 readFileSync3 } from "fs";
4264
- import { join as join7, dirname as dirname2 } from "path";
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 = dirname2(fileURLToPath(import.meta.url));
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(readFileSync3(join7(dir, "package.json"), "utf-8"));
3294
+ const pkg = JSON.parse(readFileSync4(join9(dir, "package.json"), "utf-8"));
4271
3295
  return pkg.version;
4272
3296
  } catch {
4273
- dir = dirname2(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 resolveGraph(opts) {
4283
- const targetExplicit = process.argv.some((a) => a === "-t" || a === "--target");
4284
- if (!targetExplicit) {
4285
- const layerConfig = await loadLayerConfig(opts.root);
4286
- if (layerConfig) {
4287
- const multi = await analyzeMultiLayer(opts.root, layerConfig.layers);
4288
- const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
4289
- const manualConnections = layerConfig.connections ?? [];
4290
- const manualKeys = new Set(manualConnections.map(
4291
- (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
4292
- ));
4293
- const merged = [
4294
- ...manualConnections,
4295
- ...autoConnections.filter(
4296
- (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
4297
- )
4298
- ];
4299
- return {
4300
- graph: multi.merged,
4301
- multiLayer: multi,
4302
- layerMetadata: multi.layerMetadata,
4303
- crossLayerEdges: merged
4304
- };
4305
- }
4306
- }
4307
- const graph = await analyzeProject(opts.target, {
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 resolveGraph({
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 resolveGraph({
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
- const existingSnapshot = await loadSnapshot(opts.root);
4388
- if (!existingSnapshot) {
4389
- console.log(t("cli.noSnapshot"));
4390
- process.exit(1);
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 resolveGraph({
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 resolveGraph({
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 resolveGraph({
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 resolveGraph({
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 = join8(".github", "workflows");
4536
- await mkdir3(dir, { recursive: true });
4537
- const path = join8(dir, "arch-check.yml");
4538
- await writeFile3(path, workflow, "utf-8");
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 {