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.
- package/dist/commands/prototype.d.ts.map +1 -1
- package/dist/commands/prototype.js +4 -1
- package/dist/commands/prototype.js.map +1 -1
- package/dist/graph/doc-extract.d.ts.map +1 -1
- package/dist/graph/doc-extract.js +115 -102
- package/dist/graph/doc-extract.js.map +1 -1
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.d.ts.map +1 -0
- package/dist/markdown/parse.js +100 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/prototype/config.d.ts +2 -0
- package/dist/prototype/config.d.ts.map +1 -1
- package/dist/prototype/config.js +35 -6
- package/dist/prototype/config.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/visualize/html.d.ts.map +1 -1
- package/dist/visualize/html.js +492 -282
- package/dist/visualize/html.js.map +1 -1
- package/package.json +7 -2
- package/scaffold/.ai-spector/.docflow/config/prototype.config.json +1 -2
- package/scaffold/cursor/WORKFLOW.md +2 -2
- package/scaffold/cursor/commands/_workflow.md +3 -3
- package/scaffold/cursor/mcp.json +1 -15
- package/scaffold/cursor/skills/ai-spector/references/cli-failures.md +3 -32
- package/scaffold/cursor/skills/ai-spector/references/generate-graph.md +49 -70
- package/scaffold/cursor/skills/ai-spector/references/generate-workflow.md +1 -1
- package/scaffold/cursor/skills/ai-spector/references/graph.md +0 -1
- package/scaffold/cursor/skills/ai-spector-generate-prototype/SKILL.md +6 -5
- package/scaffold/cursor/skills/ai-spector-generate-prototype/references/runbook.md +30 -10
- package/scaffold/cursor/skills/ai-spector-generate-srs/references/runbook.md +1 -1
- package/scaffold/cursor/skills/ai-spector-graph/references/analyze.md +18 -44
- package/scaffold/cursor/skills/ai-spector-graph/references/graph-commands.md +1 -4
- package/scaffold/cursor/skills/ai-spector-graph/references/index.md +6 -14
- package/scaffold/prototype/README.md +4 -2
package/dist/visualize/html.js
CHANGED
|
@@ -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:
|
|
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.
|
|
37
|
+
gap: 0.5rem 1.5rem;
|
|
36
38
|
align-items: baseline;
|
|
37
39
|
}
|
|
38
|
-
header h1 { margin: 0; font-size: 1.
|
|
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.
|
|
52
|
+
padding: 0.5rem 1rem;
|
|
51
53
|
cursor: pointer;
|
|
52
|
-
font-size: 0.
|
|
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(
|
|
65
|
-
gap: 0.
|
|
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.
|
|
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
|
-
|
|
75
|
-
.
|
|
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.
|
|
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
|
-
.
|
|
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.
|
|
108
|
+
padding: 0.3rem 0.65rem;
|
|
89
109
|
border-radius: 6px;
|
|
90
|
-
font-size: 0.
|
|
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(
|
|
117
|
+
height: min(68vh, 620px);
|
|
95
118
|
border: 1px solid var(--border);
|
|
96
119
|
border-radius: 8px;
|
|
97
120
|
background: #121820;
|
|
98
121
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
113
|
-
gap: 0.
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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:
|
|
137
|
-
.
|
|
138
|
-
.
|
|
139
|
-
|
|
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
|
|
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="
|
|
170
|
-
<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
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
235
|
-
return
|
|
236
|
-
Object.entries(byType).sort((a, b) => b[1] - a[1])
|
|
237
|
-
|
|
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
|
-
//
|
|
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",
|
|
247
|
-
knowledgeTable("Use cases", P.knowledge.useCases, ["id", "title", "priority",
|
|
248
|
-
knowledgeTable("Features", P.knowledge.features, ["id", "title", "satisfies",
|
|
249
|
-
knowledgeTable("Functional requirements", P.knowledge.functionalRequirements, ["id", "title", "tracesTo",
|
|
250
|
-
knowledgeTable("NFRs", P.knowledge.nfrs, ["id", "title",
|
|
251
|
-
knowledgeTable("
|
|
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
|
|
257
|
-
const
|
|
258
|
-
|
|
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("")
|
|
264
|
-
|
|
265
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
271
399
|
}
|
|
272
400
|
|
|
273
|
-
//
|
|
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 >
|
|
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
|
-
|
|
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
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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 (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
431
|
-
if (allIds.has(to))
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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>
|