ai-spector 0.3.6 → 0.3.7

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.
Files changed (35) hide show
  1. package/dist/commands/prototype.d.ts.map +1 -1
  2. package/dist/commands/prototype.js +4 -1
  3. package/dist/commands/prototype.js.map +1 -1
  4. package/dist/graph/doc-extract.d.ts.map +1 -1
  5. package/dist/graph/doc-extract.js +115 -102
  6. package/dist/graph/doc-extract.js.map +1 -1
  7. package/dist/markdown/parse.d.ts +25 -0
  8. package/dist/markdown/parse.d.ts.map +1 -0
  9. package/dist/markdown/parse.js +100 -0
  10. package/dist/markdown/parse.js.map +1 -0
  11. package/dist/prototype/config.d.ts +2 -0
  12. package/dist/prototype/config.d.ts.map +1 -1
  13. package/dist/prototype/config.js +35 -6
  14. package/dist/prototype/config.js.map +1 -1
  15. package/dist/types.d.ts +1 -1
  16. package/dist/types.d.ts.map +1 -1
  17. package/dist/visualize/html.d.ts.map +1 -1
  18. package/dist/visualize/html.js +492 -282
  19. package/dist/visualize/html.js.map +1 -1
  20. package/package.json +7 -2
  21. package/scaffold/.ai-spector/.docflow/config/prototype.config.json +1 -2
  22. package/scaffold/cursor/WORKFLOW.md +2 -2
  23. package/scaffold/cursor/commands/_workflow.md +3 -3
  24. package/scaffold/cursor/mcp.json +1 -15
  25. package/scaffold/cursor/skills/ai-spector/references/cli-failures.md +3 -32
  26. package/scaffold/cursor/skills/ai-spector/references/generate-graph.md +49 -70
  27. package/scaffold/cursor/skills/ai-spector/references/generate-workflow.md +1 -1
  28. package/scaffold/cursor/skills/ai-spector/references/graph.md +0 -1
  29. package/scaffold/cursor/skills/ai-spector-generate-prototype/SKILL.md +6 -5
  30. package/scaffold/cursor/skills/ai-spector-generate-prototype/references/runbook.md +30 -10
  31. package/scaffold/cursor/skills/ai-spector-generate-srs/references/runbook.md +1 -1
  32. package/scaffold/cursor/skills/ai-spector-graph/references/analyze.md +18 -44
  33. package/scaffold/cursor/skills/ai-spector-graph/references/graph-commands.md +1 -4
  34. package/scaffold/cursor/skills/ai-spector-graph/references/index.md +6 -14
  35. package/scaffold/prototype/README.md +4 -2
@@ -18,6 +18,8 @@ export function buildVisualizationHtml(payload) {
18
18
  --text: #e7ecf3;
19
19
  --muted: #8b9cb3;
20
20
  --accent: #3b82f6;
21
+ --green: #22c55e;
22
+ --red: #f87171;
21
23
  }
22
24
  * { box-sizing: border-box; }
