archtracker-mcp 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2175,44 +2175,145 @@ function mergeLayerGraphs(projectRoot, layers) {
2175
2175
  };
2176
2176
  }
2177
2177
 
2178
- // src/storage/snapshot.ts
2179
- import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
2178
+ // src/storage/layers.ts
2179
+ import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
2180
2180
  import { join as join5 } from "path";
2181
2181
  import { z } from "zod";
2182
+ var ARCHTRACKER_DIR = ".archtracker";
2183
+ var LAYERS_FILE = "layers.json";
2184
+ var LayerDefinitionSchema = z.object({
2185
+ name: z.string().min(1).regex(
2186
+ /^[a-zA-Z0-9_-]+$/,
2187
+ "Layer name must be alphanumeric (hyphens/underscores allowed)"
2188
+ ),
2189
+ targetDir: z.string().min(1),
2190
+ language: z.enum(LANGUAGE_IDS).optional(),
2191
+ exclude: z.array(z.string()).optional(),
2192
+ color: z.string().optional(),
2193
+ description: z.string().optional()
2194
+ });
2195
+ var CrossLayerConnectionSchema = z.object({
2196
+ fromLayer: z.string(),
2197
+ fromFile: z.string(),
2198
+ toLayer: z.string(),
2199
+ toFile: z.string(),
2200
+ type: z.enum(["api-call", "event", "data-flow", "manual"]),
2201
+ label: z.string().optional()
2202
+ });
2203
+ var LayerConfigSchema = z.object({
2204
+ version: z.literal("1.0"),
2205
+ layers: z.array(LayerDefinitionSchema).min(1).refine(
2206
+ (layers) => {
2207
+ const names = layers.map((l) => l.name);
2208
+ return new Set(names).size === names.length;
2209
+ },
2210
+ { message: "Layer names must be unique" }
2211
+ ),
2212
+ connections: z.array(CrossLayerConnectionSchema).optional()
2213
+ });
2214
+ async function loadLayerConfig(projectRoot) {
2215
+ const filePath = join5(projectRoot, ARCHTRACKER_DIR, LAYERS_FILE);
2216
+ let raw;
2217
+ try {
2218
+ raw = await readFile2(filePath, "utf-8");
2219
+ } catch (error) {
2220
+ if (isNodeError(error) && error.code === "ENOENT") {
2221
+ return null;
2222
+ }
2223
+ throw new Error(`Failed to read ${filePath}`);
2224
+ }
2225
+ let parsed;
2226
+ try {
2227
+ parsed = JSON.parse(raw);
2228
+ } catch {
2229
+ throw new Error(`Invalid JSON in ${filePath}`);
2230
+ }
2231
+ const result = LayerConfigSchema.safeParse(parsed);
2232
+ if (!result.success) {
2233
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
2234
+ throw new Error(`layers.json validation failed:
2235
+ ${issues}`);
2236
+ }
2237
+ return result.data;
2238
+ }
2239
+ async function saveLayerConfig(projectRoot, config) {
2240
+ const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
2241
+ const filePath = join5(dirPath, LAYERS_FILE);
2242
+ await mkdir(dirPath, { recursive: true });
2243
+ await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2244
+ }
2245
+ function isNodeError(error) {
2246
+ return error instanceof Error && "code" in error;
2247
+ }
2248
+
2249
+ // src/analyzer/resolve.ts
2250
+ async function resolveGraph(opts) {
2251
+ const layerConfig = await loadLayerConfig(opts.projectRoot);
2252
+ if (layerConfig) {
2253
+ const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers);
2254
+ const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
2255
+ const manualConnections = layerConfig.connections ?? [];
2256
+ const manualKeys = new Set(manualConnections.map(
2257
+ (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
2258
+ ));
2259
+ const merged = [
2260
+ ...manualConnections,
2261
+ ...autoConnections.filter(
2262
+ (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2263
+ )
2264
+ ];
2265
+ return {
2266
+ graph: multi.merged,
2267
+ multiLayer: multi,
2268
+ layerMetadata: multi.layerMetadata,
2269
+ crossLayerEdges: merged
2270
+ };
2271
+ }
2272
+ const graph = await analyzeProject(opts.targetDir, {
2273
+ exclude: opts.exclude,
2274
+ language: opts.language
2275
+ });
2276
+ return { graph };
2277
+ }
2278
+
2279
+ // src/storage/snapshot.ts
2280
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, access } from "fs/promises";
2281
+ import { join as join6 } from "path";
2282
+ import { z as z2 } from "zod";
2182
2283
 
2183
2284
  // src/types/schema.ts
2184
2285
  var SCHEMA_VERSION = "1.1";
2185
2286
 
2186
2287
  // src/storage/snapshot.ts
2187
- var ARCHTRACKER_DIR = ".archtracker";
2288
+ var ARCHTRACKER_DIR2 = ".archtracker";
2188
2289
  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())
2290
+ var FileNodeSchema = z2.object({
2291
+ path: z2.string(),
2292
+ exists: z2.boolean(),
2293
+ dependencies: z2.array(z2.string()),
2294
+ dependents: z2.array(z2.string())
2194
2295
  });
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"])
2296
+ var DependencyGraphSchema = z2.object({
2297
+ rootDir: z2.string(),
2298
+ files: z2.record(z2.string(), FileNodeSchema),
2299
+ edges: z2.array(z2.object({
2300
+ source: z2.string(),
2301
+ target: z2.string(),
2302
+ type: z2.enum(["static", "dynamic", "type-only"])
2202
2303
  })),
2203
- circularDependencies: z.array(z.object({ cycle: z.array(z.string()) })),
2204
- totalFiles: z.number(),
2205
- totalEdges: z.number()
2304
+ circularDependencies: z2.array(z2.object({ cycle: z2.array(z2.string()) })),
2305
+ totalFiles: z2.number(),
2306
+ totalEdges: z2.number()
2206
2307
  });