23
25
  body {
@@ -28,14 +30,14 @@ export function buildVisualizationHtml(payload) {
28
30
  min-height: 100vh;
29
31
  }
30
32
  header {
31
- padding: 1rem 1.25rem;
33
+ padding: 0.75rem 1.25rem;
32
34
  border-bottom: 1px solid var(--border);
33
35
  display: flex;
34
36
  flex-wrap: wrap;
35
- gap: 0.75rem 1.5rem;
37
+ gap: 0.5rem 1.5rem;
36
38
  align-items: baseline;
37
39
  }
38
- header h1 { margin: 0; font-size: 1.15rem; font-weight: 600; }
40
+ header h1 { margin: 0; font-size: 1.05rem; font-weight: 600; }
39
41
  header .meta { color: var(--muted); font-size: 0.8rem; }
40
42
  nav.tabs {
41
43
  display: flex;
@@ -47,107 +49,162 @@ export function buildVisualizationHtml(payload) {
47
49
  background: transparent;
48
50
  border: none;
49
51
  color: var(--muted);
50
- padding: 0.6rem 1rem;
52
+ padding: 0.5rem 1rem;
51
53
  cursor: pointer;
52
- font-size: 0.9rem;
54
+ font-size: 0.875rem;
53
55
  border-bottom: 2px solid transparent;
54
56
  margin-bottom: -1px;
55
57
  }
56
- nav.tabs button.active {
57
- color: var(--text);
58
- border-bottom-color: var(--accent);
59
- }
58
+ nav.tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
60
59
  .panel { display: none; padding: 1rem 1.25rem 1.5rem; }
61
60
  .panel.active { display: block; }
61
+
62
+ /* Overview */
62
63
  .stats-grid {
63
64
  display: grid;
64
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
65
- gap: 0.75rem;
65
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
66
+ gap: 0.6rem;
66
67
  margin-bottom: 1rem;
67
68
  }
68
69
  .stat-card {
69
70
  background: var(--panel);
70
71
  border: 1px solid var(--border);
71
72
  border-radius: 8px;
72
- padding: 0.75rem 1rem;
73
+ padding: 0.65rem 0.9rem;
74
+ }
75
+ .stat-card .label { font-size: 0.72rem; color: var(--muted); }
76
+ .stat-card .value { font-size: 1.3rem; font-weight: 600; margin-top: 0.15rem; }
77
+ .section-title { font-size: 0.9rem; margin: 1.1rem 0 0.45rem; color: var(--muted); font-weight: 500; }
78
+
79
+ /* Graph panel */
80
+ .graph-layout {
81
+ display: grid;
82
+ grid-template-columns: 1fr 300px;
83
+ gap: 0.75rem;
84
+ align-items: start;
73
85
  }
74
- .stat-card .label { font-size: 0.75rem; color: var(--muted); }
75
- .stat-card .value { font-size: 1.35rem; font-weight: 600; margin-top: 0.2rem; }
86
+ @media (max-width: 900px) { .graph-layout { grid-template-columns: 1fr; } }
87
+ .graph-main {}
76
88
  .graph-toolbar {
77
89
  display: flex;
78
90
  flex-wrap: wrap;
79
- gap: 0.75rem;
91
+ gap: 0.5rem 0.75rem;
80
92
  align-items: center;
81
- margin-bottom: 0.75rem;
93
+ margin-bottom: 0.6rem;
94
+ }
95
+ .graph-toolbar label { font-size: 0.82rem; color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
96
+ .graph-toolbar select, .graph-toolbar input[type=search], .graph-toolbar input[type=text] {
97
+ background: var(--panel);
98
+ border: 1px solid var(--border);
99
+ color: var(--text);
100
+ padding: 0.3rem 0.55rem;
101
+ border-radius: 6px;
102
+ font-size: 0.82rem;
82
103
  }
83
- .graph-toolbar label { font-size: 0.85rem; color: var(--muted); display: flex; align-items: center; gap: 0.4rem; }
84
- .graph-toolbar select, .graph-toolbar input {
104
+ .toolbar-btn {
85
105
  background: var(--panel);
86
106
  border: 1px solid var(--border);
87
107
  color: var(--text);
88
- padding: 0.35rem 0.6rem;
108
+ padding: 0.3rem 0.65rem;
89
109
  border-radius: 6px;
90
- font-size: 0.85rem;
110
+ font-size: 0.82rem;
111
+ cursor: pointer;
91
112
  }
113
+ .toolbar-btn:hover { border-color: var(--accent); }
114
+ .toolbar-btn.active { border-color: var(--accent); color: var(--accent); }
92
115
  #graph-network {
93
116
  width: 100%;
94
- height: min(70vh, 640px);
117
+ height: min(68vh, 620px);
95
118
  border: 1px solid var(--border);
96
119
  border-radius: 8px;
97
120
  background: #121820;
98
121
  }
99
- #node-detail {
100
- margin-top: 0.75rem;
101
- padding: 0.75rem 1rem;
122
+ .legend {
123
+ display: flex;
124
+ flex-wrap: wrap;
125
+ gap: 0.4rem 0.9rem;
126
+ margin-top: 0.5rem;
127
+ font-size: 0.72rem;
128
+ color: var(--muted);
129
+ }
130
+ .legend span { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; }
131
+ .legend span:hover { color: var(--text); }
132
+ .legend i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
133
+
134
+ /* Side panel / node detail */
135
+ .side-panel {
102
136
  background: var(--panel);
103
137
  border: 1px solid var(--border);
104
138
  border-radius: 8px;
105
- font-size: 0.85rem;
106
- min-height: 4rem;
107
- white-space: pre-wrap;
108
- font-family: ui-monospace, monospace;
139
+ padding: 0.75rem;
140
+ font-size: 0.82rem;
141
+ min-height: 200px;
142
+ max-height: min(68vh, 620px);
143
+ overflow-y: auto;
109
144
  }
110
- .legend {
145
+ .side-panel .node-id { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; font-family: ui-monospace, monospace; word-break: break-all; }
146
+ .side-panel .node-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
147
+ .side-panel .node-type { display: inline-block; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.7rem; margin-bottom: 0.6rem; }
148
+ .side-panel .edge-section { margin-top: 0.6rem; }
149
+ .side-panel .edge-section-title { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem; }
150
+ .side-panel .edge-group { margin-bottom: 0.4rem; }
151
+ .side-panel .edge-type-label { font-size: 0.72rem; color: var(--muted); margin-bottom: 0.15rem; }
152
+ .side-panel .edge-item {
111
153
  display: flex;
112
- flex-wrap: wrap;
113
- gap: 0.5rem 1rem;
114
- margin-top: 0.75rem;
115
- font-size: 0.75rem;
116
- }
117
- .legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
118
- .legend i {
119
- width: 10px;
120
- height: 10px;
121
- border-radius: 50%;
122
- display: inline-block;
154
+ align-items: center;
155
+ gap: 0.4rem;
156
+ padding: 0.2rem 0;
157
+ cursor: pointer;
158
+ border-radius: 4px;
123
159
  }
124
- table.data {
125
- width: 100%;
126
- border-collapse: collapse;
127
- font-size: 0.85rem;
160
+ .side-panel .edge-item:hover { background: rgba(59,130,246,0.1); }
161
+ .side-panel .edge-item .ei-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
162
+ .side-panel .edge-item .ei-label { font-size: 0.78rem; color: var(--text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
163
+ .side-panel .edge-item .ei-type { font-size: 0.68rem; color: var(--muted); flex-shrink: 0; }
164
+ .side-panel .props { margin-top: 0.5rem; }
165
+ .side-panel .prop-row { display: flex; gap: 0.5rem; padding: 0.15rem 0; border-bottom: 1px solid var(--border); font-size: 0.78rem; }
166
+ .side-panel .prop-key { color: var(--muted); flex-shrink: 0; min-width: 80px; }
167
+ .side-panel .prop-val { color: var(--text); word-break: break-word; }
168
+ .side-panel .hint { color: var(--muted); font-style: italic; }
169
+ #focus-btn { display: none; }
170
+
171
+ /* Edge filter */
172
+ .edge-filter-bar {
173
+ display: flex;
174
+ flex-wrap: wrap;
175
+ gap: 0.3rem 0.5rem;
176
+ margin-bottom: 0.6rem;
128
177
  }
129
- table.data th, table.data td {
130
- text-align: left;
131
- padding: 0.5rem 0.75rem;
132
- border-bottom: 1px solid var(--border);
178
+ .ef-chip {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 0.25rem;
182
+ padding: 0.18rem 0.5rem;
183
+ border-radius: 20px;
184
+ font-size: 0.72rem;
185
+ cursor: pointer;
186
+ border: 1px solid var(--border);
187
+ background: var(--bg);
188
+ color: var(--muted);
189
+ transition: all 0.1s;
133
190
  }
191
+ .ef-chip.on { background: var(--panel); color: var(--text); border-color: var(--accent); }
192
+ .ef-chip i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); display: inline-block; }
193
+
194
+ /* Tables */
195
+ table.data { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
196
+ table.data th, table.data td { text-align: left; padding: 0.45rem 0.65rem; border-bottom: 1px solid var(--border); }
134
197
  table.data th { color: var(--muted); font-weight: 500; }
135
198
  table.data tr:hover td { background: var(--panel); }
136
- .empty { color: var(--muted); font-style: italic; padding: 1rem 0; }
137
- .section-title { font-size: 1rem; margin: 1.25rem 0 0.5rem; color: var(--muted); }
138
- .badge {
139
- display: inline-block;
140
- padding: 0.15rem 0.45rem;
141
- border-radius: 4px;
142
- font-size: 0.7rem;
143
- background: var(--border);
144
- margin-left: 0.35rem;
145
- }
199
+ .empty { color: var(--muted); font-style: italic; padding: 0.75rem 0; }
200
+ .badge { display: inline-block; padding: 0.12rem 0.4rem; border-radius: 4px; font-size: 0.68rem; background: var(--border); margin-left: 0.3rem; }
201
+ .status-ok { color: var(--green); }
202
+ .status-miss { color: var(--red); }
146
203
  </style>
147
204
  </head>
148
205
  <body>
149
206
  <header>
150
- <h1>AI Spector — Graph &amp; Knowledge</h1>
207
+ <h1>AI Spector — Traceability Graph</h1>
151
208
  <span class="meta" id="header-meta"></span>
152
209
  </header>
153
210
  <nav class="tabs" role="tablist">
@@ -157,6 +214,7 @@ export function buildVisualizationHtml(payload) {
157
214
  </nav>
158
215
 
159
216
  <section id="panel-overview" class="panel active"></section>
217
+
160
218
  <section id="panel-graph" class="panel">
161
219
  <div class="graph-toolbar">
162
220
  <label>View
@@ -166,13 +224,28 @@ export function buildVisualizationHtml(payload) {
166
224
  <option value="all">Full graph</option>
167
225
  </select>
168
226
  </label>
169
- <label>Search <input type="search" id="filter-search" placeholder="node id or title…" /></label>
170
- <label><input type="checkbox" id="filter-physics" checked /> Physics</label>
227
+ <label>Search <input type="search" id="filter-search" placeholder="id or title…" style="width:160px" /></label>
228
+ <label>Layout
229
+ <select id="filter-layout">
230
+ <option value="physics">Force</option>
231
+ <option value="hierarchical">Hierarchical</option>
232
+ </select>
233
+ </label>
234
+ <button class="toolbar-btn" id="focus-btn" title="Show only selected node and neighbors">Focus</button>
235
+ <button class="toolbar-btn" id="reset-btn">Reset view</button>
236
+ </div>
237
+ <div class="edge-filter-bar" id="edge-filter-bar"></div>
238
+ <div class="graph-layout">
239
+ <div class="graph-main">
240
+ <div id="graph-network"></div>
241
+ <div class="legend" id="legend"></div>
242
+ </div>
243
+ <div class="side-panel" id="node-detail">
244
+ <div class="hint">Click a node to inspect its properties and connections.</div>
245
+ </div>
171
246
  </div>
172
- <div id="graph-network"></div>
173
- <div class="legend" id="legend"></div>
174
- <div id="node-detail">Click a node to inspect.</div>
175
247
  </section>
248
+
176
249
  <section id="panel-knowledge" class="panel"></section>
177
250
 
178
251
  <script type="application/json" id="payload">${embedded}</script>
@@ -184,7 +257,6 @@ export function buildVisualizationHtml(payload) {
184
257
  document: "#3b82f6",
185
258
  file: "#38bdf8",
186
259
  source: "#14b8a6",
187
- graphify: "#2dd4bf",
188
260
  section: "#64748b",
189
261
  table: "#475569",
190
262
  diagram: "#475569",
@@ -196,11 +268,14 @@ export function buildVisualizationHtml(payload) {
196
268
  };
197
269
 
198
270
  const STRUCTURE = new Set(["document", "section", "table", "diagram"]);
271
+ const DOMAIN_TYPES = new Set(["actor", "useCase", "feature", "requirement", "dataEntity"]);
199
272
 
200
- document.getElementById("header-meta").textContent =
201
- P.projectRoot + " · generated " + new Date(P.generatedAt).toLocaleString();
273
+ // All edge types in graph
274
+ const allEdgeTypes = [...new Set(P.graph.edges.map((e) => e.type))].sort();
275
+ // Which edge types are shown (default: all on)
276
+ const edgeTypeOn = new Set(allEdgeTypes);
202
277
 
203
- // Tabs
278
+ // ---- TABS ----
204
279
  document.querySelectorAll("nav.tabs button").forEach((btn) => {
205
280
  btn.addEventListener("click", () => {
206
281
  document.querySelectorAll("nav.tabs button").forEach((b) => b.classList.remove("active"));
@@ -211,7 +286,10 @@ export function buildVisualizationHtml(payload) {
211
286
  });
212
287
  });
213
288
 
214
- // Overview
289
+ document.getElementById("header-meta").textContent =
290
+ P.projectRoot + " · " + new Date(P.generatedAt).toLocaleString();
291
+
292
+ // ---- OVERVIEW ----
215
293
  const ov = document.getElementById("panel-overview");
216
294
  const gs = P.graphStats;
217
295
  const ks = P.knowledgeStats;
@@ -221,48 +299,98 @@ export function buildVisualizationHtml(payload) {
221
299
  stat("Graph edges", gs.edges) +
222
300
  stat("Domain nodes", gs.domainNodes) +
223
301
  stat("Structure nodes", gs.structureNodes) +
224
- (ks.present ? stat("Knowledge use cases", ks.useCases) : stat("Knowledge", "—")) +
225
- (ks.present ? stat("Knowledge features", ks.features) : "") +
302
+ (ks.present ? stat("Knowledge UCs", ks.useCases) : "") +
303
+ (ks.present ? stat("Knowledge features", ks.features) : stat("Knowledge", "—")) +
226
304
  "</div>" +
227
- "<p class=\\"section-title\\">Nodes by type</p>" +
228
- typeBreakdown(gs.byType) +
229
- (ks.present ? "<p class=\\"section-title\\">Knowledge staging (not merged yet?)</p><p>Compare tables in the <strong>Knowledge</strong> tab with domain nodes in the <strong>Graph</strong> tab.</p>" : "<p class=\\"empty\\">No knowledge.json found — run /analyze in Cursor.</p>");
305
+ '<p class="section-title">Nodes by type</p>' +
306
+ typeTable(gs.byType) +
307
+ '<p class="section-title">Edge types</p>' +
308
+ edgeTypeTable();
230
309
 
231
310
  function stat(label, value) {
232
311
  return '<div class="stat-card"><div class="label">' + label + '</div><div class="value">' + value + "</div></div>";
233
312
  }
234
- function typeBreakdown(byType) {
235
- return "<table class=data><thead><tr><th>Type</th><th>Count</th></tr></thead><tbody>" +
236
- Object.entries(byType).sort((a, b) => b[1] - a[1]).map(([t, n]) => "<tr><td>" + t + "</td><td>" + n + "</td></tr>").join("") +
237
- "</tbody></table>";
313
+ function typeTable(byType) {
314
+ return '<table class="data"><thead><tr><th>Type</th><th>Count</th></tr></thead><tbody>' +
315
+ Object.entries(byType).sort((a, b) => b[1] - a[1])
316
+ .map(([t, n]) => '<tr><td><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + (NODE_COLORS[t] || "#94a3b8") + ';margin-right:6px"></span>' + t + '</td><td>' + n + '</td></tr>').join("") +
317
+ '</tbody></table>';
318
+ }
319
+ function edgeTypeTable() {
320
+ const counts = {};
321
+ for (const e of P.graph.edges) counts[e.type] = (counts[e.type] || 0) + 1;
322
+ return '<table class="data"><thead><tr><th>Edge type</th><th>Count</th><th>Meaning</th></tr></thead><tbody>' +
323
+ Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([t, n]) => '<tr><td>' + t + '</td><td>' + n + '</td><td style="color:var(--muted);font-size:0.78rem">' + EDGE_MEANINGS[t] + "</td></tr>").join("") +
324
+ '</tbody></table>';
325
+ }
326
+
327
+ const EDGE_MEANINGS = {
328
+ partOf: "section belongs to document",
329
+ contains: "document/section contains another",
330
+ follows: "section ordering",
331
+ references: "cross-reference",
332
+ listedIn: "domain node listed in section",
333
+ definedIn: "domain node detailed in section",
334
+ describedIn: "domain node described by section",
335
+ satisfies: "feature satisfies use case",
336
+ dependsOn: "document depends on another document",
337
+ tracesTo: "requirement traces to document",
338
+ derivedFrom: "node derived from source file",
339
+ rendersTo: "graph node renders to markdown path",
340
+ relatesTo: "semantic link (evidence)",
341
+ };
342
+
343
+ // ---- EDGE FILTER CHIPS ----
344
+ const efBar = document.getElementById("edge-filter-bar");
345
+ for (const et of allEdgeTypes) {
346
+ const chip = document.createElement("span");
347
+ chip.className = "ef-chip on";
348
+ chip.dataset.et = et;
349
+ chip.innerHTML = '<i></i>' + et;
350
+ chip.title = EDGE_MEANINGS[et] || et;
351
+ chip.addEventListener("click", () => {
352
+ if (edgeTypeOn.has(et)) { edgeTypeOn.delete(et); chip.classList.remove("on"); }
353
+ else { edgeTypeOn.add(et); chip.classList.add("on"); }
354
+ rebuildGraph();
355
+ });
356
+ efBar.appendChild(chip);
238
357
  }
239
358
 
240
- // Knowledge panel
359
+ // ---- KNOWLEDGE PANEL ----
241
360
  const kn = document.getElementById("panel-knowledge");
242
361
  if (!P.knowledge || !ks.present) {
243
- kn.innerHTML = '<p class="empty">No knowledge.json loaded.</p>';
362
+ kn.innerHTML = '<p class="empty">No knowledge.json loaded — run /analyze in Cursor.</p>';
244
363
  } else {
364
+ const graphIds = new Set(P.graph.nodes.map((n) => n.id));
245
365
  kn.innerHTML =
246
- knowledgeTable("Actors", P.knowledge.actors, ["id", "name", "title", "listedInSection"]) +
247
- knowledgeTable("Use cases", P.knowledge.useCases, ["id", "title", "priority", "listedInSection"]) +
248
- knowledgeTable("Features", P.knowledge.features, ["id", "title", "satisfies", "listedInSection"]) +
249
- knowledgeTable("Functional requirements", P.knowledge.functionalRequirements, ["id", "title", "tracesTo", "listedInSection"]) +
250
- knowledgeTable("NFRs", P.knowledge.nfrs, ["id", "title", "listedInSection"]) +
251
- knowledgeTable("Entities", P.knowledge.entities, ["id", "name", "listedInSection"]);
366
+ knowledgeTable("Actors", P.knowledge.actors, ["id", "name", "title"], graphIds) +
367
+ knowledgeTable("Use cases", P.knowledge.useCases, ["id", "title", "priority"], graphIds) +
368
+ knowledgeTable("Features", P.knowledge.features, ["id", "title", "satisfies"], graphIds) +
369
+ knowledgeTable("Functional requirements", P.knowledge.functionalRequirements, ["id", "title", "tracesTo"], graphIds) +
370
+ knowledgeTable("NFRs", P.knowledge.nfrs, ["id", "title"], graphIds) +
371
+ knowledgeTable("Data entities", P.knowledge.entities, ["id", "name"], graphIds);
252
372
  }
253
373
 
254
- function knowledgeTable(title, rows, cols) {
374
+ function knowledgeTable(title, rows, cols, graphIds) {
255
375
  if (!rows || !rows.length) return '<p class="section-title">' + title + ' <span class="badge">0</span></p><p class="empty">(empty)</p>';
256
- const head = cols.map((c) => "<th>" + c + "</th>").join("");
257
- const body = rows.map((row) =>
258
- "<tr>" + cols.map((c) => {
376
+ const inGraph = rows.filter((r) => graphIds.has(r.id)).length;
377
+ const head = "<th>In graph</th>" + cols.map((c) => "<th>" + c + "</th>").join("");
378
+ const body = rows.map((row) => {
379
+ const merged = graphIds.has(row.id);
380
+ const statusCell = '<td class="' + (merged ? "status-ok" : "status-miss") + '">' + (merged ? "✓" : "✗") + "</td>";
381
+ const dataCells = cols.map((c) => {
259
382
  let v = row[c];
260
383
  if (Array.isArray(v)) v = v.join(", ");
261
384
  if (v === undefined || v === null) v = "";
262
385
  return "<td>" + escapeHtml(String(v)) + "</td>";
263
- }).join("") + "</tr>"
264
- ).join("");
265
- return '<p class="section-title">' + title + ' <span class="badge">' + rows.length + "</span></p>" +
386
+ }).join("");
387
+ return "<tr>" + statusCell + dataCells + "</tr>";
388
+ }).join("");
389
+ return '<p class="section-title">' + title +
390
+ ' <span class="badge">' + rows.length + "</span>" +
391
+ ' <span class="badge status-ok">' + inGraph + " in graph</span>" +
392
+ (inGraph < rows.length ? ' <span class="badge status-miss">' + (rows.length - inGraph) + " missing</span>" : "") +
393
+ "</p>" +
266
394
  '<table class="data"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
267
395
  }
268
396
 
@@ -270,193 +398,151 @@ export function buildVisualizationHtml(payload) {
270
398
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
271
399
  }
272
400
 
273
- // Legend
401
+ // ---- LEGEND ----
274
402
  const legend = document.getElementById("legend");
275
403
  legend.innerHTML = Object.entries(NODE_COLORS).map(([t, c]) =>
276
- '<span><i style="background:' + c + '"></i>' + t + "</span>"
404
+ '<span title="Click to highlight" data-ntype="' + t + '"><i style="background:' + c + '"></i>' + t + "</span>"
277
405
  ).join("");
406
+ legend.querySelectorAll("span").forEach((span) => {
407
+ span.addEventListener("click", () => {
408
+ const t = span.dataset.ntype;
409
+ if (window.__network) {
410
+ const ids = P.graph.nodes.filter((n) => n.type === t).map((n) => n.id);
411
+ window.__network.selectNodes(ids);
412
+ if (ids.length) window.__network.focus(ids[0], { scale: 1.2, animation: true });
413
+ }
414
+ });
415
+ });
278
416
 
417
+ // ---- GRAPH ----
279
418
  let network = null;
419
+ let focusedNodeId = null;
280
420
  window.__network = null;
281
421
 
422
+ function fileNodeId(path) { return "file:" + path; }
423
+ function sourceNodeId(path) { return "source:" + path; }
424
+
282
425
  function nodeLabel(n) {
283
426
  const t = n.title || n.heading || n.name || n.id;
284
- return t.length > 42 ? t.slice(0, 40) + "…" : t;
427
+ return t.length > 36 ? t.slice(0, 34) + "…" : t;
285
428
  }
286
429
 
287
- const DOMAIN_TYPES = new Set(["actor", "useCase", "feature", "requirement", "dataEntity"]);
288
-
289
430
  function domainLinkedStructureIds(domainIds) {
290
431
  const linked = new Set();
291
432
  for (const e of P.graph.edges) {
292
- if (!["listedIn", "definedIn", "describedIn"].includes(e.type)) {
293
- continue;
294
- }
295
- if (!domainIds.has(e.from)) {
296
- continue;
297
- }
433
+ if (!["listedIn", "definedIn", "describedIn"].includes(e.type)) continue;
434
+ if (!domainIds.has(e.from)) continue;
298
435
  const target = P.graph.nodes.find((n) => n.id === e.to);
299
- if (target && (STRUCTURE.has(target.type) || target.type === "document")) {
300
- linked.add(e.to);
301
- }
436
+ if (target && (STRUCTURE.has(target.type) || target.type === "document")) linked.add(e.to);
302
437
  }
303
438
  return linked;
304
439
  }
305
440
 
306
- function filterNodes(viewMode, search) {
307
- const q = (search || "").trim().toLowerCase();
308
- const filtered = P.graph.nodes.filter((n) => {
309
- if (viewMode === "structure") {
310
- if (!STRUCTURE.has(n.type)) return false;
311
- } else if (viewMode === "domain") {
312
- if (n.type === "table" || n.type === "diagram") return false;
313
- }
314
- if (q) {
315
- const hay = (n.id + " " + (n.title || "") + " " + (n.heading || "") + " " + (n.name || "")).toLowerCase();
316
- return hay.includes(q);
317
- }
318
- return true;
319
- });
320
- if (viewMode !== "domain" || q) {
321
- return filtered;
441
+ function domainLinkedSourceIds(domainIds) {
442
+ const linked = new Set();
443
+ for (const e of P.graph.edges) {
444
+ if (e.type !== "derivedFrom" || !domainIds.has(e.from)) continue;
445
+ linked.add(sourceNodeId(e.to));
322
446
  }
323
- const domainIds = new Set(
324
- filtered.filter((n) => DOMAIN_TYPES.has(n.type)).map((n) => n.id),
325
- );
326
- const linked = domainLinkedStructureIds(domainIds);
327
- const sourceLinked = domainLinkedSourceIds(domainIds);
328
- return filtered.filter(
329
- (n) =>
330
- DOMAIN_TYPES.has(n.type) ||
331
- linked.has(n.id) ||
332
- sourceLinked.has(n.id) ||
333
- (n.type === "document" && linked.has(n.id)),
334
- );
447
+ return linked;
335
448
  }
336
449
 
337
- function fileNodeId(path) {
338
- return "file:" + path;
339
- }
450
+ function filterNodes(viewMode, search, focusId) {
451
+ const q = (search || "").trim().toLowerCase();
452
+ let nodes = P.graph.nodes.filter((n) => {
453
+ if (viewMode === "structure") return STRUCTURE.has(n.type);
454
+ if (viewMode === "domain") return n.type !== "table" && n.type !== "diagram";
455
+ return true;
456
+ });
340
457
 
341
- function sourceNodeId(path) {
342
- return "source:" + path;
343
- }
458
+ if (q) {
459
+ nodes = nodes.filter((n) => {
460
+ const hay = (n.id + " " + (n.title || "") + " " + (n.heading || "") + " " + (n.name || "")).toLowerCase();
461
+ return hay.includes(q);
462
+ });
463
+ return nodes;
464
+ }
344
465
 
345
- function domainLinkedSourceIds(domainIds) {
346
- const linked = new Set();
347
- for (const e of P.graph.edges) {
348
- if (e.type !== "derivedFrom" || !domainIds.has(e.from)) {
349
- continue;
350
- }
351
- if (e.to.startsWith("graphify:")) {
352
- linked.add(e.to);
353
- } else {
354
- linked.add(sourceNodeId(e.to));
466
+ if (focusId) {
467
+ const neighbors = new Set([focusId]);
468
+ for (const e of P.graph.edges) {
469
+ if (!edgeTypeOn.has(e.type)) continue;
470
+ if (e.from === focusId) neighbors.add(e.to);
471
+ if (e.to === focusId) neighbors.add(e.from);
355
472
  }
473
+ return nodes.filter((n) => neighbors.has(n.id));
356
474
  }
357
- return linked;
475
+
476
+ if (viewMode === "domain") {
477
+ const domainIds = new Set(nodes.filter((n) => DOMAIN_TYPES.has(n.type)).map((n) => n.id));
478
+ const linked = domainLinkedStructureIds(domainIds);
479
+ const srcLinked = domainLinkedSourceIds(domainIds);
480
+ return nodes.filter((n) => DOMAIN_TYPES.has(n.type) || linked.has(n.id) || srcLinked.has(n.id));
481
+ }
482
+ return nodes;
358
483
  }
359
484
 
360
- function buildVisData(viewMode, search) {
361
- const nodes = filterNodes(viewMode, search);
485
+ function buildVisData(viewMode, search, focusId) {
486
+ const nodes = filterNodes(viewMode, search, focusId);
362
487
  const ids = new Set(nodes.map((n) => n.id));
363
488
  const pathsWithDocument = new Set(
364
- P.graph.nodes
365
- .filter((n) => n.type === "document" && typeof n.output === "string")
366
- .map((n) => n.output),
489
+ P.graph.nodes.filter((n) => n.type === "document" && typeof n.output === "string").map((n) => n.output)
367
490
  );
368
491
  const fileNodes = new Map();
369
492
  const sourceNodes = new Map();
370
- const graphifyNodes = new Map();
371
493
  for (const e of P.graph.edges) {
372
- if (e.type === "derivedFrom" && ids.has(e.from)) {
373
- if (e.to.startsWith("graphify:")) {
374
- const gid = e.to;
375
- if (!graphifyNodes.has(gid)) {
376
- const label = gid.slice("graphify:".length);
377
- graphifyNodes.set(gid, {
378
- id: gid,
379
- type: "graphify",
380
- title: label,
381
- graphifyId: label,
382
- });
383
- }
384
- } else {
385
- const sid = sourceNodeId(e.to);
386
- if (!sourceNodes.has(sid)) {
387
- const base = e.to.split("/").pop() || e.to;
388
- sourceNodes.set(sid, {
389
- id: sid,
390
- type: "source",
391
- title: base,
392
- output: e.to,
393
- });
394
- }
494
+ if (!edgeTypeOn.has(e.type)) continue;
495
+ if (e.type === "derivedFrom" && ids.has(e.from) && !e.to.startsWith("graphify:")) {
496
+ const sid = sourceNodeId(e.to);
497
+ if (!sourceNodes.has(sid)) {
498
+ const base = e.to.split("/").pop() || e.to;
499
+ sourceNodes.set(sid, { id: sid, type: "source", title: base, output: e.to });
395
500
  }
396
501
  continue;
397
502
  }
398
- if (e.type !== "rendersTo" || !ids.has(e.from)) {
399
- continue;
400
- }
401
- if (ids.has(e.to)) {
402
- continue;
403
- }
404
- if (pathsWithDocument.has(e.to)) {
405
- continue;
406
- }
503
+ if (e.type !== "rendersTo" || !ids.has(e.from) || ids.has(e.to) || pathsWithDocument.has(e.to)) continue;
407
504
  const fid = fileNodeId(e.to);
408
505
  if (!fileNodes.has(fid)) {
409
506
  const base = e.to.split("/").pop() || e.to;
410
- fileNodes.set(fid, {
411
- id: fid,
412
- type: "file",
413
- title: base,
414
- output: e.to,
415
- });
507
+ fileNodes.set(fid, { id: fid, type: "file", title: base, output: e.to });
416
508
  }
417
509
  }
418
510
  const pathToDocId = new Map();
419
511
  for (const n of P.graph.nodes) {
420
- if (n.type === "document" && typeof n.output === "string") {
421
- pathToDocId.set(n.output, n.id);
422
- }
512
+ if (n.type === "document" && typeof n.output === "string") pathToDocId.set(n.output, n.id);
423
513
  }
424
- const allNodes = nodes.concat(
425
- [...fileNodes.values()],
426
- [...sourceNodes.values()],
427
- [...graphifyNodes.values()],
428
- );
514
+ const allNodes = nodes.concat([...fileNodes.values()], [...sourceNodes.values()]);
429
515
  const allIds = new Set(allNodes.map((n) => n.id));
430
- function resolveEdgeTarget(to) {
431
- if (allIds.has(to)) {
432
- return to;
433
- }
434
- const docId = pathToDocId.get(to);
435
- if (docId && allIds.has(docId)) {
436
- return docId;
437
- }
438
- if (fileNodes.has(fileNodeId(to))) {
439
- return fileNodeId(to);
440
- }
441
- if (sourceNodes.has(sourceNodeId(to))) {
442
- return sourceNodeId(to);
443
- }
444
- if (graphifyNodes.has(to)) {
445
- return to;
446
- }
516
+ function resolveTarget(to) {
517
+ if (allIds.has(to)) return to;
518
+ const d = pathToDocId.get(to);
519
+ if (d && allIds.has(d)) return d;
520
+ if (fileNodes.has(fileNodeId(to))) return fileNodeId(to);
521
+ if (sourceNodes.has(sourceNodeId(to))) return sourceNodeId(to);
447
522
  return null;
448
523
  }
449
- const visNodes = allNodes.map((n) => ({
450
- id: n.id,
451
- label: nodeLabel(n),
452
- title: "<pre style=\\"margin:0;font-size:11px\\">" + escapeHtml(JSON.stringify(n, null, 2)) + "</pre>",
453
- color: NODE_COLORS[n.type] || "#94a3b8",
454
- font: { color: "#e7ecf3", size: 11 },
455
- shape: n.type === "document" || n.type === "file" || n.type === "source" || n.type === "graphify" ? "box" : "dot",
456
- size: n.type === "section" ? 10 : STRUCTURE.has(n.type) ? 12 : n.type === "file" || n.type === "source" || n.type === "graphify" ? 14 : 18,
457
- }));
524
+ const visNodes = allNodes.map((n) => {
525
+ const isFocused = n.id === focusId;
526
+ const isNeighbor = focusId && n.id !== focusId;
527
+ return {
528
+ id: n.id,
529
+ label: nodeLabel(n),
530
+ title: buildTooltip(n),
531
+ color: {
532
+ background: isFocused ? "#facc15" : (NODE_COLORS[n.type] || "#94a3b8"),
533
+ border: isFocused ? "#f59e0b" : (NODE_COLORS[n.type] || "#94a3b8"),
534
+ highlight: { background: "#facc15", border: "#f59e0b" },
535
+ opacity: focusId && !isFocused ? 0.7 : 1,
536
+ },
537
+ font: { color: "#e7ecf3", size: 11 },
538
+ shape: STRUCTURE.has(n.type) || n.type === "file" || n.type === "source" ? "box" : "dot",
539
+ size: n.type === "section" ? 10 : STRUCTURE.has(n.type) ? 12 : n.type === "file" || n.type === "source" ? 13 : 18,
540
+ borderWidth: isFocused ? 3 : 1,
541
+ };
542
+ });
458
543
  const visEdges = P.graph.edges
459
- .map((e) => ({ e, to: resolveEdgeTarget(e.to) }))
544
+ .filter((e) => edgeTypeOn.has(e.type))
545
+ .map((e) => ({ e, to: resolveTarget(e.to) }))
460
546
  .filter(({ e, to }) => to !== null && allIds.has(e.from))
461
547
  .map(({ e, to }, i) => ({
462
548
  id: i,
@@ -464,72 +550,196 @@ export function buildVisualizationHtml(payload) {
464
550
  to,
465
551
  label: e.type,
466
552
  font: { size: 9, color: "#8b9cb3", strokeWidth: 0 },
467
- color: {
468
- color: e.type === "derivedFrom" ? "#14b8a6" : "#4b5563",
469
- highlight: e.type === "derivedFrom" ? "#2dd4bf" : "#60a5fa",
470
- },
553
+ color: { color: "#3a4a5e", highlight: "#60a5fa" },
471
554
  arrows: "to",
555
+ smooth: { type: "dynamic" },
472
556
  }));
473
557
  return { nodes: new vis.DataSet(visNodes), edges: new vis.DataSet(visEdges) };
474
558
  }
475
559
 
560
+ function buildTooltip(n) {
561
+ const props = Object.entries(n)
562
+ .filter(([k]) => !["id", "type"].includes(k))
563
+ .map(([k, v]) => k + ": " + (typeof v === "object" ? JSON.stringify(v) : v))
564
+ .join("\\n");
565
+ return "<pre style=\\"margin:0;font-size:11px;max-width:320px;white-space:pre-wrap\\">" + escapeHtml((n.id || "") + " [" + n.type + "]\\n" + props) + "</pre>";
566
+ }
567
+
568
+ function rebuildGraph() {
569
+ if (!window.__network) return;
570
+ const viewMode = document.getElementById("filter-view").value;
571
+ const search = document.getElementById("filter-search").value;
572
+ const data = buildVisData(viewMode, search, focusedNodeId);
573
+ window.__network.setData(data);
574
+ applyLayout();
575
+ }
576
+
577
+ function applyLayout() {
578
+ if (!window.__network) return;
579
+ const layout = document.getElementById("filter-layout").value;
580
+ if (layout === "hierarchical") {
581
+ window.__network.setOptions({
582
+ layout: { hierarchical: { enabled: true, direction: "UD", sortMethod: "hubsize", nodeSpacing: 120 } },
583
+ physics: { enabled: false },
584
+ });
585
+ } else {
586
+ window.__network.setOptions({
587
+ layout: { hierarchical: { enabled: false } },
588
+ physics: { enabled: true, stabilization: { iterations: 100 } },
589
+ });
590
+ }
591
+ }
592
+
476
593
  function initGraph() {
477
594
  const container = document.getElementById("graph-network");
478
595
  const viewMode = document.getElementById("filter-view").value;
479
596
  const search = document.getElementById("filter-search").value;
480
- const data = buildVisData(viewMode, search);
481
- const physics = document.getElementById("filter-physics").checked;
482
- const options = {
483
- physics: { enabled: physics, stabilization: { iterations: 120 } },
484
- interaction: { hover: true, tooltipDelay: 120 },
597
+ const data = buildVisData(viewMode, search, null);
598
+ network = new vis.Network(container, data, {
599
+ physics: { enabled: true, stabilization: { iterations: 100 } },
600
+ interaction: { hover: true, tooltipDelay: 100, multiselect: false },
485
601
  layout: { improvedLayout: true },
486
- edges: { smooth: { type: "dynamic" } },
487
- };
488
- if (network) {
489
- network.setData(data);
490
- network.setOptions(options);
602
+ });
603
+ window.__network = network;
604
+
605
+ network.on("click", (params) => {
606
+ if (!params.nodes.length) {
607
+ renderDetail(null);
608
+ return;
609
+ }
610
+ const id = params.nodes[0];
611
+ renderDetail(id);
612
+ document.getElementById("focus-btn").style.display = "inline-block";
613
+ });
614
+
615
+ network.on("doubleClick", (params) => {
616
+ if (!params.nodes.length) return;
617
+ toggleFocus(params.nodes[0]);
618
+ });
619
+ }
620
+
621
+ function toggleFocus(id) {
622
+ if (focusedNodeId === id) {
623
+ focusedNodeId = null;
624
+ document.getElementById("focus-btn").classList.remove("active");
491
625
  } else {
492
- network = new vis.Network(container, data, options);
493
- window.__network = network;
494
- network.on("click", (params) => {
495
- const detail = document.getElementById("node-detail");
496
- if (!params.nodes.length) {
497
- detail.textContent = "Click a node to inspect.";
498
- return;
499
- }
500
- const id = params.nodes[0];
501
- const node =
502
- P.graph.nodes.find((n) => n.id === id) ||
503
- (id.startsWith("file:")
504
- ? { id, type: "file", output: id.slice(5) }
505
- : id.startsWith("source:")
506
- ? { id, type: "source", output: id.slice(7) }
507
- : id.startsWith("graphify:")
508
- ? { id, type: "graphify", graphifyId: id.slice(9) }
509
- : null);
510
- const pathTail = id.startsWith("file:") ? id.slice(5) : id.startsWith("source:") ? id.slice(7) : null;
511
- const out = P.graph.edges.filter(
512
- (e) => e.from === id || (pathTail && (e.to === pathTail || e.to === id)),
513
- );
514
- const inc = P.graph.edges.filter(
515
- (e) =>
516
- e.to === id ||
517
- (pathTail && (e.type === "rendersTo" || e.type === "derivedFrom") && e.to === pathTail),
518
- );
519
- detail.textContent =
520
- JSON.stringify(node, null, 2) +
521
- "\\n\\n--- outgoing (" + out.length + ") ---\\n" +
522
- out.map((e) => e.type + " → " + e.to).join("\\n") +
523
- "\\n\\n--- incoming (" + inc.length + ") ---\\n" +
524
- inc.map((e) => e.from + " → " + e.type).join("\\n");
525
- });
626
+ focusedNodeId = id;
627
+ document.getElementById("focus-btn").classList.add("active");
526
628
  }
629
+ rebuildGraph();
527
630
  }
528
631
 
529
- ["filter-view", "filter-search", "filter-physics"].forEach((id) => {
530
- document.getElementById(id).addEventListener("change", () => initGraph());
531
- document.getElementById(id).addEventListener("input", () => initGraph());
632
+ document.getElementById("focus-btn").addEventListener("click", () => {
633
+ if (focusedNodeId) toggleFocus(focusedNodeId);
532
634
  });
635
+
636
+ document.getElementById("reset-btn").addEventListener("click", () => {
637
+ focusedNodeId = null;
638
+ document.getElementById("focus-btn").classList.remove("active");
639
+ document.getElementById("filter-search").value = "";
640
+ rebuildGraph();
641
+ if (window.__network) window.__network.fit({ animation: true });
642
+ });
643
+
644
+ ["filter-view", "filter-search", "filter-layout"].forEach((id) => {
645
+ const el = document.getElementById(id);
646
+ el.addEventListener("change", () => {
647
+ focusedNodeId = null;
648
+ rebuildGraph();
649
+ if (id === "filter-layout") applyLayout();
650
+ });
651
+ el.addEventListener("input", () => rebuildGraph());
652
+ });
653
+
654
+ // ---- NODE DETAIL PANEL ----
655
+ function renderDetail(id) {
656
+ const detail = document.getElementById("node-detail");
657
+ if (!id) {
658
+ detail.innerHTML = '<div class="hint">Click a node to inspect. Double-click to focus its neighborhood.</div>';
659
+ return;
660
+ }
661
+ const node = P.graph.nodes.find((n) => n.id === id);
662
+ if (!node) {
663
+ detail.innerHTML = '<div class="hint">Node not in base graph (file/source node).</div>';
664
+ return;
665
+ }
666
+
667
+ // Partition edges
668
+ const outEdges = P.graph.edges.filter((e) => e.from === id);
669
+ const inEdges = P.graph.edges.filter((e) => e.to === id);
670
+
671
+ function groupByType(edges, dir) {
672
+ const groups = {};
673
+ for (const e of edges) {
674
+ const key = e.type;
675
+ if (!groups[key]) groups[key] = [];
676
+ const otherId = dir === "out" ? e.to : e.from;
677
+ const otherNode = P.graph.nodes.find((n) => n.id === otherId);
678
+ const label = otherNode ? (otherNode.title || otherNode.heading || otherNode.name || otherId) : otherId;
679
+ const type = otherNode ? otherNode.type : "?";
680
+ groups[key].push({ id: otherId, label, type });
681
+ }
682
+ return groups;
683
+ }
684
+
685
+ function renderEdgeGroups(groups, dir) {
686
+ const entries = Object.entries(groups);
687
+ if (!entries.length) return '<div class="hint" style="font-size:0.75rem">none</div>';
688
+ return entries.map(([et, items]) =>
689
+ '<div class="edge-group">' +
690
+ '<div class="edge-type-label">' + et + ' (' + items.length + ')</div>' +
691
+ items.slice(0, 12).map((item) =>
692
+ '<div class="edge-item" data-target="' + escapeHtml(item.id) + '">' +
693
+ '<span class="ei-dot" style="background:' + (NODE_COLORS[item.type] || "#94a3b8") + '"></span>' +
694
+ '<span class="ei-label" title="' + escapeHtml(item.id) + '">' + escapeHtml(item.label) + '</span>' +
695
+ '<span class="ei-type">' + item.type + '</span>' +
696
+ '</div>'
697
+ ).join("") +
698
+ (items.length > 12 ? '<div style="font-size:0.72rem;color:var(--muted);padding:0.1rem 0">+' + (items.length - 12) + ' more…</div>' : "") +
699
+ "</div>"
700
+ ).join("");
701
+ }
702
+
703
+ // Render props (exclude standard fields)
704
+ const skipKeys = new Set(["id", "type", "title", "heading", "name"]);
705
+ const propRows = Object.entries(node)
706
+ .filter(([k]) => !skipKeys.has(k))
707
+ .map(([k, v]) => {
708
+ const val = typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
709
+ return '<div class="prop-row"><span class="prop-key">' + escapeHtml(k) + '</span><span class="prop-val">' + escapeHtml(val) + '</span></div>';
710
+ }).join("");
711
+
712
+ const outGroups = groupByType(outEdges, "out");
713
+ const inGroups = groupByType(inEdges, "in");
714
+
715
+ detail.innerHTML =
716
+ '<div class="node-id">' + escapeHtml(node.id) + '</div>' +
717
+ '<div class="node-title">' + escapeHtml(node.title || node.heading || node.name || node.id) + '</div>' +
718
+ '<div><span class="node-type" style="background:' + (NODE_COLORS[node.type] || "#64748b") + '33;color:' + (NODE_COLORS[node.type] || "#94a3b8") + '">' + node.type + '</span></div>' +
719
+ (propRows ? '<div class="props">' + propRows + '</div>' : '') +
720
+ '<div class="edge-section">' +
721
+ '<div class="edge-section-title">Outgoing (' + outEdges.length + ')</div>' +
722
+ renderEdgeGroups(outGroups, "out") +
723
+ '</div>' +
724
+ '<div class="edge-section">' +
725
+ '<div class="edge-section-title">Incoming (' + inEdges.length + ')</div>' +
726
+ renderEdgeGroups(inGroups, "in") +
727
+ '</div>';
728
+
729
+ // Make edge items clickable → select node in graph
730
+ detail.querySelectorAll(".edge-item").forEach((item) => {
731
+ item.addEventListener("click", () => {
732
+ const targetId = item.dataset.target;
733
+ if (!window.__network) return;
734
+ renderDetail(targetId);
735
+ const allVis = window.__network.body.data.nodes.getIds();
736
+ if (allVis.includes(targetId)) {
737
+ window.__network.selectNodes([targetId]);
738
+ window.__network.focus(targetId, { scale: 1.3, animation: { duration: 300 } });
739
+ }
740
+ });
741
+ });
742
+ }
533
743
  })();
534
744
  </script>
535
745
  </body>