2207
- var SnapshotSchema = z.object({
2208
- version: z.enum([SCHEMA_VERSION, "1.0"]),
2209
- timestamp: z.string(),
2210
- rootDir: z.string(),
2308
+ var SnapshotSchema = z2.object({
2309
+ version: z2.enum([SCHEMA_VERSION, "1.0"]),
2310
+ timestamp: z2.string(),
2311
+ rootDir: z2.string(),
2211
2312
  graph: DependencyGraphSchema
2212
2313
  });
2213
2314
  async function saveSnapshot(projectRoot, graph, multiLayer) {
2214
- const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
2215
- const filePath = join5(dirPath, SNAPSHOT_FILE);
2315
+ const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
2316
+ const filePath = join6(dirPath, SNAPSHOT_FILE);
2216
2317
  const snapshot = {
2217
2318
  version: SCHEMA_VERSION,
2218
2319
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2220,17 +2321,17 @@ async function saveSnapshot(projectRoot, graph, multiLayer) {
2220
2321
  graph,
2221
2322
  ...multiLayer ? { multiLayer } : {}
2222
2323
  };
2223
- await mkdir(dirPath, { recursive: true });
2224
- await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
2324
+ await mkdir2(dirPath, { recursive: true });
2325
+ await writeFile2(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
2225
2326
  return snapshot;
2226
2327
  }
2227
2328
  async function loadSnapshot(projectRoot) {
2228
- const filePath = join5(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
2329
+ const filePath = join6(projectRoot, ARCHTRACKER_DIR2, SNAPSHOT_FILE);
2229
2330
  let raw;
2230
2331
  try {
2231
- raw = await readFile2(filePath, "utf-8");
2332
+ raw = await readFile3(filePath, "utf-8");
2232
2333
  } catch (error) {
2233
- if (isNodeError(error) && error.code === "ENOENT") {
2334
+ if (isNodeError2(error) && error.code === "ENOENT") {
2234
2335
  return null;
2235
2336
  }
2236
2337
  throw new StorageError(
@@ -2261,7 +2362,7 @@ var StorageError = class extends Error {
2261
2362
  this.name = "StorageError";
2262
2363
  }
2263
2364
  };
2264
- function isNodeError(error) {
2365
+ function isNodeError2(error) {
2265
2366
  return error instanceof Error && "code" in error;
2266
2367
  }
2267
2368
 
@@ -2362,121 +2463,12 @@ function arraysEqual(a, b) {
2362
2463
  return true;
2363
2464
  }
2364
2465
 
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
2466
  // src/web/server.ts
2437
2467
  import { createServer } from "http";
2438
2468
 
2439
- // 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>
2469
+ // src/web/styles.ts
2470
+ function buildStyles() {
2471
+ return `<style>
2480
2472
  :root {
2481
2473
  --bg: #0d1117; --bg-card: #161b22; --bg-hover: #1c2129;
2482
2474
  --border: #30363d; --border-active: #58a6ff;
@@ -2606,6 +2598,9 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2606
2598
  #impact-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
2607
2599
  #impact-badge { position: absolute; bottom: 52px; left: 12px; z-index: 10; display: none; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: var(--radius); }
2608
2600
 
2601
+ /* \u2500\u2500\u2500 Diff focus button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2602
+ #diff-focus-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
2603
+
2609
2604
  /* \u2500\u2500\u2500 Help bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2610
2605
  #help-bar { position: absolute; bottom: 12px; right: 12px; z-index: 10; font-size: 11px; color: var(--text-muted); background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; transition: background 0.3s; }
2611
2606
 
@@ -2626,10 +2621,12 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2626
2621
  .layer-pill.active { border-color: var(--accent); }
2627
2622
  .layer-pill .lp-dot { width: 6px; height: 6px; border-radius: 50%; }
2628
2623
  .layer-pill .lp-count { color: var(--text-muted); font-size: 9px; font-weight: 400; }
2629
- </style>
2630
- </head>
2631
- <body>
2624
+ </style>`;
2625
+ }
2632
2626
 
2627
+ // src/web/viewer-html.ts
2628
+ function buildViewerHtml() {
2629
+ return `
2633
2630
  <!-- Tab bar -->
2634
2631
  <div id="tab-bar">
2635
2632
  <span class="logo" id="project-title" contenteditable="true" spellcheck="false" title="Click to edit project name"></span>
@@ -2735,7 +2732,7 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2735
2732
  <div id="zoom-ctrl">
2736
2733
  <button onclick="zoomIn()" title="Zoom in">+</button>
2737
2734
  <button onclick="zoomOut()" title="Zoom out">\u2212</button>
2738
- <button onclick="zoomFit()" title="Fit">\u22A1</button>
2735
+ <button onclick="zoomFit()" title="Fit">\u229E</button>
2739
2736
  <button id="impact-btn" onclick="toggleImpactMode()" title="Impact simulation" style="font-size:12px;margin-top:4px" data-i18n="impact.btn">Impact</button>
2740
2737
  </div>
2741
2738
  <div id="impact-badge"></div>
@@ -2768,15 +2765,26 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2768
2765
  <!-- Diff View -->
2769
2766
  <div id="diff-view" class="view">
2770
2767
  <svg id="diff-svg"></svg>
2771
- <div id="diff-legend" style="position:absolute;top:12px;left:12px;z-index:10;">
2768
+ <div id="diff-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
2772
2769
  <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>
2770
+ <div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div> <span data-i18n="diff.addedLabel">Added</span> <b id="diff-added-count" style="margin-left:auto">0</b></div>
2771
+ <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="diff.removedLabel">Removed</span> <b id="diff-removed-count" style="margin-left:auto">0</b></div>
2772
+ <div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div> <span data-i18n="diff.modifiedLabel">Modified</span> <b id="diff-modified-count" style="margin-left:auto">0</b></div>
2773
+ <div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> <span data-i18n="diff.affectedLabel">Affected</span> <b id="diff-affected-count" style="margin-left:auto">0</b></div>
2774
+ <div style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;">
2775
+ <button id="diff-focus-btn" onclick="toggleDiffFocus()" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer;color:var(--text-dim);font-size:11px;width:100%;transition:all 0.15s;" data-i18n="diff.focusChanges">Focus changes</button>
2776
+ </div>
2777
2777
  </div>
2778
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>
2779
+ <div id="diff-detail" style="position:absolute;top:12px;right:12px;width:280px;z-index:10;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-size:13px;display:none;max-height:calc(100vh - 100px);overflow-y:auto;transition:background 0.3s;">
2780
+ <button class="close-btn" onclick="closeDiffDetail()" style="position:absolute;top:8px;right:10px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;">\u2715</button>
2781
+ <div class="detail-name" id="dd-name"></div>
2782
+ <div id="dd-status" style="margin:6px 0;font-size:12px;font-weight:600;"></div>
2783
+ <div class="detail-meta" id="dd-meta"></div>
2784
+ <div class="detail-section"><h4 data-i18n="diff.affectedByChange">Affected by this change</h4><ul class="detail-list" id="dd-affected"></ul></div>
2785
+ <div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="dd-deps"></ul></div>
2786
+ </div>
2787
+ <div id="help-bar" style="position:absolute" data-i18n="help.diff">Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected \xB7 Click: impact chain</div>
2780
2788
  </div>
2781
2789
 
2782
2790
  <!-- Tooltip (shared, interactive) -->
@@ -2787,1441 +2795,1692 @@ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px;
2787
2795
  <span class="tt-badge tt-in" id="tt-dpt-count" style="margin-left:6px"></span> <span data-i18n="tooltip.importedBy">imported by</span>
2788
2796
  </div>
2789
2797
  <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));
2798
+ </div>`;
2847
2799
  }
2848
- window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
2849
- function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
2850
2800
 
2801
+ // src/web/js-hierarchy.ts
2802
+ function buildHierarchyJs() {
2803
+ return `
2851
2804
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2852
- // SETTINGS (persisted to localStorage)
2805
+ // HIERARCHY VIEW
2853
2806
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
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
- }
2807
+ function buildHierarchy(){
2808
+ const hSvg=d3.select('#hier-svg');
2809
+ const hG=hSvg.append('g');
2810
+ const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
2811
+ hSvg.call(hZoom);
2862
2812
 
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();
2813
+ const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
2814
+ const importsMap={}; DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!importsMap[s])importsMap[s]=[];importsMap[s].push(t);});
2815
+
2816
+ const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
2817
+ const layers={};const visited=new Set();
2818
+ const queue=entryPoints.map(id=>({id,layer:0}));
2819
+ DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
2820
+
2821
+ while(queue.length>0){
2822
+ const{id,layer}=queue.shift();
2823
+ if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
2824
+ layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
2825
+ (importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
2902
2826
  }
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();
2827
+ DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
2828
+
2829
+ const maxLayer=Math.max(0,...Object.values(layers));
2830
+ const layerGroups={};
2831
+ for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
2832
+ Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
2833
+ Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
2834
+
2835
+ const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
2836
+ const positions={};let maxRowWidth=0;
2837
+ for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
2838
+ for(let layer=0;layer<=maxLayer;layer++){
2839
+ const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
2840
+ items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
2911
2841
  }
2912
- saveSettings();
2913
- };
2914
2842
 
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
- };
2843
+ const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
2844
+ hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
2944
2845
 
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);
2846
+ const linkG=hG.append('g');
2847
+ DATA.links.forEach(l=>{
2848
+ const sId=l.source.id??l.source,tId=l.target.id??l.target;
2849
+ const s=positions[sId],t=positions[tId]; if(!s||!t)return;
2850
+ const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
2851
+ linkG.append('path').attr('class','hier-link')
2852
+ .attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
2853
+ .attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
2854
+ .attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
2855
+ .attr('data-source',sId).attr('data-target',tId);
2856
+ });
2953
2857
 
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(); } });
2858
+ hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
2859
+ .attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
2860
+ .append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
2861
+ linkG.selectAll('path').attr('marker-end','url(#harrow)');
2959
2862
 
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
- }
2863
+ for(let layer=0;layer<=maxLayer;layer++){
2864
+ if(!layerGroups[layer].length)continue;
2865
+ hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
2866
+ .attr('data-depth-idx',layer)
2867
+ .attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
2868
+ }
2973
2869
 
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;
2870
+ const nodeG=hG.append('g');
2871
+ DATA.nodes.forEach(n=>{
2872
+ const pos=positions[n.id]; if(!pos)return;
2873
+ const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
2874
+ gn.append('rect').attr('width',boxW).attr('height',boxH)
2875
+ .attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
2876
+ .attr('stroke-width',circularSet.has(n.id)?2:1.5);
2877
+ gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
2878
+ .text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\\u2026':fileName(n.id));
2879
+ gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
2880
+ .attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
2881
+ .text(n.dependents>0?'\\u2191'+n.dependents:'');
2882
+ gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
2883
+ .attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
2977
2884
 
2978
- const dirColor = d3.scaleOrdinal()
2979
- .domain(DATA.dirs)
2980
- .range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
2885
+ gn.node().__data_id=n.id;
2886
+ gn.on('mouseover',e=>{
2887
+ showTooltip(e,n);
2888
+ if (!hierPinned) hierHighlight(n.id);
2889
+ })
2890
+ .on('mousemove',e=>positionTooltip(e))
2891
+ .on('mouseout',()=>{
2892
+ scheduleHideTooltip();
2893
+ if (!hierPinned) hierResetHighlight();
2894
+ })
2895
+ .on('click',(e)=>{
2896
+ e.stopPropagation();
2897
+ hierPinned=n.id;
2898
+ hierHighlight(n.id);
2899
+ showHierDetail(n);
2900
+ });
2901
+ });
2981
2902
 
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
- }
2903
+ // Hierarchy highlight helpers
2904
+ let hierPinned=null;
2905
+ function hierHighlight(nId){
2906
+ linkG.selectAll('path')
2907
+ .attr('stroke',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');if(s===nId)return'#58a6ff';if(t===nId)return'#3fb950';return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
2908
+ .attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
2909
+ .attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
2910
+ nodeG.selectAll('.hier-node').attr('opacity',function(){
2911
+ const id=this.__data_id; if(id===nId)return 1;
2912
+ const connected=DATA.links.some(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return(s===nId&&t===id)||(t===nId&&s===id);});
2913
+ return connected?1:0.3;
2914
+ });
2915
+ }
2916
+ function hierResetHighlight(){
2917
+ hierPinned=null;
2918
+ linkG.selectAll('path')
2919
+ .attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
2920
+ .attr('stroke-width',1).attr('opacity',1);
2921
+ nodeG.selectAll('.hier-node').attr('opacity',1);
2922
+ }
2923
+ function showHierDetail(n){
2924
+ const p=document.getElementById('hier-detail');
2925
+ document.getElementById('hd-name').textContent=n.id;
2926
+ document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+esc(n.dir)+'<br>'+i('detail.dependencies')+': '+n.deps+' \\u00b7 '+i('detail.dependents')+': '+n.dependents;
2927
+ document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
2928
+ document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
2929
+ p.classList.add('open');
2930
+ }
2931
+ window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
2990
2932
 
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(); }
2933
+ // Click on empty space to deselect
2934
+ hSvg.on('click',()=>{closeHierDetail();});
3000
2935
 
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
- });
2936
+ // Hierarchy filters \u2014 layer pills or dir pills
2937
+ const hFilterRow=document.getElementById('hier-filter-row');
2938
+ const hFilterBar=document.getElementById('hier-filter-bar');
2939
+ if (hFilterBar) hFilterBar.style.display='';
2940
+ const hActiveLayers=new Set(); // empty = show all (same as graph view)
3019
2941
 
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;
2942
+ function hierRelayoutInner() {
2943
+ function isVisible(nId) {
2944
+ var nd = nodeMap[nId];
2945
+ if (!nd) return false;
2946
+ if (LAYERS && nd.layer && hActiveLayers.size > 0 && !hActiveLayers.has(nd.layer)) return false;
2947
+ return true;
2948
+ }
3026
2949
 
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();
2950
+ // Build visible layer groups and compact Y positions
2951
+ var visibleDepths = [];
2952
+ var visLayerGroups = {};
2953
+ for (var depth = 0; depth <= maxLayer; depth++) {
2954
+ var visItems = layerGroups[depth].filter(function(id) { return isVisible(id); });
2955
+ if (visItems.length > 0) {
2956
+ visLayerGroups[depth] = visItems;
2957
+ visibleDepths.push(depth);
2958
+ }
3060
2959
  }
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
2960
 
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));
2961
+ // Recalculate positions for visible nodes (compacted)
2962
+ var newPositions = {};
2963
+ var newMaxRowWidth = 0;
2964
+ visibleDepths.forEach(function(depth) {
2965
+ newMaxRowWidth = Math.max(newMaxRowWidth, visLayerGroups[depth].length * (boxW + gapX) - gapX);
2966
+ });
2967
+ visibleDepths.forEach(function(depth, yIdx) {
2968
+ var items = visLayerGroups[depth];
2969
+ var rowWidth = items.length * (boxW + gapX) - gapX;
2970
+ var startX = padX + (newMaxRowWidth - rowWidth) / 2;
2971
+ items.forEach(function(id, idx) {
2972
+ newPositions[id] = { x: startX + idx * (boxW + gapX), y: padY + yIdx * (boxH + gapY) };
2973
+ });
2974
+ });
3140
2975
 
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);
2976
+ // Update SVG size
2977
+ var newTotalW = (newMaxRowWidth || 0) + padX * 2;
2978
+ var newTotalH = padY * 2 + Math.max(1, visibleDepths.length) * (boxH + gapY);
2979
+ hSvg.attr('width', Math.max(newTotalW, W)).attr('height', Math.max(newTotalH, H));
3147
2980
 
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');
2981
+ // Update nodes: hide/show + transition positions
2982
+ nodeG.selectAll('.hier-node').each(function() {
2983
+ var nId = this.__data_id;
2984
+ var el = d3.select(this);
2985
+ if (!isVisible(nId) || !newPositions[nId]) {
2986
+ el.attr('display', 'none');
2987
+ } else {
2988
+ el.attr('display', null)
2989
+ .transition().duration(300)
2990
+ .attr('transform', 'translate(' + newPositions[nId].x + ',' + newPositions[nId].y + ')');
2991
+ }
2992
+ });
3157
2993
 
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);
2994
+ // Update links: show only if both endpoints visible, recalculate bezier
2995
+ linkG.selectAll('path').each(function() {
2996
+ var sId = this.getAttribute('data-source');
2997
+ var tId = this.getAttribute('data-target');
2998
+ var el = d3.select(this);
2999
+ if (!isVisible(sId) || !isVisible(tId) || !newPositions[sId] || !newPositions[tId]) {
3000
+ el.attr('display', 'none');
3001
+ } else {
3002
+ var s = newPositions[sId], t = newPositions[tId];
3003
+ var x1 = s.x + boxW / 2, y1 = s.y + boxH;
3004
+ var x2 = t.x + boxW / 2, y2 = t.y;
3005
+ var midY = (y1 + y2) / 2;
3006
+ el.attr('display', null)
3007
+ .transition().duration(300)
3008
+ .attr('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2);
3009
+ }
3174
3010
  });
3175
- node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
3176
- });
3177
3011
 
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
3012
+ // Update depth labels: hide empty depths, reposition visible ones
3013
+ hG.selectAll('.hier-layer-label').each(function() {
3014
+ var depthIdx = +this.getAttribute('data-depth-idx');
3015
+ var el = d3.select(this);
3016
+ var yIdx = visibleDepths.indexOf(depthIdx);
3017
+ if (yIdx === -1) {
3018
+ el.attr('display', 'none');
3019
+ } else {
3020
+ el.attr('display', null)
3021
+ .transition().duration(300)
3022
+ .attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
3023
+ }
3024
+ });
3184
3025
 
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
- });
3026
+ // Close detail panel if pinned node became hidden
3027
+ if (hierPinned && !isVisible(hierPinned)) {
3028
+ closeHierDetail();
3029
+ }
3030
+ }
3195
3031
 
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 };
3032
+ function hierSyncFromTabInner() {
3033
+ if (!LAYERS) return;
3034
+ hActiveLayers.clear();
3035
+ activeLayers.forEach(function(name) { hActiveLayers.add(name); });
3036
+ // Sync pill UI
3037
+ hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
3038
+ var ln = p.dataset.layer;
3039
+ if (ln === 'all') {
3040
+ p.classList.toggle('active', hActiveLayers.size === 0);
3041
+ } else {
3042
+ p.classList.toggle('active', hActiveLayers.has(ln));
3043
+ }
3207
3044
  });
3208
- return centers;
3209
3045
  }
3210
3046
 
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));
3047
+ if (LAYERS) {
3048
+ // "All" button
3049
+ const allPill=document.createElement('div');
3050
+ allPill.className='layer-pill active';
3051
+ allPill.style.fontWeight='400';
3052
+ allPill.textContent='All';
3053
+ allPill.dataset.layer='all';
3054
+ allPill.onclick=()=>{
3055
+ hActiveLayers.clear();
3056
+ hFilterRow.querySelectorAll('.layer-pill').forEach(p=>p.classList.remove('active'));
3057
+ allPill.classList.add('active');
3058
+ hierRelayoutInner();
3059
+ };
3060
+ hFilterRow.appendChild(allPill);
3216
3061
 
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;
3062
+ LAYERS.forEach(layer => {
3063
+ const pill=document.createElement('div');
3064
+ pill.className='layer-pill';
3065
+ pill.dataset.layer=layer.name;
3066
+ const count=DATA.nodes.filter(n=>n.layer===layer.name).length;
3067
+ pill.innerHTML='<div class="lp-dot" style="background:'+esc(layer.color)+'"></div>'+esc(layer.name)+' <span class="lp-count">'+count+'</span>';
3068
+ pill.onclick=(e)=>{
3069
+ if (e.shiftKey) {
3070
+ hActiveLayers.clear();
3071
+ hActiveLayers.add(layer.name);
3072
+ } else {
3073
+ if (hActiveLayers.has(layer.name)) hActiveLayers.delete(layer.name);
3074
+ else hActiveLayers.add(layer.name);
3075
+ }
3076
+ // Sync pill UI
3077
+ hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
3078
+ var ln = p.dataset.layer;
3079
+ if (ln === 'all') p.classList.toggle('active', hActiveLayers.size === 0);
3080
+ else p.classList.toggle('active', hActiveLayers.has(ln));
3081
+ });
3082
+ hierRelayoutInner();
3083
+ };
3084
+ hFilterRow.appendChild(pill);
3085
+ });
3086
+ } else {
3087
+ const hActiveDirs=new Set(DATA.dirs);
3088
+ DATA.dirs.forEach(dir=>{
3089
+ const pill=document.createElement('div');
3090
+ pill.className='filter-pill active';
3091
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
3092
+ pill.onclick=()=>{
3093
+ if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
3094
+ else{hActiveDirs.add(dir);pill.classList.add('active');}
3095
+ nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
3096
+ };
3097
+ hFilterRow.appendChild(pill);
3098
+ });
3244
3099
  }
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
3100
 
3254
- hullGroup = g.insert('g', ':first-child');
3101
+ // Assign function pointers for cross-view sync
3102
+ hierRelayout = hierRelayoutInner;
3103
+ hierSyncFromTab = hierSyncFromTabInner;
3255
3104
 
3256
- function updateHulls() {
3257
- if (!hullGroup) return;
3258
- hullGroup.selectAll('*').remove();
3259
- // Show hulls always (filter to selected layers when focused)
3105
+ hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
3106
+ Math.max(0,(W-totalW)/2),20
3107
+ ).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
3260
3108
 
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;
3109
+ // If layers were already filtered in graph view, sync hierarchy on first build
3110
+ if (activeLayers.size > 0) {
3111
+ hierSyncFromTabInner();
3112
+ hierRelayoutInner();
3113
+ }
3114
+ }
3115
+ `;
3116
+ }
3265
3117
 
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
- });
3118
+ // src/web/js-diff.ts
3119
+ function buildDiffJs(diffData) {
3120
+ return `
3121
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3122
+ // DIFF VIEW
3123
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3124
+ const DIFF = ${diffData};
3125
+ if (DIFF) {
3126
+ document.getElementById('diff-tab').style.display = '';
3127
+ const addedSet = new Set(DIFF.added||[]);
3128
+ const removedSet = new Set(DIFF.removed||[]);
3129
+ const modifiedSet = new Set(DIFF.modified||[]);
3130
+ const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
3275
3131
 
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
- }
3132
+ // Populate summary counts
3133
+ document.getElementById('diff-added-count').textContent = addedSet.size;
3134
+ document.getElementById('diff-removed-count').textContent = removedSet.size;
3135
+ document.getElementById('diff-modified-count').textContent = modifiedSet.size;
3136
+ document.getElementById('diff-affected-count').textContent = affectedSet.size;
3296
3137
 
3297
- const hull = d3.polygonHull(points);
3298
- if (!hull) return;
3138
+ function isDiffNode(id) {
3139
+ return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
3140
+ }
3299
3141
 
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);
3142
+ function diffStatus(id) {
3143
+ if (addedSet.has(id)) return 'Added';
3144
+ if (removedSet.has(id)) return 'Removed';
3145
+ if (modifiedSet.has(id)) return 'Modified';
3146
+ if (affectedSet.has(id)) return 'Affected';
3147
+ return 'Unchanged';
3148
+ }
3305
3149
 
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
- });
3150
+ function diffStatusColor(id) {
3151
+ if (addedSet.has(id)) return 'var(--green)';
3152
+ if (removedSet.has(id)) return 'var(--red)';
3153
+ if (modifiedSet.has(id)) return 'var(--yellow)';
3154
+ if (affectedSet.has(id)) return 'var(--accent)';
3155
+ return 'var(--text-muted)';
3315
3156
  }
3316
3157
 
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();
3158
+ // Build reverse dependency map for impact chain
3159
+ var diffRevMap = {};
3160
+ DATA.links.forEach(function(l) {
3161
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3162
+ if (!diffRevMap[t]) diffRevMap[t] = [];
3163
+ diffRevMap[t].push(s);
3350
3164
  });
3351
3165
 
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);
3166
+ function getImpactChain(startId) {
3167
+ var result = new Set();
3168
+ var queue = [startId];
3169
+ while (queue.length) {
3170
+ var id = queue.shift();
3171
+ if (result.has(id)) continue;
3172
+ result.add(id);
3173
+ (diffRevMap[id] || []).forEach(function(x) { queue.push(x); });
3174
+ }
3175
+ return result;
3366
3176
  }
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
3177
 
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(); }
3178
+ let diffFocusMode = false;
3179
+ var dNode, dLink, dSim, simNodes, simLinks;
3180
+
3181
+ window.toggleDiffFocus = function() {
3182
+ diffFocusMode = !diffFocusMode;
3183
+ var btn = document.getElementById('diff-focus-btn');
3184
+ btn.classList.toggle('active', diffFocusMode);
3185
+ btn.textContent = diffFocusMode ? i('diff.showAll') : i('diff.focusChanges');
3186
+ if (!diffBuilt) return;
3187
+ applyDiffFilter();
3382
3188
  };
3383
- layerTabsEl.appendChild(allTab);
3384
3189
 
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
- });
3190
+ window.closeDiffDetail = function() {
3191
+ document.getElementById('diff-detail').style.display = 'none';
3192
+ if (diffBuilt) resetDiffHighlight();
3193
+ };
3406
3194
 
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));
3195
+ function applyDiffFilter() {
3196
+ dNode.attr('display', function(d) {
3197
+ if (!diffFocusMode) return null;
3198
+ return isDiffNode(d.id) ? null : 'none';
3411
3199
  });
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));
3200
+ dNode.select('circle')
3201
+ .attr('opacity', function(d) {
3202
+ if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
3203
+ return isDiffNode(d.id) ? 1 : 0.12;
3204
+ });
3205
+ dNode.select('text')
3206
+ .attr('opacity', function(d) {
3207
+ if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
3208
+ return isDiffNode(d.id) ? 1 : 0.08;
3209
+ });
3210
+ dLink.attr('display', function(l) {
3211
+ if (!diffFocusMode) return null;
3212
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3213
+ return (isDiffNode(s) && isDiffNode(t)) ? null : 'none';
3415
3214
  });
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;
3215
+ dLink.attr('opacity', function(l) {
3216
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3217
+ if (isDiffNode(s) && isDiffNode(t)) return 0.6;
3218
+ if (isDiffNode(s) || isDiffNode(t)) return 0.15;
3219
+ return diffFocusMode ? 0 : 0.05;
3456
3220
  });
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);
3221
+ dLink.attr('stroke', function(l) {
3222
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3223
+ if (isDiffNode(s) && isDiffNode(t)) return diffStatusColor(s);
3224
+ return '#30363d';
3461
3225
  });
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));
3226
+ dNode.select('circle')
3227
+ .attr('stroke-width', function(d) { return isDiffNode(d.id) ? 3 : 1; });
3228
+ }
3229
+
3230
+ function resetDiffHighlight() {
3231
+ applyDiffFilter();
3232
+ }
3233
+
3234
+ function highlightDiffImpact(d) {
3235
+ var chain = getImpactChain(d.id);
3236
+ dNode.select('circle').transition().duration(200)
3237
+ .attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.04; })
3238
+ .attr('stroke-width', function(n) { return chain.has(n.id) && n.id !== d.id ? 3 : isDiffNode(n.id) ? 3 : 1; })
3239
+ .attr('stroke', function(n) { return chain.has(n.id) && n.id !== d.id ? 'var(--red)' : diffStatusColor(n.id); });
3240
+ dNode.select('text').transition().duration(200)
3241
+ .attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.03; });
3242
+ dLink.transition().duration(200)
3243
+ .attr('opacity', function(l) {
3244
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3245
+ return (chain.has(s) && chain.has(t)) ? 0.8 : 0.03;
3246
+ })
3247
+ .attr('stroke', function(l) {
3248
+ var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3249
+ return (chain.has(s) && chain.has(t)) ? 'var(--red)' : '#30363d';
3250
+ });
3251
+ return chain;
3252
+ }
3253
+
3254
+ function showDiffDetail(d) {
3255
+ var panel = document.getElementById('diff-detail');
3256
+ document.getElementById('dd-name').textContent = d.id;
3257
+ var statusEl = document.getElementById('dd-status');
3258
+ statusEl.textContent = diffStatus(d.id);
3259
+ statusEl.style.color = diffStatusColor(d.id);
3260
+ document.getElementById('dd-meta').innerHTML = i('detail.dir') + ': ' + esc(d.dir) + '<br>' + i('detail.dependencies') + ': ' + d.deps + ' \\u00b7 ' + i('detail.dependents') + ': ' + d.dependents;
3261
+
3262
+ // Show impact chain
3263
+ var chain = getImpactChain(d.id);
3264
+ chain.delete(d.id);
3265
+ var affectedList = document.getElementById('dd-affected');
3266
+ if (chain.size > 0) {
3267
+ affectedList.innerHTML = Array.from(chain).map(function(id) {
3268
+ return '<li style="color:' + diffStatusColor(id) + '">\\u2190 ' + esc(id) + ' <span style="font-size:10px;color:var(--text-muted)">(' + diffStatus(id) + ')</span></li>';
3269
+ }).join('');
3473
3270
  } 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));
3271
+ affectedList.innerHTML = '<li style="color:var(--text-muted)">' + i('diff.noImpact') + '</li>';
3478
3272
  }
3479
- simulation.alpha(0.6).restart();
3480
- // Zoom to fit visible nodes after simulation settles
3481
- setTimeout(() => zoomFit(), 600);
3273
+
3274
+ // Show imports
3275
+ var depsList = document.getElementById('dd-deps');
3276
+ depsList.innerHTML = (d.dependencies || []).map(function(x) {
3277
+ return '<li style="color:' + diffStatusColor(x) + '">\\u2192 ' + esc(x) + '</li>';
3278
+ }).join('') || '<li style="color:var(--text-muted)">' + i('detail.none') + '</li>';
3279
+
3280
+ panel.style.display = 'block';
3482
3281
  }
3483
3282
 
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');
3283
+ function buildDiffView() {
3284
+ const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
3285
+ const dG = dSvg.append('g');
3286
+ const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
3287
+ dSvg.call(dZoom);
3487
3288
 
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);
3289
+ function diffColor(d) {
3290
+ if (addedSet.has(d.id)) return 'var(--green)';
3291
+ if (removedSet.has(d.id)) return 'var(--red)';
3292
+ if (modifiedSet.has(d.id)) return 'var(--yellow)';
3293
+ if (affectedSet.has(d.id)) return 'var(--accent)';
3294
+ return '#30363d';
3295
+ }
3498
3296
 
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
- }
3297
+ const dDefs = dSvg.append('defs');
3298
+ dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
3299
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3300
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
3301
+ // Colored arrow markers for diff edges
3302
+ [['var(--green)','darrow-g'],['var(--red)','darrow-r'],['var(--yellow)','darrow-y'],['var(--accent)','darrow-a']].forEach(function(pair) {
3303
+ dDefs.append('marker').attr('id',pair[1]).attr('viewBox','0 -4 8 8')
3304
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3305
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',pair[0]);
3306
+ });
3511
3307
 
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);
3308
+ simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
3309
+ simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
3535
3310
 
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);
3311
+ dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
3312
+ .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.05);
3313
+
3314
+ dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
3315
+ dNode.append('circle')
3316
+ .attr('r', d=>nodeRadius(d)*nodeScale)
3317
+ .attr('fill', diffColor)
3318
+ .attr('stroke', diffColor).attr('stroke-width', d=>isDiffNode(d.id)?3:1)
3319
+ .attr('opacity', d=>isDiffNode(d.id)?1:0.12);
3320
+ dNode.append('text')
3321
+ .text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
3322
+ .attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
3323
+ .attr('fill', d=>isDiffNode(d.id)?'var(--text)':'var(--text-muted)')
3324
+ .attr('opacity', d=>isDiffNode(d.id)?1:0.08)
3325
+ .attr('pointer-events','none');
3326
+
3327
+ dSim = d3.forceSimulation(simNodes)
3328
+ .force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
3329
+ .force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
3330
+ .force('center', d3.forceCenter(0,0))
3331
+ .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4));
3332
+
3333
+ // Layer-aware physics for diff view (same pattern as graph view)
3334
+ var dHullGroup = null;
3335
+ if (LAYERS && LAYERS.length > 0) {
3336
+ var dLayerCenters = {};
3337
+ var dLayerCount = LAYERS.length;
3338
+ var dBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(dLayerCount));
3339
+ LAYERS.forEach(function(l, idx) {
3340
+ var angle = (2 * Math.PI * idx) / dLayerCount - Math.PI / 2;
3341
+ dLayerCenters[l.name] = { x: Math.cos(angle) * dBaseRadius, y: Math.sin(angle) * dBaseRadius };
3342
+ });
3343
+ dSim.force('center', null);
3344
+ dSim.force('layerX', d3.forceX(function(d) { return dLayerCenters[d.layer]?.x || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
3345
+ dSim.force('layerY', d3.forceY(function(d) { return dLayerCenters[d.layer]?.y || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
3346
+ dSim.force('link').strength(function(l) {
3347
+ var sL = l.source.layer ?? l.source, tL = l.target.layer ?? l.target;
3348
+ return sL === tL ? 0.4 : 0.1;
3349
+ });
3350
+ // Cluster force for diff view
3351
+ dSim.force('cluster', (function() {
3352
+ var ns;
3353
+ function f(alpha) {
3354
+ var centroids = {}, counts = {};
3355
+ ns.forEach(function(n) {
3356
+ if (!n.layer) return;
3357
+ if (!centroids[n.layer]) { centroids[n.layer] = {x:0,y:0}; counts[n.layer] = 0; }
3358
+ centroids[n.layer].x += n.x; centroids[n.layer].y += n.y; counts[n.layer]++;
3359
+ });
3360
+ Object.keys(centroids).forEach(function(k) { centroids[k].x /= counts[k]; centroids[k].y /= counts[k]; });
3361
+ ns.forEach(function(n) {
3362
+ if (!n.layer || !centroids[n.layer]) return;
3363
+ n.vx += (centroids[n.layer].x - n.x) * alpha * 0.2;
3364
+ n.vy += (centroids[n.layer].y - n.y) * alpha * 0.2;
3365
+ });
3366
+ }
3367
+ f.initialize = function(n) { ns = n; };
3368
+ return f;
3369
+ })());
3370
+
3371
+ dHullGroup = dG.insert('g', ':first-child');
3372
+ }
3373
+
3374
+ function updateDiffHulls() {
3375
+ if (!dHullGroup) return;
3376
+ dHullGroup.selectAll('*').remove();
3377
+ LAYERS.forEach(function(layer) {
3378
+ var layerNodes = simNodes.filter(function(n) { return n.layer === layer.name; });
3379
+ if (layerNodes.length === 0) return;
3380
+ if (diffFocusMode && !layerNodes.some(function(n) { return isDiffNode(n.id); })) return;
3381
+ var hasDiff = layerNodes.some(function(n) { return isDiffNode(n.id); });
3382
+
3383
+ var points = [];
3384
+ layerNodes.forEach(function(n) {
3385
+ if (n.x == null || n.y == null) return;
3386
+ if (diffFocusMode && !isDiffNode(n.id)) return;
3387
+ var r = nodeRadius(n) * nodeScale + 30;
3388
+ for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
3389
+ points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
3390
+ }
3391
+ });
3392
+
3393
+ var fillOp = hasDiff ? 0.15 : 0.06;
3394
+ var strokeOp = hasDiff ? 0.6 : 0.2;
3395
+ var sw = hasDiff ? 2.5 : 1;
3396
+ if (points.length < 6) {
3397
+ var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
3398
+ var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
3399
+ dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
3400
+ .attr('fill', layer.color).attr('fill-opacity', fillOp)
3401
+ .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
3402
+ } else {
3403
+ var hull = d3.polygonHull(points);
3404
+ if (hull) {
3405
+ dHullGroup.append('path')
3406
+ .attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
3407
+ .attr('fill', layer.color).attr('fill-opacity', fillOp)
3408
+ .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
3409
+ .attr('stroke-dasharray', hasDiff ? null : '6,3');
3410
+ }
3411
+ }
3412
+ // Layer name label
3413
+ var visNodes = diffFocusMode ? layerNodes.filter(function(n) { return isDiffNode(n.id); }) : layerNodes;
3414
+ if (visNodes.length === 0) return;
3415
+ var lx = visNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / visNodes.length;
3416
+ var ly = Math.min.apply(null, visNodes.map(function(n) { return n.y||0; })) - 25;
3417
+ dHullGroup.append('text')
3418
+ .attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
3419
+ .attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
3420
+ .attr('font-size', 12).attr('font-weight', 600).text(layer.name);
3558
3421
  });
3559
- group.appendChild(pillsWrap);
3560
- dirPanelEl.appendChild(group);
3561
3422
  }
3423
+
3424
+ var dTickCount = 0;
3425
+ dSim.on('tick', function() {
3426
+ dLink.each(function(d) {
3427
+ var dx=d.target.x-d.source.x, dy=d.target.y-d.source.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
3428
+ var rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
3429
+ d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
3430
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3431
+ });
3432
+ dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
3433
+ if (++dTickCount % 5 === 0) updateDiffHulls();
3434
+ });
3435
+
3436
+ // Click: show impact chain + detail panel
3437
+ dNode.on('click', function(e, d) {
3438
+ e.stopPropagation();
3439
+ highlightDiffImpact(d);
3440
+ showDiffDetail(d);
3441
+ });
3442
+
3443
+ // Click on empty space to deselect
3444
+ dSvg.on('click', function() {
3445
+ closeDiffDetail();
3446
+ });
3447
+
3448
+ dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
3449
+
3450
+ // Apply initial filter (in case focus was toggled before build)
3451
+ applyDiffFilter();
3452
+
3453
+ var dAutoFitDone = false;
3454
+ dSim.on('end', function() {
3455
+ if (dAutoFitDone) return;
3456
+ dAutoFitDone = true;
3457
+ var b=dG.node().getBBox(); if(!b.width) return;
3458
+ var s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
3459
+ dSvg.call(dZoom.transform,d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s,H/2-(b.y+b.height/2)*s).scale(s));
3460
+ });
3461
+ }
3462
+
3463
+ }
3464
+ `;
3465
+ }
3466
+
3467
+ // src/utils/html-escape.ts
3468
+ var ESC_FUNCTION_JS = `function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }`;
3469
+
3470
+ // src/web/template.ts
3471
+ function buildGraphPage(graph, options = {}) {
3472
+ const locale = options.locale ?? "en";
3473
+ const diff = options.diff ?? null;
3474
+ const layers = options.layerMetadata ?? null;
3475
+ const crossEdges = options.crossLayerEdges ?? null;
3476
+ const files = Object.values(graph.files);
3477
+ const nodes = files.map((f) => ({
3478
+ id: f.path,
3479
+ deps: f.dependencies.length,
3480
+ dependents: f.dependents.length,
3481
+ dependencies: f.dependencies,
3482
+ dependentsList: f.dependents,
3483
+ isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
3484
+ dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".",
3485
+ layer: layers && f.path.includes("/") ? f.path.substring(0, f.path.indexOf("/")) : null
3486
+ }));
3487
+ const links = graph.edges.map((e) => ({
3488
+ source: e.source,
3489
+ target: e.target,
3490
+ type: e.type
3491
+ }));
3492
+ const circularFiles = /* @__PURE__ */ new Set();
3493
+ for (const c of graph.circularDependencies) {
3494
+ for (const f of c.cycle) circularFiles.add(f);
3495
+ }
3496
+ const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
3497
+ const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
3498
+ const diffData = diff ? JSON.stringify(diff) : "null";
3499
+ const layersData = layers ? JSON.stringify(layers) : "null";
3500
+ const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
3501
+ const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
3502
+ return (
3503
+ /* html */
3504
+ `<!DOCTYPE html>
3505
+ <html lang="${locale}">
3506
+ <head>
3507
+ <meta charset="utf-8">
3508
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3509
+ <title>${projectName} \u2014 Architecture Viewer</title>
3510
+ ${buildStyles()}
3511
+ </head>
3512
+ <body>
3513
+ ${buildViewerHtml()}
3514
+ <script src="https://d3js.org/d3.v7.min.js"></script>
3515
+ <script>
3516
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3517
+ // i18n
3518
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3519
+ const I18N = {
3520
+ en: {
3521
+ 'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
3522
+ 'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
3523
+ 'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
3524
+ 'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
3525
+ 'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
3526
+ 'search.placeholder': 'Search files...',
3527
+ 'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
3528
+ 'legend.imports': 'imports', 'legend.importedBy': 'imported by',
3529
+ 'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
3530
+ 'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
3531
+ 'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
3532
+ 'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
3533
+ 'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
3534
+ 'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
3535
+ 'tab.diff': 'Diff',
3536
+ 'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
3537
+ 'diff.showAll': 'Show all', 'diff.focusChanges': 'Focus changes', 'diff.noImpact': 'No downstream impact',
3538
+ 'diff.affectedByChange': 'Affected by this change',
3539
+ },
3540
+ ja: {
3541
+ 'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
3542
+ 'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
3543
+ 'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
3544
+ 'settings.nodeSize': '\u30CE\u30FC\u30C9\u30B5\u30A4\u30BA', 'settings.linkOpacity': '\u30EA\u30F3\u30AF\u900F\u660E\u5EA6', 'settings.gravity': '\u91CD\u529B', 'settings.language': '\u8A00\u8A9E', 'settings.export': '\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8',
3545
+ 'impact.title': '\u5F71\u97FF\u7BC4\u56F2\u30B7\u30DF\u30E5\u30EC\u30FC\u30B7\u30E7\u30F3', 'impact.btn': '\u5F71\u97FF', 'impact.transitive': '\u30D5\u30A1\u30A4\u30EB\u306B\u5F71\u97FF',
3546
+ 'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
3547
+ 'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
3548
+ 'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
3549
+ 'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
3550
+ 'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
3551
+ 'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
3552
+ 'help.graph': '\u30B9\u30AF\u30ED\u30FC\u30EB: \u30BA\u30FC\u30E0 \xB7 \u30C9\u30E9\u30C3\u30B0: \u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF: \u9078\u629E \xB7 / \u691C\u7D22',
3553
+ 'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
3554
+ 'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
3555
+ 'tab.diff': '\u5DEE\u5206',
3556
+ 'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
3557
+ 'diff.showAll': '\u5168\u8868\u793A', 'diff.focusChanges': '\u5909\u66F4\u306E\u307F\u8868\u793A', 'diff.noImpact': '\u4E0B\u6D41\u3078\u306E\u5F71\u97FF\u306A\u3057',
3558
+ 'diff.affectedByChange': '\u3053\u306E\u5909\u66F4\u306E\u5F71\u97FF\u7BC4\u56F2',
3559
+ }
3560
+ };
3561
+ let currentLang = '${locale}';
3562
+ function applyI18n() {
3563
+ const msgs = I18N[currentLang] || I18N.en;
3564
+ document.querySelectorAll('[data-i18n]').forEach(el => {
3565
+ const key = el.getAttribute('data-i18n');
3566
+ if (msgs[key]) el.textContent = msgs[key];
3562
3567
  });
3568
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
3569
+ const key = el.getAttribute('data-i18n-placeholder');
3570
+ if (msgs[key]) el.placeholder = msgs[key];
3571
+ });
3572
+ document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
3573
+ }
3574
+ window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
3575
+ function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
3576
+ ${ESC_FUNCTION_JS}
3577
+
3578
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3579
+ // SETTINGS (persisted to localStorage)
3580
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3581
+ const STORAGE_KEY = 'archtracker-settings';
3582
+ function saveSettings() {
3583
+ const s = { theme: document.body.getAttribute('data-theme') || 'dark', fontSize: document.getElementById('font-size-val').textContent, nodeSize: document.getElementById('node-size-val').textContent, linkOpacity: document.getElementById('link-opacity-val').textContent, gravity: document.getElementById('gravity-val').textContent, layerGravity: document.getElementById('layer-gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
3584
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
3585
+ }
3586
+ function loadSettings() {
3587
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; } catch(e) { return null; }
3588
+ }
3589
+
3590
+ let nodeScale = 1, baseLinkOpacity = 0.4;
3591
+ window.toggleSettings = () => document.getElementById('settings-panel').classList.toggle('open');
3592
+ window.setTheme = (theme) => {
3593
+ document.body.setAttribute('data-theme', theme === 'light' ? 'light' : '');
3594
+ document.querySelectorAll('.theme-btn[data-theme-val]').forEach(b => b.classList.toggle('active', b.dataset.themeVal === theme));
3595
+ saveSettings();
3596
+ };
3597
+ window.setFontSize = (v) => {
3598
+ document.getElementById('font-size-val').textContent = v;
3599
+ const scale = v / 13;
3600
+ if (typeof node !== 'undefined') {
3601
+ node.select('text').attr('font-size', d => (d.dependents>=3?12:10) * scale);
3602
+ }
3603
+ saveSettings();
3604
+ };
3605
+ window.setNodeScale = (v) => {
3606
+ nodeScale = v / 100;
3607
+ document.getElementById('node-size-val').textContent = v;
3608
+ if (typeof node !== 'undefined') {
3609
+ node.select('circle').attr('r', d => nodeRadius(d) * nodeScale);
3610
+ node.select('text').attr('dx', d => nodeRadius(d) * nodeScale + 4);
3611
+ simulation.force('collision', d3.forceCollide().radius(d => nodeRadius(d) * nodeScale + 4));
3612
+ simulation.alpha(0.3).restart();
3613
+ }
3614
+ saveSettings();
3615
+ };
3616
+ window.setLinkOpacity = (v) => {
3617
+ baseLinkOpacity = v / 100;
3618
+ document.getElementById('link-opacity-val').textContent = v;
3619
+ if (typeof link !== 'undefined') link.attr('opacity', baseLinkOpacity);
3620
+ saveSettings();
3621
+ };
3622
+ let gravityStrength = 150;
3623
+ window.setGravity = (v) => {
3624
+ gravityStrength = +v;
3625
+ document.getElementById('gravity-val').textContent = v;
3626
+ if (typeof simulation !== 'undefined') {
3627
+ if (typeof updateLayerPhysics === 'function') {
3628
+ updateLayerPhysics();
3629
+ } else {
3630
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
3631
+ }
3632
+ simulation.alpha(0.5).restart();
3633
+ }
3634
+ saveSettings();
3635
+ };
3636
+ let layerGravity = 12;
3637
+ window.setLayerGravity = (v) => {
3638
+ layerGravity = +v;
3639
+ document.getElementById('layer-gravity-val').textContent = v;
3640
+ if (typeof simulation !== 'undefined' && typeof updateLayerPhysics === 'function') {
3641
+ updateLayerPhysics();
3642
+ simulation.alpha(0.5).restart();
3643
+ }
3644
+ saveSettings();
3645
+ };
3563
3646
 
3564
- // Override applyFilter to respect layers
3565
- window._origApplyFilter = applyFilter;
3566
- }
3647
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3648
+ // EXPORT
3649
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3650
+ window.exportSVG = () => {
3651
+ const activeView = document.querySelector('.view.active svg');
3652
+ if (!activeView) return;
3653
+ const clone = activeView.cloneNode(true);
3654
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
3655
+ const blob = new Blob([clone.outerHTML], {type: 'image/svg+xml'});
3656
+ const a = document.createElement('a');
3657
+ a.href = URL.createObjectURL(blob);
3658
+ a.download = (document.getElementById('project-title').textContent || 'graph') + '.svg';
3659
+ a.click(); URL.revokeObjectURL(a.href);
3660
+ };
3661
+ window.exportPNG = () => {
3662
+ const activeView = document.querySelector('.view.active svg');
3663
+ if (!activeView) return;
3664
+ const clone = activeView.cloneNode(true);
3665
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
3666
+ const svgStr = new XMLSerializer().serializeToString(clone);
3667
+ const canvas = document.createElement('canvas');
3668
+ const bbox = activeView.getBoundingClientRect();
3669
+ canvas.width = bbox.width * 2; canvas.height = bbox.height * 2;
3670
+ const ctx = canvas.getContext('2d');
3671
+ ctx.scale(2, 2);
3672
+ const img = new Image();
3673
+ 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(); };
3674
+ img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
3675
+ };
3567
3676
 
3568
- setTimeout(()=>zoomFit(), 1500);
3677
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3678
+ // DATA
3679
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3680
+ const DATA = ${graphData};
3681
+ const LAYERS = ${layersData};
3682
+ const CROSS_EDGES = ${crossEdgesData};
3683
+ const W = window.innerWidth, H = window.innerHeight - 44;
3684
+ const circularSet = new Set(DATA.circularFiles);
3569
3685
 
3570
- // Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
3686
+ // Project title (editable)
3687
+ const titleEl = document.getElementById('project-title');
3688
+ titleEl.textContent = DATA.projectName;
3689
+ titleEl.addEventListener('blur', () => { if (!titleEl.textContent.trim()) titleEl.textContent = DATA.projectName; document.title = titleEl.textContent + ' \u2014 Architecture Viewer'; saveSettings(); });
3690
+ titleEl.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
3691
+
3692
+ // Restore saved settings \u2014 phase 1: non-graph settings (before graph init)
3693
+ const _savedSettings = loadSettings();
3571
3694
  if (_savedSettings) {
3572
- if (_savedSettings.fontSize) setFontSize(_savedSettings.fontSize);
3695
+ if (_savedSettings.theme) setTheme(_savedSettings.theme);
3696
+ if (_savedSettings.lang) { currentLang = _savedSettings.lang; applyI18n(); }
3697
+ if (_savedSettings.projectTitle) { titleEl.textContent = _savedSettings.projectTitle; document.title = _savedSettings.projectTitle + ' \u2014 Architecture Viewer'; }
3698
+ // Set slider positions (visual only \u2014 graph not built yet)
3699
+ if (_savedSettings.fontSize) { document.getElementById('font-size-slider').value = _savedSettings.fontSize; document.getElementById('font-size-val').textContent = _savedSettings.fontSize; }
3700
+ if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
3701
+ if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
3702
+ if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
3703
+ if (_savedSettings.layerGravity) { document.getElementById('layer-gravity-slider').value = _savedSettings.layerGravity; document.getElementById('layer-gravity-val').textContent = _savedSettings.layerGravity; layerGravity = +_savedSettings.layerGravity; }
3573
3704
  }
3574
3705
 
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;
3706
+ document.getElementById('s-files').textContent = DATA.nodes.length;
3707
+ document.getElementById('s-edges').textContent = DATA.links.length;
3708
+ document.getElementById('s-circular').textContent = DATA.circularFiles.length;
3577
3709
 
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
- }
3710
+ const dirColor = d3.scaleOrdinal()
3711
+ .domain(DATA.dirs)
3712
+ .range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
3589
3713
 
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)');
3714
+ // Layer color map (from LAYERS metadata)
3715
+ const layerColorMap = {};
3716
+ let activeLayerFilter = null; // DEPRECATED \u2014 kept for backward compat, always null with multi-select tabs
3717
+ const activeLayers = new Set(); // empty = no filter (show all); non-empty = show only selected
3718
+ if (LAYERS) {
3719
+ LAYERS.forEach(l => { layerColorMap[l.name] = l.color; });
3720
+ document.getElementById('layer-gravity-setting').style.display = '';
3598
3721
  }
3599
3722
 
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');
3723
+ function nodeColor(d) {
3724
+ if (circularSet.has(d.id)) return '#f97583';
3725
+ if (d.isOrphan) return '#484f58';
3726
+ // Layer coloring: all-visible or multi-select \u2192 layer colors; single-select \u2192 dir colors
3727
+ if (LAYERS && d.layer && layerColorMap[d.layer] && activeLayers.size !== 1) return layerColorMap[d.layer];
3728
+ return dirColor(d.dir);
3630
3729
  }
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);}
3730
+ function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
3731
+ function fileName(id) { return id.split('/').pop(); }
3641
3732
 
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);
3733
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3734
+ // TAB SWITCHING
3735
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3736
+ let hierBuilt = false;
3737
+ let diffBuilt = false;
3738
+ let hierRelayout = null;
3739
+ let hierSyncFromTab = null;
3740
+ document.querySelectorAll('.tab').forEach(tab => {
3741
+ tab.addEventListener('click', () => {
3742
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
3743
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
3744
+ tab.classList.add('active');
3745
+ document.getElementById(tab.dataset.view).classList.add('active');
3746
+ if (tab.dataset.view === 'hier-view') {
3747
+ if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
3748
+ if (hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
3749
+ }
3750
+ if (tab.dataset.view === 'diff-view') {
3751
+ if (!diffBuilt) { buildDiffView(); diffBuilt = true; }
3752
+ }
3753
+ });
3654
3754
  });
3655
3755
 
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
- });
3756
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3757
+ // TOOLTIP \u2014 delayed hide + interactive
3758
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3759
+ const tooltip = document.getElementById('tooltip');
3760
+ let tooltipHideTimer = null;
3761
+ let tooltipLocked = false;
3762
+
3763
+ function showTooltip(e, d) {
3764
+ clearTimeout(tooltipHideTimer);
3765
+ document.getElementById('tt-name').textContent = d.id;
3766
+ document.getElementById('tt-dep-count').textContent = d.deps;
3767
+ document.getElementById('tt-dpt-count').textContent = d.dependents;
3768
+ const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+esc(x)+'</div>');
3769
+ const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+esc(x)+'</div>');
3770
+ document.getElementById('tt-details').innerHTML = [...out, ...inc].join('');
3771
+ tooltip.style.display = 'block';
3772
+ positionTooltip(e);
3681
3773
  }
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
- });
3774
+ function positionTooltip(e) {
3775
+ const gap = 24;
3776
+ const tw = 420, th = tooltip.offsetHeight || 200;
3777
+ // Prefer placing to the right and above the cursor so it doesn't cover nodes below
3778
+ let x = e.clientX + gap;
3779
+ let y = e.clientY - th - 12;
3780
+ // If no room on the right, flip left
3781
+ if (x + tw > window.innerWidth) x = e.clientX - tw - gap;
3782
+ // If no room above, place below the cursor with gap
3783
+ if (y < 50) y = e.clientY + gap;
3784
+ // Final clamp
3785
+ if (y + th > window.innerHeight) y = window.innerHeight - th - 8;
3786
+ if (x < 8) x = 8;
3787
+ tooltip.style.left = x + 'px';
3788
+ tooltip.style.top = y + 'px';
3693
3789
  }
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;
3790
+ function scheduleHideTooltip() {
3791
+ clearTimeout(tooltipHideTimer);
3792
+ tooltipHideTimer = setTimeout(() => {
3793
+ if (!tooltipLocked) {
3794
+ tooltip.style.display = 'none';
3795
+ if (!pinnedNode) resetGraphHighlight();
3796
+ }
3797
+ }, 250);
3709
3798
  }
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
3799
 
3731
- window.addEventListener('resize',()=>{
3732
- const w=window.innerWidth,h=window.innerHeight-44;
3733
- svg.attr('width',w).attr('height',h);
3800
+ // Keep tooltip visible when mouse enters it
3801
+ tooltip.addEventListener('mouseenter', () => {
3802
+ clearTimeout(tooltipHideTimer);
3803
+ tooltipLocked = true;
3804
+ });
3805
+ tooltip.addEventListener('mouseleave', () => {
3806
+ tooltipLocked = false;
3807
+ scheduleHideTooltip();
3734
3808
  });
3735
3809
 
3736
3810
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\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
3811
+ // GRAPH VIEW
3738
3812
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\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);
3813
+ const svg = d3.select('#graph-svg').attr('width', W).attr('height', H);
3814
+ const g = svg.append('g');
3815
+ const zoom = d3.zoom().scaleExtent([0.05, 10]).on('zoom', e => g.attr('transform', e.transform));
3816
+ svg.call(zoom);
3817
+ svg.call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(0.7));
3744
3818
 
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);});
3819
+ window.zoomIn = () => svg.transition().duration(300).call(zoom.scaleBy, 1.4);
3820
+ window.zoomOut = () => svg.transition().duration(300).call(zoom.scaleBy, 0.7);
3821
+ window.zoomFit = () => {
3822
+ const b = g.node().getBBox(); if (!b.width) return;
3823
+ const s = Math.min(W/(b.width+80), H/(b.height+80))*0.9;
3824
+ 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));
3825
+ };
3747
3826
 
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;});
3827
+ // Defs
3828
+ const defs = svg.append('defs');
3829
+ ['#30363d','#58a6ff','#3fb950'].forEach((c,i) => {
3830
+ defs.append('marker').attr('id','arrow-'+i).attr('viewBox','0 -4 8 8')
3831
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3832
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',c);
3833
+ });
3752
3834
 
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;});
3835
+ // Links
3836
+ const link = g.append('g').selectAll('line').data(DATA.links).join('line')
3837
+ .attr('stroke', d => d.type==='type-only'?'#1f3d5c':'#30363d')
3838
+ .attr('stroke-width',1)
3839
+ .attr('stroke-dasharray', d => d.type==='type-only'?'4,3':d.type==='dynamic'?'6,3':null)
3840
+ .attr('marker-end','url(#arrow-0)')
3841
+ .attr('opacity', baseLinkOpacity);
3760
3842
 
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)));
3843
+ // Cross-layer links (from layers.json connections)
3844
+ defs.append('marker').attr('id','arrow-cross').attr('viewBox','0 -4 8 8')
3845
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3846
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#f0883e');
3766
3847
 
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
- }
3848
+ const crossLinkData = (CROSS_EDGES || []).map(e => ({
3849
+ source: e.fromLayer + '/' + e.fromFile,
3850
+ target: e.toLayer + '/' + e.toFile,
3851
+ sourceLayer: e.fromLayer,
3852
+ targetLayer: e.toLayer,
3853
+ type: e.type || 'api-call',
3854
+ label: e.label || e.type || '',
3855
+ })).filter(e => DATA.nodes.some(n => n.id === e.source) && DATA.nodes.some(n => n.id === e.target));
3774
3856
 
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));
3857
+ const crossLinkG = g.append('g');
3858
+ const crossLink = crossLinkG.selectAll('line').data(crossLinkData).join('line')
3859
+ .attr('stroke', '#f0883e')
3860
+ .attr('stroke-width', 2)
3861
+ .attr('stroke-dasharray', '8,4')
3862
+ .attr('marker-end', 'url(#arrow-cross)')
3863
+ .attr('opacity', 0.7);
3864
+ const crossLabel = crossLinkG.selectAll('text').data(crossLinkData).join('text')
3865
+ .text(d => d.label)
3866
+ .attr('font-size', 9)
3867
+ .attr('fill', '#f0883e')
3868
+ .attr('text-anchor', 'middle')
3869
+ .attr('opacity', 0.8)
3870
+ .attr('pointer-events', 'none');
3777
3871
 
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);
3872
+ // Nodes
3873
+ const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
3874
+ .attr('cursor','pointer')
3875
+ .call(d3.drag().on('start',dragStart).on('drag',dragging).on('end',dragEnd));
3876
+
3877
+ node.append('circle')
3878
+ .attr('r', d => nodeRadius(d) * nodeScale)
3879
+ .attr('fill', nodeColor)
3880
+ .attr('stroke', d => d.deps>=5?'var(--yellow)':nodeColor(d))
3881
+ .attr('stroke-width', d => d.deps>=5?2.5:1.5)
3882
+ .attr('stroke-opacity', d => d.deps>=5?0.8:0.3);
3883
+
3884
+ node.append('text')
3885
+ .text(d => fileName(d.id).replace(/\\.tsx?$/,''))
3886
+ .attr('dx', d => nodeRadius(d)*nodeScale+4)
3887
+ .attr('dy',3.5)
3888
+ .attr('font-size', d => d.dependents>=3?12:10)
3889
+ .attr('font-weight', d => d.dependents>=3?600:400)
3890
+ .attr('fill', d => d.dependents>=3?'var(--text)':'var(--text-dim)')
3891
+ .attr('opacity', d => d.dependents>=1||d.deps>=3?1:0.5)
3892
+ .attr('pointer-events','none');
3893
+
3894
+ // Simulation
3895
+ const simulation = d3.forceSimulation(DATA.nodes)
3896
+ .force('link', d3.forceLink(DATA.links).id(d=>d.id).distance(70).strength(0.25))
3897
+ .force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500))
3898
+ .force('center', d3.forceCenter(0,0))
3899
+ .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
3900
+ .force('x', d3.forceX(0).strength(0.03))
3901
+ .force('y', d3.forceY(0).strength(0.03))
3902
+ .on('tick', () => {
3903
+ link.each(function(d) {
3904
+ const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
3905
+ const dist=Math.sqrt(dx*dx+dy*dy)||1;
3906
+ const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
3907
+ d3.select(this)
3908
+ .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
3909
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3910
+ });
3911
+ node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
3788
3912
  });
3789
3913
 
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)');
3914
+ // \u2500\u2500\u2500 Layer convex hulls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3915
+ let hullGroup = null;
3916
+ const activeDirs = new Set(DATA.dirs);
3917
+ const dirCounts = {};
3918
+ DATA.nodes.forEach(n => dirCounts[n.dir] = (dirCounts[n.dir] || 0) + 1);
3919
+ var applyLayerFilter = null; // hoisted for dir-filter integration
3920
+ var updateLayerPhysics = null; // hoisted \u2014 updates charge/layer forces without visibility changes
3794
3921
 
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);
3922
+ if (LAYERS && LAYERS.length > 0) {
3923
+ // \u2500\u2500\u2500 Water droplet physics: intra-layer cohesion + inter-layer separation \u2500\u2500\u2500
3924
+ const allLayerCount = LAYERS.length;
3925
+ const allBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(allLayerCount));
3926
+ // Pre-compute full-circle positions for all layers (used when no filter)
3927
+ const allLayerCenters = {};
3928
+ LAYERS.forEach((l, idx) => {
3929
+ const angle = (2 * Math.PI * idx) / allLayerCount - Math.PI / 2;
3930
+ allLayerCenters[l.name] = { x: Math.cos(angle) * allBaseRadius, y: Math.sin(angle) * allBaseRadius };
3931
+ });
3932
+
3933
+ // Dynamic center calculation: compact when multi-selecting, full spread when all
3934
+ function getLayerCenters() {
3935
+ if (activeLayers.size <= 1) return allLayerCenters; // 0 = all, 1 = single (centered)
3936
+ // Multi-select: arrange only selected layers compactly on a smaller circle
3937
+ const selected = LAYERS.filter(l => activeLayers.has(l.name));
3938
+ const count = selected.length;
3939
+ const compactRadius = Math.max(40, Math.min(W, H) * 0.03 * Math.sqrt(count));
3940
+ const centers = {};
3941
+ selected.forEach((l, idx) => {
3942
+ const angle = (2 * Math.PI * idx) / count - Math.PI / 2;
3943
+ centers[l.name] = { x: Math.cos(angle) * compactRadius, y: Math.sin(angle) * compactRadius };
3944
+ });
3945
+ return centers;
3800
3946
  }
3801
3947
 
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);
3948
+ // Replace default centering forces with per-layer positioning
3949
+ const layerStrength = layerGravity / 100;
3950
+ simulation.force('x', null).force('y', null).force('center', null);
3951
+ simulation.force('layerX', d3.forceX(d => allLayerCenters[d.layer]?.x || 0).strength(d => d.layer ? layerStrength : 0.03));
3952
+ simulation.force('layerY', d3.forceY(d => allLayerCenters[d.layer]?.y || 0).strength(d => d.layer ? layerStrength : 0.03));
3953
+
3954
+ // Custom clustering force \u2014 surface tension pulling nodes toward their layer centroid
3955
+ function clusterForce() {
3956
+ let nodes;
3957
+ function force(alpha) {
3958
+ const centroids = {};
3959
+ const counts = {};
3960
+ nodes.forEach(n => {
3961
+ if (!n.layer) return;
3962
+ if (!centroids[n.layer]) { centroids[n.layer] = {x: 0, y: 0}; counts[n.layer] = 0; }
3963
+ centroids[n.layer].x += n.x;
3964
+ centroids[n.layer].y += n.y;
3965
+ counts[n.layer]++;
3966
+ });
3967
+ Object.keys(centroids).forEach(k => {
3968
+ centroids[k].x /= counts[k];
3969
+ centroids[k].y /= counts[k];
3970
+ });
3971
+ // Pull each node toward its layer centroid (surface tension)
3972
+ const strength = 0.2;
3973
+ nodes.forEach(n => {
3974
+ if (!n.layer || !centroids[n.layer]) return;
3975
+ n.vx += (centroids[n.layer].x - n.x) * alpha * strength;
3976
+ n.vy += (centroids[n.layer].y - n.y) * alpha * strength;
3977
+ });
3978
+ }
3979
+ force.initialize = (n) => { nodes = n; };
3980
+ return force;
3981
+ }
3982
+ simulation.force('cluster', clusterForce());
3816
3983
 
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
- });
3984
+ // Boost link strength for intra-layer edges (tighter connections within a layer)
3985
+ simulation.force('link').strength(l => {
3986
+ const sLayer = (l.source.layer ?? l.source);
3987
+ const tLayer = (l.target.layer ?? l.target);
3988
+ return sLayer === tLayer ? 0.4 : 0.1;
3833
3989
  });
3834
3990
 
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;};
3991
+ hullGroup = g.insert('g', ':first-child');
3864
3992
 
3865
- // Click on empty space to deselect
3866
- hSvg.on('click',()=>{closeHierDetail();});
3993
+ function updateHulls() {
3994
+ if (!hullGroup) return;
3995
+ hullGroup.selectAll('*').remove();
3996
+ // Show hulls always (filter to selected layers when focused)
3867
3997
 
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)
3998
+ LAYERS.forEach(layer => {
3999
+ if (activeLayers.size > 0 && !activeLayers.has(layer.name)) return;
4000
+ const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
4001
+ if (layerNodes.length === 0) return;
3873
4002
 
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
- }
4003
+ const points = [];
4004
+ layerNodes.forEach(n => {
4005
+ if (n.x == null || n.y == null) return;
4006
+ const r = nodeRadius(n) * nodeScale + 30;
4007
+ // Add expanded points for a nicer hull shape
4008
+ for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
4009
+ points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
4010
+ }
4011
+ });
3881
4012
 
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);
4013
+ if (points.length < 3) {
4014
+ // Fallback: circle for 1-2 nodes
4015
+ const cx = layerNodes.reduce((s, n) => s + (n.x || 0), 0) / layerNodes.length;
4016
+ const cy = layerNodes.reduce((s, n) => s + (n.y || 0), 0) / layerNodes.length;
4017
+ const maxR = Math.max(60, ...layerNodes.map(n => {
4018
+ const dx = (n.x || 0) - cx, dy = (n.y || 0) - cy;
4019
+ return Math.sqrt(dx*dx + dy*dy) + nodeRadius(n) * nodeScale + 30;
4020
+ }));
4021
+ hullGroup.append('circle')
4022
+ .attr('cx', cx).attr('cy', cy).attr('r', maxR)
4023
+ .attr('class', 'layer-hull')
4024
+ .attr('fill', layer.color).attr('stroke', layer.color);
4025
+ hullGroup.append('text')
4026
+ .attr('class', 'layer-hull-label')
4027
+ .attr('x', cx).attr('y', cy - maxR - 8)
4028
+ .attr('text-anchor', 'middle')
4029
+ .attr('fill', layer.color)
4030
+ .text(layer.name);
4031
+ return;
3890
4032
  }
3891
- }
3892
4033
 
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
- });
4034
+ const hull = d3.polygonHull(points);
4035
+ if (!hull) return;
3907
4036
 
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));
4037
+ // Smooth the hull with a cardinal closed curve
4038
+ hullGroup.append('path')
4039
+ .attr('class', 'layer-hull')
4040
+ .attr('d', d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5))(hull))
4041
+ .attr('fill', layer.color).attr('stroke', layer.color);
3912
4042
 
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
- }
4043
+ // Label at the top of the hull
4044
+ const topPt = hull.reduce((best, p) => p[1] < best[1] ? p : best, hull[0]);
4045
+ hullGroup.append('text')
4046
+ .attr('class', 'layer-hull-label')
4047
+ .attr('x', topPt[0]).attr('y', topPt[1] - 10)
4048
+ .attr('text-anchor', 'middle')
4049
+ .attr('fill', layer.color)
4050
+ .text(layer.name);
3924
4051
  });
4052
+ }
3925
4053
 
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
- }
4054
+ // Update hulls + cross-layer links on each tick
4055
+ simulation.on('tick', () => {
4056
+ // Regular links
4057
+ link.each(function(d) {
4058
+ const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
4059
+ const dist=Math.sqrt(dx*dx+dy*dy)||1;
4060
+ const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
4061
+ d3.select(this)
4062
+ .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
4063
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3942
4064
  });
4065
+ node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
4066
+ // Cross-layer links \u2014 resolve node positions by ID
4067
+ if (crossLinkData.length > 0) {
4068
+ const nodeById = {};
4069
+ DATA.nodes.forEach(n => { nodeById[n.id] = n; });
4070
+ crossLink.each(function(d) {
4071
+ const sN = nodeById[d.source], tN = nodeById[d.target];
4072
+ if (!sN || !tN) return;
4073
+ const dx = tN.x - sN.x, dy = tN.y - sN.y;
4074
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
4075
+ const rS = nodeRadius(sN) * nodeScale, rT = nodeRadius(tN) * nodeScale;
4076
+ d3.select(this)
4077
+ .attr('x1', sN.x + (dx/dist)*rS).attr('y1', sN.y + (dy/dist)*rS)
4078
+ .attr('x2', tN.x - (dx/dist)*rT).attr('y2', tN.y - (dy/dist)*rT);
4079
+ });
4080
+ crossLabel.each(function(d) {
4081
+ const sN = nodeById[d.source], tN = nodeById[d.target];
4082
+ if (!sN || !tN) return;
4083
+ d3.select(this).attr('x', (sN.x + tN.x) / 2).attr('y', (sN.y + tN.y) / 2 - 6);
4084
+ });
4085
+ }
4086
+ updateHulls();
4087
+ });
3943
4088
 
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');
4089
+ // \u2500\u2500\u2500 Layer legend \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4090
+ const layerLegend = document.getElementById('layer-legend');
4091
+ LAYERS.forEach(layer => {
4092
+ const item = document.createElement('div');
4093
+ item.className = 'legend-item';
4094
+ item.innerHTML = '<div class="legend-dot" style="background:' + esc(layer.color) + '"></div> ' + esc(layer.name);
4095
+ layerLegend.appendChild(item);
4096
+ });
4097
+ // Cross-layer edge legend
4098
+ if (CROSS_EDGES && CROSS_EDGES.length > 0) {
4099
+ const crossItem = document.createElement('div');
4100
+ crossItem.className = 'legend-item';
4101
+ crossItem.innerHTML = '<span style="color:#f0883e;font-size:11px">- - \u2192</span> Cross-layer link';
4102
+ layerLegend.appendChild(crossItem);
4103
+ }
4104
+ // Add separator
4105
+ const sep = document.createElement('hr');
4106
+ sep.style.cssText = 'border:none;border-top:1px solid var(--border);margin:6px 0;';
4107
+ layerLegend.appendChild(sep);
4108
+
4109
+ // \u2500\u2500\u2500 Layer tabs (multi-select toggles in tab bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4110
+ const layerTabsEl = document.getElementById('layer-tabs');
4111
+ const allTab = document.createElement('div');
4112
+ allTab.className = 'layer-tab active';
4113
+ allTab.textContent = 'All';
4114
+ allTab.onclick = () => {
4115
+ activeLayers.clear();
4116
+ syncLayerTabUI();
4117
+ applyLayerFilter();
4118
+ if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
4119
+ };
4120
+ layerTabsEl.appendChild(allTab);
4121
+
4122
+ LAYERS.forEach(layer => {
4123
+ const tab = document.createElement('div');
4124
+ tab.className = 'layer-tab';
4125
+ tab.dataset.layer = layer.name;
4126
+ tab.innerHTML = '<div class="lt-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
4127
+ tab.onclick = (e) => {
4128
+ if (e.shiftKey) {
4129
+ // Shift+click: solo this layer
4130
+ activeLayers.clear();
4131
+ activeLayers.add(layer.name);
3951
4132
  } else {
3952
- el.attr('display', null)
3953
- .transition().duration(300)
3954
- .attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
4133
+ // Toggle
4134
+ if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
4135
+ else activeLayers.add(layer.name);
3955
4136
  }
3956
- });
4137
+ syncLayerTabUI();
4138
+ applyLayerFilter();
4139
+ if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
4140
+ };
4141
+ layerTabsEl.appendChild(tab);
4142
+ });
3957
4143
 
3958
- // Close detail panel if pinned node became hidden
3959
- if (hierPinned && !isVisible(hierPinned)) {
3960
- closeHierDetail();
3961
- }
4144
+ function syncLayerTabUI() {
4145
+ allTab.classList.toggle('active', activeLayers.size === 0);
4146
+ layerTabsEl.querySelectorAll('.layer-tab[data-layer]').forEach(t => {
4147
+ t.classList.toggle('active', activeLayers.has(t.dataset.layer));
4148
+ });
4149
+ // Also sync the filter bar layer pills
4150
+ layerRowEl.querySelectorAll('.layer-pill[data-layer]').forEach(p => {
4151
+ p.classList.toggle('active', activeLayers.has(p.dataset.layer));
4152
+ });
3962
4153
  }
3963
4154
 
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);
4155
+ applyLayerFilter = function() {
4156
+ const isSingleLayer = activeLayers.size === 1;
4157
+ const hasLayerFilter = activeLayers.size > 0;
4158
+ node.attr('display', d => {
4159
+ if (!activeDirs.has(d.dir)) return 'none';
4160
+ if (hasLayerFilter && !activeLayers.has(d.layer)) return 'none';
4161
+ return null;
4162
+ });
4163
+ link.attr('display', l => {
4164
+ const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
4165
+ const sN = DATA.nodes.find(n => n.id === s), tN = DATA.nodes.find(n => n.id === t);
4166
+ if (!sN || !tN) return 'none';
4167
+ if (!activeDirs.has(sN.dir) || !activeDirs.has(tN.dir)) return 'none';
4168
+ if (hasLayerFilter && (!activeLayers.has(sN.layer) || !activeLayers.has(tN.layer))) return 'none';
4169
+ return null;
4170
+ });
4171
+ // Refresh node colors: single-layer = dir-based, multi-layer = layer-based
4172
+ node.select('circle')
4173
+ .attr('fill', nodeColor)
4174
+ .attr('stroke', d => d.deps >= 5 ? 'var(--yellow)' : nodeColor(d));
4175
+ // Cross-layer links: respect user toggle + layer filter
4176
+ if (typeof crossLink !== 'undefined') {
4177
+ if (!crossLinksUserEnabled || isSingleLayer) {
4178
+ crossLink.attr('display', 'none');
4179
+ crossLabel.attr('display', 'none');
4180
+ } else if (hasLayerFilter) {
4181
+ crossLink.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
4182
+ crossLabel.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
3973
4183
  } else {
3974
- p.classList.toggle('active', hActiveLayers.has(ln));
4184
+ crossLink.attr('display', null);
4185
+ crossLabel.attr('display', null);
3975
4186
  }
4187
+ }
4188
+ // Update stats
4189
+ const visibleNodes = DATA.nodes.filter(d => {
4190
+ if (!activeDirs.has(d.dir)) return false;
4191
+ if (hasLayerFilter && !activeLayers.has(d.layer)) return false;
4192
+ return true;
4193
+ });
4194
+ const visibleIds = new Set(visibleNodes.map(n => n.id));
4195
+ const visibleEdges = DATA.links.filter(l => {
4196
+ const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
4197
+ return visibleIds.has(s) && visibleIds.has(t);
3976
4198
  });
4199
+ document.getElementById('s-files').textContent = visibleNodes.length;
4200
+ document.getElementById('s-edges').textContent = visibleEdges.length;
4201
+ const visCirc = DATA.circularFiles.filter(f => visibleIds.has(f));
4202
+ document.getElementById('s-circular').textContent = visCirc.length;
4203
+ updateHulls();
4204
+ // Delegate physics update and zoom to fit
4205
+ updateLayerPhysics();
4206
+ simulation.alpha(0.6).restart();
4207
+ setTimeout(() => zoomFit(), 600);
3977
4208
  }
3978
4209
 
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
- });
4210
+ // Separated physics update: handles charge/layer forces based on filter state.
4211
+ // Called by applyLayerFilter (with zoomFit), setGravity, setLayerGravity (without zoomFit).
4212
+ updateLayerPhysics = function() {
4213
+ const isSingleLayer = activeLayers.size === 1;
4214
+ const lStrength = layerGravity / 100;
4215
+ if (isSingleLayer) {
4216
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength * 3).distanceMax(800));
4217
+ simulation.force('layerX', d3.forceX(0).strength(0.03));
4218
+ simulation.force('layerY', d3.forceY(0).strength(0.03));
4219
+ } else {
4220
+ const centers = getLayerCenters();
4221
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
4222
+ simulation.force('layerX', d3.forceX(d => centers[d.layer]?.x || 0).strength(d => d.layer ? lStrength : 0.03));
4223
+ simulation.force('layerY', d3.forceY(d => centers[d.layer]?.y || 0).strength(d => d.layer ? lStrength : 0.03));
4224
+ }
4031
4225
  }
4032
4226
 
4033
- // Assign function pointers for cross-view sync
4034
- hierRelayout = hierRelayoutInner;
4035
- hierSyncFromTab = hierSyncFromTabInner;
4227
+ // \u2500\u2500\u2500 Layer filter pills (new grouped bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4228
+ const layerRowEl = document.getElementById('filter-layer-row');
4229
+ const dirPanelEl = document.getElementById('filter-dir-panel');
4036
4230
 
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))));
4231
+ // Dir toggle button
4232
+ const dirToggle = document.createElement('div');
4233
+ dirToggle.id = 'filter-dir-toggle';
4234
+ dirToggle.textContent = '\u25B8 Dirs';
4235
+ dirToggle.onclick = () => {
4236
+ dirToggle.classList.toggle('open');
4237
+ dirPanelEl.classList.toggle('open');
4238
+ dirToggle.textContent = dirPanelEl.classList.contains('open') ? '\u25BE Dirs' : '\u25B8 Dirs';
4239
+ };
4240
+ layerRowEl.appendChild(dirToggle);
4040
4241
 
4041
- // If a layer tab was already selected, sync hierarchy on first build
4042
- if (activeLayerFilter) {
4043
- hierSyncFromTabInner(activeLayerFilter);
4044
- hierRelayoutInner();
4242
+ // Cross-layer link toggle (in settings sidebar)
4243
+ let crossLinksUserEnabled = true;
4244
+ if (crossLinkData.length > 0) {
4245
+ document.getElementById('cross-layer-setting').style.display = '';
4246
+ window.toggleCrossLinks = () => {
4247
+ crossLinksUserEnabled = !crossLinksUserEnabled;
4248
+ const btn = document.getElementById('cross-link-toggle');
4249
+ btn.textContent = crossLinksUserEnabled ? 'ON' : 'OFF';
4250
+ btn.classList.toggle('active', crossLinksUserEnabled);
4251
+ applyLayerFilter();
4252
+ };
4045
4253
  }
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
4254
 
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);
4255
+ LAYERS.forEach(layer => {
4256
+ const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
4257
+ const pill = document.createElement('div');
4258
+ pill.className = 'layer-pill';
4259
+ pill.dataset.layer = layer.name;
4260
+ pill.innerHTML = '<div class="lp-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name) + ' <span class="lp-count">' + layerNodes.length + '</span>';
4261
+ pill.onclick = () => {
4262
+ if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
4263
+ else activeLayers.add(layer.name);
4264
+ syncLayerTabUI();
4265
+ applyLayerFilter();
4266
+ };
4267
+ pill.onmouseenter = () => {
4268
+ if (pinnedNode) return;
4269
+ node.select('circle').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.1);
4270
+ node.select('text').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.05);
4271
+ };
4272
+ pill.onmouseleave = () => {
4273
+ if (pinnedNode) return;
4274
+ node.select('circle').transition().duration(150).attr('opacity', 1);
4275
+ node.select('text').transition().duration(150).attr('opacity', d => d.dependents >= 1 || d.deps >= 3 ? 1 : 0.5);
4276
+ };
4277
+ layerRowEl.appendChild(pill);
4065
4278
 
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';
4279
+ // Build dir group in panel for this layer
4280
+ const layerDirs = [...new Set(layerNodes.map(n => n.dir))].sort();
4281
+ if (layerDirs.length > 0) {
4282
+ const group = document.createElement('div');
4283
+ group.className = 'dir-group';
4284
+ const label = document.createElement('div');
4285
+ label.className = 'dir-group-label';
4286
+ label.innerHTML = '<div class="dg-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
4287
+ group.appendChild(label);
4288
+ const pillsWrap = document.createElement('div');
4289
+ pillsWrap.className = 'dir-group-pills';
4290
+ layerDirs.forEach(dir => {
4291
+ const dp = document.createElement('div');
4292
+ dp.className = 'filter-pill active';
4293
+ const shortDir = dir.includes('/') ? dir.substring(dir.indexOf('/') + 1) : dir;
4294
+ dp.innerHTML = '<div class="pill-dot" style="background:' + dirColor(dir) + '"></div>' + esc(shortDir || '.') + ' <span class="pill-count">' + (dirCounts[dir] || 0) + '</span>';
4295
+ dp.onclick = () => {
4296
+ if (activeDirs.has(dir)) { activeDirs.delete(dir); dp.classList.remove('active'); }
4297
+ else { activeDirs.add(dir); dp.classList.add('active'); }
4298
+ applyLayerFilter();
4299
+ };
4300
+ pillsWrap.appendChild(dp);
4301
+ });
4302
+ group.appendChild(pillsWrap);
4303
+ dirPanelEl.appendChild(group);
4072
4304
  }
4305
+ });
4073
4306
 
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}));
4081
-
4082
- const dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
4083
- .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
4307
+ // Override applyFilter to respect layers
4308
+ window._origApplyFilter = applyFilter;
4309
+ }
4084
4310
 
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');
4311
+ setTimeout(()=>zoomFit(), 1500);
4097
4312
 
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));
4313
+ // Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
4314
+ if (_savedSettings) {
4315
+ if (_savedSettings.fontSize) setFontSize(_savedSettings.fontSize);
4316
+ }
4103
4317
 
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
- })());
4318
+ // \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
4319
+ let pinnedNode = null;
4141
4320
 
4142
- dHullGroup = dG.insert('g', ':first-child');
4143
- }
4321
+ function highlightNode(d) {
4322
+ const conn = new Set([d.id]);
4323
+ 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); });
4324
+ node.select('circle').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.1);
4325
+ node.select('text').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.05);
4326
+ link.transition().duration(150)
4327
+ .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;})
4328
+ .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';})
4329
+ .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;})
4330
+ .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)';});
4331
+ }
4144
4332
 
4145
- function isDiffNode(id) {
4146
- return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
4147
- }
4333
+ function resetGraphHighlight() {
4334
+ pinnedNode = null;
4335
+ node.select('circle').transition().duration(200).attr('opacity',1);
4336
+ node.select('text').transition().duration(200).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
4337
+ link.transition().duration(200)
4338
+ .attr('opacity',baseLinkOpacity)
4339
+ .attr('stroke',d=>d.type==='type-only'?'#1f3d5c':'#30363d')
4340
+ .attr('stroke-width',1).attr('marker-end','url(#arrow-0)');
4341
+ }
4148
4342
 
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); });
4343
+ // \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
4344
+ node.on('mouseover', (e,d) => {
4345
+ showTooltip(e,d);
4346
+ if (!pinnedNode) highlightNode(d);
4347
+ })
4348
+ .on('mousemove', e=>positionTooltip(e))
4349
+ .on('mouseout', () => { scheduleHideTooltip(); if (!pinnedNode) { /* highlight resets via scheduleHideTooltip */ } });
4156
4350
 
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
- });
4351
+ // \u2500\u2500\u2500 Click: pin highlight + detail panel \u2500\u2500\u2500\u2500\u2500
4352
+ node.on('click', (e,d) => {
4353
+ e.stopPropagation();
4354
+ pinnedNode = d;
4355
+ highlightNode(d);
4356
+ showDetail(d);
4357
+ });
4358
+ svg.on('click', () => {
4359
+ resetGraphHighlight();
4360
+ tooltip.style.display = 'none';
4361
+ tooltipLocked = false;
4362
+ closeDetail();
4363
+ });
4165
4364
 
4166
- var fillOp = hasDiff ? 0.15 : 0.06;
4167
- var strokeOp = hasDiff ? 0.6 : 0.2;
4168
- var sw = hasDiff ? 2.5 : 1;
4169
- if (points.length < 6) {
4170
- var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4171
- var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
4172
- dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
4173
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
4174
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
4175
- } else {
4176
- var hull = d3.polygonHull(points);
4177
- if (hull) {
4178
- dHullGroup.append('path')
4179
- .attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
4180
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
4181
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
4182
- .attr('stroke-dasharray', hasDiff ? null : '6,3');
4183
- }
4184
- }
4185
- // Layer name label
4186
- var lx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
4187
- var ly = Math.min.apply(null, layerNodes.map(function(n) { return n.y||0; })) - 25;
4188
- dHullGroup.append('text')
4189
- .attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
4190
- .attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
4191
- .attr('font-size', 12).attr('font-weight', 600).text(layer.name);
4192
- });
4193
- }
4365
+ function showDetail(d) {
4366
+ const p=document.getElementById('detail');
4367
+ document.getElementById('d-name').textContent=d.id;
4368
+ document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+esc(d.dir)+'<br>'+i('detail.dependencies')+': '+d.deps+' \\u00b7 '+i('detail.dependents')+': '+d.dependents;
4369
+ const deptL=document.getElementById('d-dependents'), depsL=document.getElementById('d-deps');
4370
+ deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
4371
+ depsL.innerHTML=(d.dependencies||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
4372
+ p.classList.add('open');
4373
+ }
4374
+ // Event delegation for detail panel list items (avoids inline onclick)
4375
+ document.getElementById('d-dependents').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
4376
+ document.getElementById('d-deps').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
4377
+ window.closeDetail=()=>document.getElementById('detail').classList.remove('open');
4378
+ window.focusNode=(id)=>{
4379
+ const n=DATA.nodes.find(x=>x.id===id); if(!n)return; showDetail(n);
4380
+ 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));
4381
+ };
4194
4382
 
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
- });
4383
+ // Drag
4384
+ function dragStart(e,d){if(!e.active)simulation.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}
4385
+ function dragging(e,d){d.fx=e.x;d.fy=e.y;}
4386
+ function dragEnd(e,d){if(!e.active)simulation.alphaTarget(0);}
4206
4387
 
4207
- dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
4388
+ // \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
4389
+ const searchInput=document.getElementById('search');
4390
+ document.addEventListener('keydown',e=>{
4391
+ if(e.key==='/'&&document.activeElement!==searchInput){e.preventDefault();searchInput.focus();}
4392
+ if(e.key==='Escape'){searchInput.value='';searchInput.blur();resetGraphHighlight();}
4393
+ });
4394
+ searchInput.addEventListener('input',e=>{
4395
+ const q=e.target.value.toLowerCase();
4396
+ if(!q){resetGraphHighlight();return;}
4397
+ node.select('circle').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.06);
4398
+ node.select('text').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.04);
4399
+ link.attr('opacity',0.03);
4400
+ });
4208
4401
 
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);
4402
+ // \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
4403
+ if (!LAYERS) {
4404
+ // Non-layer mode: flat pills in filter-layer-row
4405
+ const filterRowEl=document.getElementById('filter-layer-row');
4406
+ DATA.dirs.forEach(dir=>{
4407
+ const pill=document.createElement('div');
4408
+ pill.className='filter-pill active';
4409
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
4410
+ pill.onclick=()=>{
4411
+ if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
4412
+ else{activeDirs.add(dir);pill.classList.add('active');}
4413
+ applyFilter();
4414
+ };
4415
+ pill.onmouseenter=()=>{
4416
+ if(pinnedNode)return;
4417
+ node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
4418
+ node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
4419
+ };
4420
+ pill.onmouseleave=()=>{
4421
+ if(pinnedNode)return;
4422
+ node.select('circle').transition().duration(150).attr('opacity',1);
4423
+ node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
4424
+ };
4425
+ filterRowEl.appendChild(pill);
4426
+ });
4427
+ }
4428
+ function applyFilter(){
4429
+ if (LAYERS) {
4430
+ // Delegate to layer-aware filter
4431
+ if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
4214
4432
  }
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
- });
4433
+ node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
4434
+ link.attr('display',l=>{
4435
+ const s=l.source.id??l.source,t=l.target.id??l.target;
4436
+ const sD=DATA.nodes.find(n=>n.id===s)?.dir,tD=DATA.nodes.find(n=>n.id===t)?.dir;
4437
+ return activeDirs.has(sD)&&activeDirs.has(tD)?null:'none';
4222
4438
  });
4223
4439
  }
4224
4440
 
4441
+ // \u2500\u2500\u2500 Impact simulation mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4442
+ let impactMode=false;
4443
+ const impactBadge=document.getElementById('impact-badge');
4444
+ window.toggleImpactMode=()=>{
4445
+ impactMode=!impactMode;
4446
+ document.getElementById('impact-btn').classList.toggle('active',impactMode);
4447
+ if(!impactMode){impactBadge.style.display='none';resetGraphHighlight();}
4448
+ };
4449
+ function getTransitiveDependents(startId){
4450
+ const result=new Set();const queue=[startId];
4451
+ const revMap={};
4452
+ 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);});
4453
+ while(queue.length){const id=queue.shift();if(result.has(id))continue;result.add(id);(revMap[id]||[]).forEach(x=>queue.push(x));}
4454
+ return result;
4455
+ }
4456
+ // Override click in impact mode
4457
+ const origClick=node.on('click');
4458
+ node.on('click',(e,d)=>{
4459
+ if(!impactMode){e.stopPropagation();pinnedNode=d;highlightNode(d);showDetail(d);return;}
4460
+ e.stopPropagation();
4461
+ const affected=getTransitiveDependents(d.id);
4462
+ node.select('circle').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.06)
4463
+ .attr('stroke',n=>affected.has(n.id)&&n.id!==d.id?'var(--red)':n.deps>=5?'var(--yellow)':nodeColor(n))
4464
+ .attr('stroke-width',n=>affected.has(n.id)?3:1.5);
4465
+ node.select('text').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.04);
4466
+ link.transition().duration(200).attr('opacity',l=>{
4467
+ const s=l.source.id??l.source,t=l.target.id??l.target;
4468
+ return affected.has(s)&&affected.has(t)?0.8:0.03;
4469
+ }).attr('stroke',l=>{
4470
+ const s=l.source.id??l.source,t=l.target.id??l.target;
4471
+ return affected.has(s)&&affected.has(t)?'var(--red)':l.type==='type-only'?'#1f3d5c':'#30363d';
4472
+ });
4473
+ impactBadge.textContent=d.id.split('/').pop()+' \u2192 '+(affected.size-1)+' '+i('impact.transitive');
4474
+ impactBadge.style.display='block';
4475
+ });
4476
+
4477
+ window.addEventListener('resize',()=>{
4478
+ const w=window.innerWidth,h=window.innerHeight-44;
4479
+ svg.attr('width',w).attr('height',h);
4480
+ });
4481
+
4482
+ ${buildHierarchyJs()}
4483
+ ${buildDiffJs(diffData)}
4225
4484
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
4226
4485
  // INIT
4227
4486
  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
@@ -4279,36 +4538,13 @@ var VERSION = loadVersion();
4279
4538
 
4280
4539
  // src/cli/index.ts
4281
4540
  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, {
4541
+ async function resolveGraphCli(opts) {
4542
+ return resolveGraph({
4543
+ targetDir: opts.target,
4544
+ projectRoot: opts.root,
4308
4545
  exclude: opts.exclude,
4309
4546
  language: opts.language
4310
4547
  });
4311
- return { graph };
4312
4548
  }
4313
4549
  var program = new Command();
4314
4550
  program.name("archtracker").description(
@@ -4326,7 +4562,7 @@ program.command("init").description("Generate initial snapshot and save to .arch
4326
4562
  try {
4327
4563
  const language = validateLanguage(opts.language);
4328
4564
  console.log(t("cli.analyzing"));
4329
- const { graph, multiLayer } = await resolveGraph({
4565
+ const { graph, multiLayer } = await resolveGraphCli({
4330
4566
  target: opts.target,
4331
4567
  root: opts.root,
4332
4568
  exclude: opts.exclude,
@@ -4363,7 +4599,7 @@ program.command("analyze").description(
4363
4599
  try {
4364
4600
  const language = validateLanguage(opts.language);
4365
4601
  console.log(t("cli.analyzing"));
4366
- const { graph, multiLayer } = await resolveGraph({
4602
+ const { graph, multiLayer } = await resolveGraphCli({
4367
4603
  target: opts.target,
4368
4604
  root: opts.root,
4369
4605
  exclude: opts.exclude,
@@ -4390,7 +4626,7 @@ program.command("check").description(
4390
4626
  process.exit(1);
4391
4627
  }
4392
4628
  console.log(t("cli.analyzing"));
4393
- const { graph: currentGraph } = await resolveGraph({
4629
+ const { graph: currentGraph } = await resolveGraphCli({
4394
4630
  target: opts.target,
4395
4631
  root: opts.root,
4396
4632
  language
@@ -4414,7 +4650,7 @@ program.command("context").description(
4414
4650
  let snapshot = await loadSnapshot(opts.root);
4415
4651
  if (!snapshot) {
4416
4652
  console.log(t("cli.autoGenerating"));
4417
- const result = await resolveGraph({
4653
+ const result = await resolveGraphCli({
4418
4654
  target: opts.target,
4419
4655
  root: opts.root,
4420
4656
  language
@@ -4462,7 +4698,7 @@ program.command("serve").description(
4462
4698
  console.log(t("web.starting"));
4463
4699
  console.log(t("cli.analyzing"));
4464
4700
  let diff = null;
4465
- const result = await resolveGraph({
4701
+ const result = await resolveGraphCli({
4466
4702
  target: opts.target,
4467
4703
  root: opts.root,
4468
4704
  exclude: opts.exclude,
@@ -4489,7 +4725,7 @@ program.command("serve").description(
4489
4725
  debounce = setTimeout(async () => {
4490
4726
  try {
4491
4727
  console.log(t("web.reloading"));
4492
- const newResult = await resolveGraph({
4728
+ const newResult = await resolveGraphCli({
4493
4729
  target: opts.target,
4494
4730
  root: opts.root,
4495
4731
  exclude: opts.exclude,