polycodegraph 0.1.0__py3-none-any.whl
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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1671 @@
|
|
|
1
|
+
/* Architecture view — infra topology + click-an-endpoint flow animation. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
(function () {
|
|
5
|
+
const KIND_TIERS = {
|
|
6
|
+
WEB_SERVER: { tier: 1, label: 'Web / API' },
|
|
7
|
+
HTTP_CLIENT: { tier: 2, label: 'HTTP clients' },
|
|
8
|
+
EXTERNAL_API: { tier: 2, label: 'External APIs' },
|
|
9
|
+
MESSAGING: { tier: 2, label: 'Messaging' },
|
|
10
|
+
QUEUE: { tier: 3, label: 'Queues' },
|
|
11
|
+
BROKER: { tier: 3, label: 'Brokers' },
|
|
12
|
+
CACHE: { tier: 4, label: 'Caches' },
|
|
13
|
+
SEARCH: { tier: 4, label: 'Search' },
|
|
14
|
+
ORM: { tier: 5, label: 'ORM / Query' },
|
|
15
|
+
DB: { tier: 6, label: 'Databases' },
|
|
16
|
+
OBJECT_STORE: { tier: 6, label: 'Object storage' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function escapeHtml(s) {
|
|
20
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, c =>
|
|
21
|
+
({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------- DF4 / Phase 4: per-handler dataflow trace ----------
|
|
25
|
+
|
|
26
|
+
// Role → swimlane id + colour. Matches role palette in graph3d_transform.js
|
|
27
|
+
// (HANDLER amber, SERVICE blue, COMPONENT green, REPO purple).
|
|
28
|
+
const DF_ROLE_LANES = {
|
|
29
|
+
COMPONENT: { id: 'df-component', label: 'Component', color: '#22c55e', icon: 'layout' },
|
|
30
|
+
HANDLER: { id: 'df-handler', label: 'Handler', color: '#fbbf24', icon: 'cpu' },
|
|
31
|
+
SERVICE: { id: 'df-service', label: 'Service', color: '#60a5fa', icon: 'cog' },
|
|
32
|
+
REPO: { id: 'df-repo', label: 'Repository', color: '#a78bfa', icon: 'database' },
|
|
33
|
+
DB: { id: 'df-db', label: 'Storage', color: '#22d3ee', icon: 'hard-drive' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Map a hop to a lane id given its role/kind.
|
|
37
|
+
function hopLaneId(hop) {
|
|
38
|
+
if (!hop) return 'df-handler';
|
|
39
|
+
if (hop.kind === 'FETCH_CALL') return 'df-component';
|
|
40
|
+
if (hop.kind === 'READS_FROM' || hop.kind === 'WRITES_TO') return 'df-db';
|
|
41
|
+
if (hop.role && DF_ROLE_LANES[hop.role]) return DF_ROLE_LANES[hop.role].id;
|
|
42
|
+
if (hop.kind === 'ROUTE') return 'df-handler';
|
|
43
|
+
return 'df-service';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Format args list as compact "(a, b)" string.
|
|
47
|
+
function formatHopArgs(hop) {
|
|
48
|
+
const args = (hop && hop.args) || [];
|
|
49
|
+
if (!args.length) return '';
|
|
50
|
+
return '(' + args.map(a => String(a)).join(', ') + ')';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Short label for hop based on its kind + qualname.
|
|
54
|
+
function hopLabel(hop) {
|
|
55
|
+
const qn = hop.qualname || '';
|
|
56
|
+
const tail = qn.includes('::') ? qn.split('::').pop()
|
|
57
|
+
: qn.split('.').pop();
|
|
58
|
+
const argTxt = formatHopArgs(hop);
|
|
59
|
+
if (hop.kind === 'FETCH_CALL') return 'fetch ' + (tail || qn) + argTxt;
|
|
60
|
+
if (hop.kind === 'ROUTE') return 'dispatch → ' + (tail || qn) + argTxt;
|
|
61
|
+
if (hop.kind === 'READS_FROM') return 'read ' + (tail || qn);
|
|
62
|
+
if (hop.kind === 'WRITES_TO') return 'write ' + (tail || qn);
|
|
63
|
+
return (tail || qn) + argTxt;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build a Phase 4 segment: lanes + stages + meta from the handler's
|
|
67
|
+
// dataflow.hops. Returns { lanes, stages, meta } or null when no data.
|
|
68
|
+
// Pure function, suitable for unit testing.
|
|
69
|
+
function buildDataflowSegment(handler) {
|
|
70
|
+
const df = handler && handler.dataflow;
|
|
71
|
+
if (!df) {
|
|
72
|
+
return { lanes: [], stages: [], meta: { available: false, hopCount: 0, confidence: 0 } };
|
|
73
|
+
}
|
|
74
|
+
const hops = Array.isArray(df.hops) ? df.hops : [];
|
|
75
|
+
const confidence = typeof df.confidence === 'number' ? df.confidence : 0;
|
|
76
|
+
if (!hops.length) {
|
|
77
|
+
return { lanes: [], stages: [],
|
|
78
|
+
meta: { available: true, hopCount: 0, confidence, empty: true } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Collect needed lanes in encounter order, dedupe.
|
|
82
|
+
const lanes = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
function addLane(id) {
|
|
85
|
+
if (seen.has(id)) return;
|
|
86
|
+
seen.add(id);
|
|
87
|
+
const proto = Object.values(DF_ROLE_LANES).find(L => L.id === id)
|
|
88
|
+
|| DF_ROLE_LANES.SERVICE;
|
|
89
|
+
lanes.push({ ...proto, id });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Always start chain from 'handler' lane (the upstream side of the first
|
|
93
|
+
// hop) so the sequence connects to Phase 3's mw → handler arrow.
|
|
94
|
+
const stages = [];
|
|
95
|
+
let prev = 'handler';
|
|
96
|
+
for (const hop of hops) {
|
|
97
|
+
const lane = hopLaneId(hop);
|
|
98
|
+
addLane(lane);
|
|
99
|
+
const label = hopLabel(hop);
|
|
100
|
+
const detail = (hop.file ? (hop.file + (hop.line ? ':' + hop.line : '')) : hop.qualname || '')
|
|
101
|
+
+ (hop.role ? ' · role=' + hop.role : '')
|
|
102
|
+
+ (hop.kind ? ' · kind=' + hop.kind : '');
|
|
103
|
+
stages.push({
|
|
104
|
+
from: prev, to: lane,
|
|
105
|
+
label, detail,
|
|
106
|
+
kind: 'data',
|
|
107
|
+
hop,
|
|
108
|
+
});
|
|
109
|
+
prev = lane;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
lanes, stages,
|
|
114
|
+
meta: {
|
|
115
|
+
available: true,
|
|
116
|
+
hopCount: hops.length,
|
|
117
|
+
confidence,
|
|
118
|
+
lowConfidence: confidence < 0.5,
|
|
119
|
+
empty: false,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- DF5 / Stretch: argument-flow propagation ----------
|
|
125
|
+
|
|
126
|
+
// Stable palette for the param picker. Index = position of the param key in
|
|
127
|
+
// the arg_flow object (insertion order). Same key in the same session
|
|
128
|
+
// renders with the same colour because key order is stable across renders.
|
|
129
|
+
const ARG_PALETTE = [
|
|
130
|
+
'#fbbf24', // amber
|
|
131
|
+
'#38bdf8', // sky
|
|
132
|
+
'#fb7185', // rose
|
|
133
|
+
'#a78bfa', // violet
|
|
134
|
+
'#a3e635', // lime
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
function argColor(index) {
|
|
138
|
+
if (index == null || index < 0) return ARG_PALETTE[0];
|
|
139
|
+
return ARG_PALETTE[index % ARG_PALETTE.length];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getArgFlowKeys(handler) {
|
|
143
|
+
const df = handler && handler.dataflow;
|
|
144
|
+
const hops = df && Array.isArray(df.hops) ? df.hops : [];
|
|
145
|
+
if (!hops.length) return [];
|
|
146
|
+
const af = hops[0].arg_flow;
|
|
147
|
+
if (!af || typeof af !== 'object') return [];
|
|
148
|
+
return Object.keys(af);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hopArgLocalName(hop, selected) {
|
|
152
|
+
if (!hop || !selected) return null;
|
|
153
|
+
const af = hop.arg_flow;
|
|
154
|
+
if (!af || typeof af !== 'object') return null;
|
|
155
|
+
if (!Object.prototype.hasOwnProperty.call(af, selected)) return null;
|
|
156
|
+
return { local: af[selected] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderArgPicker(keys, selected) {
|
|
160
|
+
if (!keys || !keys.length) return '';
|
|
161
|
+
const chips = keys.map((k, i) => {
|
|
162
|
+
const color = argColor(i);
|
|
163
|
+
const active = k === selected;
|
|
164
|
+
const cls = 'cg-arg-chip' + (active ? ' is-active' : '');
|
|
165
|
+
return `<button type="button" data-arg-key="${escapeHtml(k)}"
|
|
166
|
+
data-arg-index="${i}"
|
|
167
|
+
class="${cls}"
|
|
168
|
+
style="--arg-color:${color}; color:${color};"
|
|
169
|
+
aria-pressed="${active ? 'true' : 'false'}">
|
|
170
|
+
<span class="cg-arg-chip-dot" style="background:${color}"></span>
|
|
171
|
+
<span class="cg-arg-chip-label">${escapeHtml(k)}</span>
|
|
172
|
+
</button>`;
|
|
173
|
+
}).join('');
|
|
174
|
+
return `<div class="cg-arg-picker" data-arg-picker
|
|
175
|
+
role="group" aria-label="Trace argument across hops">
|
|
176
|
+
<span class="cg-arg-picker-hint">Trace argument:</span>
|
|
177
|
+
${chips}
|
|
178
|
+
</div>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function argHighlightForStage(stage, selected, color) {
|
|
182
|
+
if (!stage || !selected) return null;
|
|
183
|
+
const hop = stage.hop;
|
|
184
|
+
if (!hop) return null;
|
|
185
|
+
const lookup = hopArgLocalName(hop, selected);
|
|
186
|
+
if (!lookup) return null;
|
|
187
|
+
if (lookup.local == null) return null;
|
|
188
|
+
return {
|
|
189
|
+
local: lookup.local,
|
|
190
|
+
isRename: lookup.local !== selected,
|
|
191
|
+
color,
|
|
192
|
+
selected,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Sequence-mode SVG label fragment. Wraps the matched local name in a
|
|
197
|
+
// <tspan class="cg-arg-active"> with inline fill, and appends a muted
|
|
198
|
+
// "(was selected)" tspan when this hop renamed the param.
|
|
199
|
+
function renderSequenceLabelSvg(label, hl) {
|
|
200
|
+
const text = String(label == null ? '' : label);
|
|
201
|
+
if (!hl) return escapeHtml(text);
|
|
202
|
+
const re = new RegExp('\\b' + escapeRegex(hl.local) + '\\b');
|
|
203
|
+
const m = re.exec(text);
|
|
204
|
+
let body;
|
|
205
|
+
if (!m) {
|
|
206
|
+
body = escapeHtml(text)
|
|
207
|
+
+ ` <tspan class="cg-arg-active" style="fill:${hl.color}">${escapeHtml(hl.local)}</tspan>`;
|
|
208
|
+
} else {
|
|
209
|
+
const before = text.slice(0, m.index);
|
|
210
|
+
const after = text.slice(m.index + m[0].length);
|
|
211
|
+
body = escapeHtml(before)
|
|
212
|
+
+ `<tspan class="cg-arg-active" style="fill:${hl.color}">${escapeHtml(hl.local)}</tspan>`
|
|
213
|
+
+ escapeHtml(after);
|
|
214
|
+
}
|
|
215
|
+
if (hl.isRename) {
|
|
216
|
+
body += ` <tspan class="cg-arg-rename" style="fill:#94a3b8">(was ${escapeHtml(hl.selected)})</tspan>`;
|
|
217
|
+
}
|
|
218
|
+
return body;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function escapeRegex(s) {
|
|
222
|
+
return String(s == null ? '' : s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build a highlighted HTML fragment for a label, replacing the first
|
|
226
|
+
// occurrence of `local` (as a whole word) with a coloured span. If no
|
|
227
|
+
// textual match exists in the label, append a trailing badge so the
|
|
228
|
+
// selection is still visible. Used by pipeline-mode .col-msg.
|
|
229
|
+
function highlightLabelHtml(label, hl) {
|
|
230
|
+
const text = String(label == null ? '' : label);
|
|
231
|
+
if (!hl) return escapeHtml(text);
|
|
232
|
+
const re = new RegExp('\\b' + escapeRegex(hl.local) + '\\b');
|
|
233
|
+
const m = re.exec(text);
|
|
234
|
+
let html;
|
|
235
|
+
if (!m) {
|
|
236
|
+
html = escapeHtml(text)
|
|
237
|
+
+ ` <span class="cg-arg-active" style="color:${hl.color}">${escapeHtml(hl.local)}</span>`;
|
|
238
|
+
} else {
|
|
239
|
+
const before = text.slice(0, m.index);
|
|
240
|
+
const after = text.slice(m.index + m[0].length);
|
|
241
|
+
html = escapeHtml(before)
|
|
242
|
+
+ `<span class="cg-arg-active" style="color:${hl.color}">${escapeHtml(hl.local)}</span>`
|
|
243
|
+
+ escapeHtml(after);
|
|
244
|
+
}
|
|
245
|
+
if (hl.isRename) {
|
|
246
|
+
html += ` <span class="cg-arg-rename">(was ${escapeHtml(hl.selected)})</span>`;
|
|
247
|
+
}
|
|
248
|
+
return html;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderArchitecture(host) {
|
|
252
|
+
const arch = (window.state && window.state.data && window.state.data.architecture) || null;
|
|
253
|
+
if (!arch || !arch.components || !arch.metrics) {
|
|
254
|
+
host.innerHTML = '<div class="empty p-12 text-center text-ink-200">'
|
|
255
|
+
+ 'No infrastructure components detected. Make sure your project '
|
|
256
|
+
+ 'imports something from a supported package (express, redis, '
|
|
257
|
+
+ 'bullmq, pg, mongoose, sqlalchemy, ...). Then rebuild.</div>';
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const m = arch.metrics;
|
|
262
|
+
const components = arch.components || [];
|
|
263
|
+
const handlers = arch.handlers || [];
|
|
264
|
+
|
|
265
|
+
// Group components by tier.
|
|
266
|
+
const byTier = new Map();
|
|
267
|
+
for (const c of components) {
|
|
268
|
+
const meta = KIND_TIERS[c.kind] || { tier: 9, label: c.kind };
|
|
269
|
+
const tier = meta.tier;
|
|
270
|
+
if (!byTier.has(tier)) byTier.set(tier, { tier, label: meta.label, items: [] });
|
|
271
|
+
byTier.get(tier).items.push(c);
|
|
272
|
+
}
|
|
273
|
+
const tiers = Array.from(byTier.values()).sort((a, b) => a.tier - b.tier);
|
|
274
|
+
|
|
275
|
+
const summaryCards = [
|
|
276
|
+
{ n: m.components, l: 'Components' },
|
|
277
|
+
{ n: m.handlers, l: 'Endpoints' },
|
|
278
|
+
{ n: m.import_sites, l: 'Import sites' },
|
|
279
|
+
{ n: Object.keys(m.by_kind || {}).length, l: 'Component kinds' },
|
|
280
|
+
].map(c => `
|
|
281
|
+
<div class="rounded-lg border border-ink-600/60 bg-ink-800/60 px-4 py-3">
|
|
282
|
+
<div class="text-2xl font-semibold tracking-tight">${c.n}</div>
|
|
283
|
+
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-300 mt-1">${c.l}</div>
|
|
284
|
+
</div>`).join('');
|
|
285
|
+
|
|
286
|
+
host.innerHTML = `
|
|
287
|
+
<div class="px-6 md:px-8 py-6 space-y-6">
|
|
288
|
+
<div class="rounded-xl border border-brand-500/40 bg-gradient-to-br from-brand-700/15 via-ink-800/40 to-ink-800/40 px-5 py-4 text-sm text-ink-100">
|
|
289
|
+
<b class="text-brand-300">Architecture view.</b>
|
|
290
|
+
External services this project talks to (detected from imports).
|
|
291
|
+
Click an endpoint on the right to highlight the components a request
|
|
292
|
+
to that route reaches as it flows through the system.
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">${summaryCards}</div>
|
|
296
|
+
|
|
297
|
+
<div class="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-5">
|
|
298
|
+
<div class="rounded-xl border border-ink-600/60 bg-ink-800/40 p-5" id="arch-topology">
|
|
299
|
+
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-300 mb-3">Topology</div>
|
|
300
|
+
<div id="arch-svg-host" class="overflow-auto"></div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<aside class="rounded-xl border border-ink-600/60 bg-ink-800/40 p-4 max-h-[78vh] flex flex-col">
|
|
304
|
+
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-300 mb-2">Endpoints (${handlers.length})</div>
|
|
305
|
+
<input id="arch-search" placeholder="Filter endpoints..." class="w-full mb-3 px-3 py-2 rounded-md bg-ink-900/70 border border-ink-600/60 text-sm focus:outline-none focus:border-brand-500"/>
|
|
306
|
+
<div id="arch-handlers" class="flex-1 overflow-y-auto space-y-1.5 pr-1"></div>
|
|
307
|
+
<div class="text-[11px] text-ink-300 mt-3 leading-relaxed">
|
|
308
|
+
Click an endpoint to animate the request flow. Click again to clear.
|
|
309
|
+
</div>
|
|
310
|
+
</aside>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div class="rounded-xl border border-ink-600/60 bg-ink-800/40 p-5">
|
|
314
|
+
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-300 mb-3">Component evidence</div>
|
|
315
|
+
<div id="arch-evidence" class="space-y-3"></div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>`;
|
|
318
|
+
|
|
319
|
+
drawTopology(tiers, components, handlers);
|
|
320
|
+
renderHandlerList(handlers, components);
|
|
321
|
+
renderEvidence(components);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------- Topology SVG ----------
|
|
325
|
+
|
|
326
|
+
function drawTopology(tiers, components, handlers) {
|
|
327
|
+
const svgHost = document.getElementById('arch-svg-host');
|
|
328
|
+
const W = Math.max(820, svgHost.clientWidth || 820);
|
|
329
|
+
const ROW_H = 110;
|
|
330
|
+
const H = Math.max(360, tiers.length * ROW_H + 80);
|
|
331
|
+
|
|
332
|
+
// Place a virtual "Client" + "App code" block at the top.
|
|
333
|
+
const clientNode = { id: 'client', label: 'Client', kind: 'CLIENT', color: '#94a3b8', x: W * 0.5, y: 40 };
|
|
334
|
+
const appNode = { id: 'app', label: 'Your code', kind: 'APP', color: '#a5b4fc', x: W * 0.5, y: 110 };
|
|
335
|
+
|
|
336
|
+
// Spread each tier's components horizontally.
|
|
337
|
+
const compPos = new Map();
|
|
338
|
+
tiers.forEach((tier, ti) => {
|
|
339
|
+
const y = 200 + ti * ROW_H;
|
|
340
|
+
const items = tier.items;
|
|
341
|
+
items.forEach((c, i) => {
|
|
342
|
+
const x = ((i + 1) / (items.length + 1)) * W;
|
|
343
|
+
compPos.set(c.id, { x, y, ...c });
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Build edges: app -> every component (one per detected component).
|
|
348
|
+
const edgeList = components.map(c => {
|
|
349
|
+
const p = compPos.get(c.id);
|
|
350
|
+
return { from: appNode, to: p, color: c.color, id: c.id };
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" class="arch-svg" xmlns="http://www.w3.org/2000/svg">`;
|
|
354
|
+
|
|
355
|
+
// Tier band labels.
|
|
356
|
+
tiers.forEach((tier, ti) => {
|
|
357
|
+
const y = 200 + ti * ROW_H;
|
|
358
|
+
svg += `<text x="14" y="${y - 38}" fill="#5b6b8c" font-size="10" font-family="Inter,sans-serif" letter-spacing="1.2" text-transform="uppercase">${escapeHtml(tier.label)}</text>`;
|
|
359
|
+
svg += `<line x1="14" y1="${y - 26}" x2="${W - 14}" y2="${y - 26}" stroke="#243049" stroke-dasharray="2,4"/>`;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Edges.
|
|
363
|
+
svg += '<g id="arch-edges">';
|
|
364
|
+
for (const e of edgeList) {
|
|
365
|
+
const d = `M ${e.from.x} ${e.from.y + 22} C ${e.from.x} ${(e.from.y + e.to.y) / 2}, ${e.to.x} ${(e.from.y + e.to.y) / 2}, ${e.to.x} ${e.to.y - 22}`;
|
|
366
|
+
svg += `<path id="edge-${escapeHtml(e.id)}" d="${d}" fill="none" stroke="${e.color}" stroke-opacity="0.18" stroke-width="2" />`;
|
|
367
|
+
}
|
|
368
|
+
svg += '</g>';
|
|
369
|
+
|
|
370
|
+
// Animated dot host (initially empty).
|
|
371
|
+
svg += '<g id="arch-flow-dot"></g>';
|
|
372
|
+
|
|
373
|
+
// Nodes.
|
|
374
|
+
function rect(node, opts = {}) {
|
|
375
|
+
const w = opts.w || 130, h = opts.h || 44;
|
|
376
|
+
const x = node.x - w / 2, y = node.y - h / 2;
|
|
377
|
+
return `
|
|
378
|
+
<g class="arch-node" data-node-id="${escapeHtml(node.id)}" transform="translate(${x},${y})">
|
|
379
|
+
<rect width="${w}" height="${h}" rx="10" ry="10"
|
|
380
|
+
fill="${node.color}" fill-opacity="0.18"
|
|
381
|
+
stroke="${node.color}" stroke-width="1.2"/>
|
|
382
|
+
<text x="${w / 2}" y="${h / 2 + 4}" text-anchor="middle"
|
|
383
|
+
fill="#e6ecf5" font-size="12" font-family="Inter,sans-serif"
|
|
384
|
+
font-weight="500">${escapeHtml(node.label)}</text>
|
|
385
|
+
</g>`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
svg += rect(clientNode, { w: 110, h: 36 });
|
|
389
|
+
svg += rect(appNode, { w: 140, h: 40 });
|
|
390
|
+
svg += `<path d="M ${clientNode.x} ${clientNode.y + 18} L ${appNode.x} ${appNode.y - 20}" stroke="#5b6b8c" stroke-width="1.5" stroke-dasharray="4,3" fill="none"/>`;
|
|
391
|
+
|
|
392
|
+
components.forEach(c => {
|
|
393
|
+
const p = compPos.get(c.id);
|
|
394
|
+
if (p) svg += rect(p);
|
|
395
|
+
});
|
|
396
|
+
svg += '</svg>';
|
|
397
|
+
svgHost.innerHTML = svg;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------- Endpoint list + flow animation ----------
|
|
401
|
+
|
|
402
|
+
function renderHandlerList(handlers, components) {
|
|
403
|
+
const compById = new Map(components.map(c => [c.id, c]));
|
|
404
|
+
const host = document.getElementById('arch-handlers');
|
|
405
|
+
if (!handlers.length) {
|
|
406
|
+
host.innerHTML = '<div class="text-ink-300 text-xs px-2 py-3">No HANDLER-tagged routes found. Run <code>codegraph build</code> on a project with routes (FastAPI, Flask, NestJS, Express decorators, ...).</div>';
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function methodColor(method) {
|
|
411
|
+
const m = (method || '').toUpperCase();
|
|
412
|
+
if (m === 'GET') return 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40';
|
|
413
|
+
if (m === 'POST') return 'bg-amber-500/20 text-amber-300 border-amber-500/40';
|
|
414
|
+
if (m === 'PUT') return 'bg-sky-500/20 text-sky-300 border-sky-500/40';
|
|
415
|
+
if (m === 'PATCH') return 'bg-violet-500/20 text-violet-300 border-violet-500/40';
|
|
416
|
+
if (m === 'DELETE') return 'bg-rose-500/20 text-rose-300 border-rose-500/40';
|
|
417
|
+
return 'bg-ink-600/40 text-ink-200 border-ink-500/40';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
host.innerHTML = handlers.map((h, i) => `
|
|
421
|
+
<button data-i="${i}" class="arch-handler w-full text-left rounded-md border border-ink-600/50 bg-ink-900/40 hover:border-brand-500/60 hover:bg-ink-700/40 px-3 py-2 transition">
|
|
422
|
+
<div class="flex items-center gap-2">
|
|
423
|
+
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded border ${methodColor(h.method)}">${escapeHtml(h.method || '???')}</span>
|
|
424
|
+
<span class="font-mono text-xs text-ink-100 truncate">${escapeHtml(h.path || h.name)}</span>
|
|
425
|
+
</div>
|
|
426
|
+
<div class="text-[10px] text-ink-300 mt-1 truncate" title="${escapeHtml(h.qualname)}">${escapeHtml(h.qualname || h.name)}</div>
|
|
427
|
+
<div class="text-[10px] text-ink-300 mt-0.5">${(h.components || []).length} component${(h.components||[]).length === 1 ? '' : 's'}</div>
|
|
428
|
+
</button>`).join('');
|
|
429
|
+
|
|
430
|
+
host.querySelectorAll('.arch-handler').forEach(btn => {
|
|
431
|
+
btn.addEventListener('click', () => {
|
|
432
|
+
const i = parseInt(btn.dataset.i, 10);
|
|
433
|
+
host.querySelectorAll('.arch-handler').forEach(b => b.classList.remove('ring-2', 'ring-brand-500'));
|
|
434
|
+
btn.classList.add('ring-2', 'ring-brand-500');
|
|
435
|
+
openLearnModal(handlers[i], compById, Array.from(compById.values()));
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const search = document.getElementById('arch-search');
|
|
440
|
+
if (search) {
|
|
441
|
+
search.addEventListener('input', e => {
|
|
442
|
+
const q = e.target.value.toLowerCase();
|
|
443
|
+
host.querySelectorAll('.arch-handler').forEach((btn, i) => {
|
|
444
|
+
const h = handlers[i];
|
|
445
|
+
const hay = ((h.method || '') + ' ' + (h.path || '') + ' ' + (h.qualname || '')).toLowerCase();
|
|
446
|
+
btn.style.display = hay.includes(q) ? '' : 'none';
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------- Learn Mode: full request lifecycle modal ----------
|
|
453
|
+
|
|
454
|
+
function buildLifecycleStages(handler, components) {
|
|
455
|
+
const reachable = (handler.components || [])
|
|
456
|
+
.map(id => components.find(c => c.id === id))
|
|
457
|
+
.filter(Boolean);
|
|
458
|
+
const caches = reachable.filter(c => c.kind === 'CACHE');
|
|
459
|
+
const dbs = reachable.filter(c => c.kind === 'DB' || c.kind === 'ORM');
|
|
460
|
+
const queues = reachable.filter(c => c.kind === 'QUEUE' || c.kind === 'BROKER');
|
|
461
|
+
const externals = reachable.filter(c => c.kind === 'EXTERNAL_API' || c.kind === 'HTTP_CLIENT');
|
|
462
|
+
const ws = reachable.filter(c => c.kind === 'WEB_SERVER');
|
|
463
|
+
const wsLabel = ws[0]?.label || 'App Server';
|
|
464
|
+
|
|
465
|
+
const lanes = [
|
|
466
|
+
{ id: 'client', label: 'Mobile Client', color: '#94a3b8', icon: 'phone' },
|
|
467
|
+
{ id: 'net', label: 'Internet', color: '#5b6b8c', icon: 'globe' },
|
|
468
|
+
{ id: 'tls', label: 'TLS Layer', color: '#f59e0b', icon: 'lock' },
|
|
469
|
+
{ id: 'server', label: wsLabel, color: '#a78bfa', icon: 'server' },
|
|
470
|
+
{ id: 'mw', label: 'Middleware', color: '#22d3ee', icon: 'shield' },
|
|
471
|
+
{ id: 'handler', label: 'Route Handler', color: '#6366f1', icon: 'cpu' },
|
|
472
|
+
];
|
|
473
|
+
if (caches.length) lanes.push({ id: 'cache', label: caches[0].label, color: caches[0].color, icon: 'zap' });
|
|
474
|
+
if (dbs.length) lanes.push({ id: 'db', label: dbs[0].label, color: dbs[0].color, icon: 'database' });
|
|
475
|
+
if (queues.length) lanes.push({ id: 'queue', label: queues[0].label, color: queues[0].color, icon: 'list' });
|
|
476
|
+
if (externals.length) lanes.push({ id: 'ext', label: externals[0].label,color: externals[0].color, icon: 'cloud' });
|
|
477
|
+
|
|
478
|
+
const M = (handler.method || 'GET').toUpperCase();
|
|
479
|
+
const P = handler.path || '/';
|
|
480
|
+
|
|
481
|
+
const stages = [];
|
|
482
|
+
const add = (from, to, label, detail, kind) => stages.push({ from, to, label, detail, kind: kind || 'app' });
|
|
483
|
+
|
|
484
|
+
// ---- Phase 1: Network handshake (static) ----
|
|
485
|
+
add('client', 'net',
|
|
486
|
+
'DNS lookup',
|
|
487
|
+
`Mobile resolves the API hostname into an IP address. The OS asks its configured DNS server (e.g. 8.8.8.8) for the A/AAAA record. Once it has the IP, it can open a TCP socket.`,
|
|
488
|
+
'net');
|
|
489
|
+
add('client', 'server',
|
|
490
|
+
'TCP SYN',
|
|
491
|
+
`Three-way handshake step 1. The client sends a TCP packet with the SYN flag set, proposing an initial sequence number. No data is sent yet — this is just "I want to talk to you."`,
|
|
492
|
+
'net');
|
|
493
|
+
add('server', 'client',
|
|
494
|
+
'TCP SYN-ACK',
|
|
495
|
+
`Step 2. Server replies acknowledging the client's SYN and sending its OWN SYN with its initial sequence number. Now both sides know each other's starting sequence.`,
|
|
496
|
+
'net');
|
|
497
|
+
add('client', 'server',
|
|
498
|
+
'TCP ACK',
|
|
499
|
+
`Step 3. Client acknowledges the server's SYN. The TCP connection is now ESTABLISHED — bytes can flow reliably and in-order in both directions.`,
|
|
500
|
+
'net');
|
|
501
|
+
|
|
502
|
+
// ---- Phase 2: TLS handshake ----
|
|
503
|
+
add('client', 'tls',
|
|
504
|
+
'TLS ClientHello',
|
|
505
|
+
`For HTTPS, the client now starts a TLS handshake on top of TCP. ClientHello lists the TLS versions and cipher suites the client supports plus a random nonce. SNI (Server Name Indication) tells the server which hostname this connection is for.`,
|
|
506
|
+
'tls');
|
|
507
|
+
add('tls', 'client',
|
|
508
|
+
'ServerHello + Certificate',
|
|
509
|
+
`Server picks a cipher suite, sends its own random nonce, and presents its X.509 certificate chain. The client verifies the certificate against trusted CAs and checks that the hostname matches the SAN field.`,
|
|
510
|
+
'tls');
|
|
511
|
+
add('client', 'tls',
|
|
512
|
+
'Key exchange + Finished',
|
|
513
|
+
`Client and server agree on a shared symmetric key (ECDHE in modern TLS). Both sides send a "Finished" record encrypted with the new key, proving the handshake wasn't tampered with. Encryption is now active.`,
|
|
514
|
+
'tls');
|
|
515
|
+
|
|
516
|
+
// ---- Phase 3: HTTP request ----
|
|
517
|
+
add('client', 'server',
|
|
518
|
+
`HTTP ${M} ${P}`,
|
|
519
|
+
`The actual API call. Now encrypted inside the TLS tunnel. Includes headers (Authorization, Content-Type, ...) and, for ${M === 'GET' || M === 'DELETE' ? 'most ' + M + ' requests, no body' : 'POST/PUT/PATCH, a JSON body'}.`,
|
|
520
|
+
'app');
|
|
521
|
+
add('server', 'mw',
|
|
522
|
+
'Run middleware chain',
|
|
523
|
+
`Express runs every middleware registered before this route — typically: body parser → CORS → request logger → auth (verify JWT / session cookie) → rate limit → validation. Any middleware can short-circuit with an error response.`,
|
|
524
|
+
'app');
|
|
525
|
+
add('mw', 'handler',
|
|
526
|
+
`Dispatch to ${handler.name || 'handler'}()`,
|
|
527
|
+
`Auth + validation passed. Express invokes the handler function registered for this route. ${handler.qualname ? 'Source: ' + handler.qualname : ''}`,
|
|
528
|
+
'app');
|
|
529
|
+
|
|
530
|
+
// ---- Phase 4: Project-specific data layer ----
|
|
531
|
+
// Prefer real dataflow.hops when the backend HLD payload includes them
|
|
532
|
+
// (codegraph >= v0.3). Fall back to the generic infra-component animation
|
|
533
|
+
// otherwise so older builds still render something useful.
|
|
534
|
+
const dfSeg = buildDataflowSegment(handler);
|
|
535
|
+
let dataflowMeta = dfSeg.meta;
|
|
536
|
+
if (dfSeg.stages.length) {
|
|
537
|
+
// Wire DF lanes onto the swim lane list (avoid id collisions with the
|
|
538
|
+
// generic lanes by giving them df-* prefixes inside DF_ROLE_LANES).
|
|
539
|
+
dfSeg.lanes.forEach(L => lanes.push(L));
|
|
540
|
+
// Re-anchor the first hop's "from" to the existing 'handler' lane that
|
|
541
|
+
// Phase 3 already established. dfSeg.stages[0].from is already 'handler'.
|
|
542
|
+
dfSeg.stages.forEach(s => stages.push(s));
|
|
543
|
+
} else {
|
|
544
|
+
if (caches.length) {
|
|
545
|
+
add('handler', 'cache',
|
|
546
|
+
'GET cached value',
|
|
547
|
+
`Handler checks ${caches[0].label} first to avoid hitting the DB. The lookup key is usually composed from request params — e.g. user:123:profile.`,
|
|
548
|
+
'data');
|
|
549
|
+
add('cache', 'handler',
|
|
550
|
+
'cache miss / hit',
|
|
551
|
+
`If cached, return immediately and skip the DB. If miss, fall through to DB and write-back to cache after.`,
|
|
552
|
+
'data');
|
|
553
|
+
}
|
|
554
|
+
if (dbs.length) {
|
|
555
|
+
add('handler', 'db',
|
|
556
|
+
`Query ${dbs[0].label}`,
|
|
557
|
+
`Handler issues a query through ${dbs[0].label}. Connection is reused from a pool. The DB plans the query, hits indexes, returns rows.`,
|
|
558
|
+
'data');
|
|
559
|
+
add('db', 'handler',
|
|
560
|
+
'rows / documents',
|
|
561
|
+
`Result set comes back as JS objects. Handler may shape them (filter fields, populate relations) before responding.`,
|
|
562
|
+
'data');
|
|
563
|
+
}
|
|
564
|
+
if (queues.length) {
|
|
565
|
+
add('handler', 'queue',
|
|
566
|
+
'Enqueue background job',
|
|
567
|
+
`Long-running work (sending email, generating report, calling slow third-party API) is pushed onto ${queues[0].label} so the response can return fast. A separate worker process picks it up.`,
|
|
568
|
+
'data');
|
|
569
|
+
}
|
|
570
|
+
if (externals.length) {
|
|
571
|
+
add('handler', 'ext',
|
|
572
|
+
`Call ${externals[0].label}`,
|
|
573
|
+
`Handler makes an outbound HTTP call. Each external call adds latency + failure modes — usually wrapped in a try/catch with timeout.`,
|
|
574
|
+
'data');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---- Phase 5: response ----
|
|
579
|
+
add('handler', 'mw',
|
|
580
|
+
'res.json(payload)',
|
|
581
|
+
`Handler builds the response payload (often a JSON object) and hands it back to Express. Response middleware (compression, CORS headers, logging) runs in reverse order.`,
|
|
582
|
+
'app');
|
|
583
|
+
add('mw', 'server',
|
|
584
|
+
'Build HTTP response',
|
|
585
|
+
`Express assembles the status line + headers + body. Status is 2xx for success, 4xx for client errors, 5xx for server errors.`,
|
|
586
|
+
'app');
|
|
587
|
+
add('server', 'tls',
|
|
588
|
+
'TLS encrypt',
|
|
589
|
+
`Response bytes are encrypted with the symmetric key established earlier and chunked into TLS records.`,
|
|
590
|
+
'tls');
|
|
591
|
+
add('tls', 'client',
|
|
592
|
+
`HTTP ${M === 'POST' ? '201' : '200'} OK + body`,
|
|
593
|
+
`The encrypted response travels back through TCP. Client decrypts, parses headers, hands the body to the app code. UI updates.`,
|
|
594
|
+
'net');
|
|
595
|
+
|
|
596
|
+
return { lanes, stages, dataflowMeta };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Tiny chip shown next to the method/path in the modal header. Communicates
|
|
600
|
+
// whether Phase 4 is rendering real DF4 data, the empty fallback, or a
|
|
601
|
+
// low-confidence trace.
|
|
602
|
+
function renderDataflowChip(meta) {
|
|
603
|
+
if (!meta || !meta.available) return '';
|
|
604
|
+
if (meta.empty) {
|
|
605
|
+
return `<div data-df-chip="empty"
|
|
606
|
+
class="mt-1 inline-flex items-center gap-1.5 text-[10px] uppercase tracking-[0.1em]
|
|
607
|
+
px-2 py-0.5 rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-200">
|
|
608
|
+
<i data-lucide="alert-triangle" style="width:10px;height:10px"></i>
|
|
609
|
+
no trace data — run <code class="font-mono">codegraph build</code> first
|
|
610
|
+
</div>`;
|
|
611
|
+
}
|
|
612
|
+
if (meta.lowConfidence) {
|
|
613
|
+
return `<div data-df-chip="low-confidence"
|
|
614
|
+
class="mt-1 inline-flex items-center gap-1.5 text-[10px] uppercase tracking-[0.1em]
|
|
615
|
+
px-2 py-0.5 rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-200">
|
|
616
|
+
<i data-lucide="alert-circle" style="width:10px;height:10px"></i>
|
|
617
|
+
low-confidence trace (${(meta.confidence * 100).toFixed(0)}%)
|
|
618
|
+
</div>`;
|
|
619
|
+
}
|
|
620
|
+
return `<div data-df-chip="ok"
|
|
621
|
+
class="mt-1 inline-flex items-center gap-1.5 text-[10px] uppercase tracking-[0.1em]
|
|
622
|
+
px-2 py-0.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200">
|
|
623
|
+
<i data-lucide="git-branch" style="width:10px;height:10px"></i>
|
|
624
|
+
${meta.hopCount} hop${meta.hopCount === 1 ? '' : 's'} · ${(meta.confidence * 100).toFixed(0)}%
|
|
625
|
+
</div>`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function openLearnModal(handler, compById, components) {
|
|
629
|
+
closeLearnModal();
|
|
630
|
+
const { lanes, stages, dataflowMeta } = buildLifecycleStages(handler, components);
|
|
631
|
+
|
|
632
|
+
const VALID_MODES = ['pipeline', 'diagram', 'lanes'];
|
|
633
|
+
let mode = 'pipeline';
|
|
634
|
+
try {
|
|
635
|
+
const saved = localStorage.getItem('arch.lifecycleMode');
|
|
636
|
+
if (VALID_MODES.includes(saved)) mode = saved;
|
|
637
|
+
} catch (_) {}
|
|
638
|
+
|
|
639
|
+
ensurePipelineStyles();
|
|
640
|
+
ensureDiagramStyles();
|
|
641
|
+
ensureArgFlowStyles();
|
|
642
|
+
|
|
643
|
+
// DF5 / arg-flow stretch: extract starting param keys + initial selection.
|
|
644
|
+
const argKeys = getArgFlowKeys(handler);
|
|
645
|
+
let selectedArg = argKeys.length ? argKeys[0] : null;
|
|
646
|
+
function selectedArgColor() {
|
|
647
|
+
const i = argKeys.indexOf(selectedArg);
|
|
648
|
+
return argColor(i >= 0 ? i : 0);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const overlay = document.createElement('div');
|
|
652
|
+
overlay.id = 'learn-modal';
|
|
653
|
+
overlay.className = 'fixed inset-0 z-[100] bg-ink-950/85 backdrop-blur-sm flex items-stretch';
|
|
654
|
+
overlay.innerHTML = `
|
|
655
|
+
<div class="flex-1 flex flex-col">
|
|
656
|
+
<div class="flex items-center justify-between border-b border-ink-600/60 bg-ink-900/80 px-6 py-4">
|
|
657
|
+
<div class="flex items-center gap-3 min-w-0">
|
|
658
|
+
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-brand-500 to-accent-cyan flex items-center justify-center text-ink-950 font-bold">
|
|
659
|
+
<i data-lucide="route"></i>
|
|
660
|
+
</div>
|
|
661
|
+
<div class="min-w-0">
|
|
662
|
+
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-300">Request Lifecycle</div>
|
|
663
|
+
<div class="text-lg font-semibold tracking-tight truncate">
|
|
664
|
+
<span class="font-mono text-brand-300">${escapeHtml(handler.method || '???')}</span>
|
|
665
|
+
<span class="font-mono text-ink-100 ml-2">${escapeHtml(handler.path || '')}</span>
|
|
666
|
+
</div>
|
|
667
|
+
${renderDataflowChip(dataflowMeta)}
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="flex items-center gap-2">
|
|
671
|
+
<div class="learn-mode-toggle flex rounded-md border border-ink-600/60 overflow-hidden mr-1" title="Cycle visualization (V)">
|
|
672
|
+
<button id="learn-mode-pipeline" class="learn-mode-btn">Pipeline</button>
|
|
673
|
+
<button id="learn-mode-diagram" class="learn-mode-btn">Diagram</button>
|
|
674
|
+
<button id="learn-mode-lanes" class="learn-mode-btn">Lanes</button>
|
|
675
|
+
</div>
|
|
676
|
+
<button id="learn-prev" class="icon-btn" title="Previous step (←)"><i data-lucide="chevron-left"></i></button>
|
|
677
|
+
<button id="learn-play" class="rounded-md bg-brand-600 hover:bg-brand-500 text-white px-3 py-1.5 text-sm font-medium flex items-center gap-1.5 transition">
|
|
678
|
+
<i data-lucide="play" class="w-4 h-4"></i><span>Play</span>
|
|
679
|
+
</button>
|
|
680
|
+
<button id="learn-next" class="icon-btn" title="Next step (→)"><i data-lucide="chevron-right"></i></button>
|
|
681
|
+
<button id="learn-close" class="icon-btn ml-2" title="Close (Esc)"><i data-lucide="x"></i></button>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<div class="flex-1 grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-0 min-h-0">
|
|
686
|
+
<div class="overflow-auto p-6 bg-ink-900/40">
|
|
687
|
+
<div id="learn-arg-picker-host">${renderArgPicker(argKeys, selectedArg)}</div>
|
|
688
|
+
<div id="learn-svg-host" class="rounded-xl border border-ink-600/60 bg-ink-800/50 p-4"></div>
|
|
689
|
+
</div>
|
|
690
|
+
<aside class="overflow-y-auto border-l border-ink-600/60 bg-ink-800/60 p-5">
|
|
691
|
+
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-300 mb-2">
|
|
692
|
+
Step <span id="learn-step-num">1</span> / <span id="learn-step-total">${stages.length}</span>
|
|
693
|
+
</div>
|
|
694
|
+
<div id="learn-stage-label" class="text-base font-semibold tracking-tight mb-1"></div>
|
|
695
|
+
<div id="learn-stage-kind" class="text-[11px] uppercase tracking-[0.1em] mb-4"></div>
|
|
696
|
+
<div id="learn-stage-detail" class="text-sm leading-relaxed text-ink-100"></div>
|
|
697
|
+
<div class="text-[11px] text-ink-300 mt-6 pt-4 border-t border-ink-600/60 leading-relaxed">
|
|
698
|
+
<b class="text-ink-100">Keys:</b> ←/→ step, Space play/pause, V toggle view, Esc close.
|
|
699
|
+
</div>
|
|
700
|
+
</aside>
|
|
701
|
+
</div>
|
|
702
|
+
</div>`;
|
|
703
|
+
document.body.appendChild(overlay);
|
|
704
|
+
|
|
705
|
+
let cur = 0;
|
|
706
|
+
let playing = false;
|
|
707
|
+
let timer = null;
|
|
708
|
+
|
|
709
|
+
function argCtx() {
|
|
710
|
+
return { selected: selectedArg, color: selectedArgColor() };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function applyStep(i, animate) {
|
|
714
|
+
const ax = argCtx();
|
|
715
|
+
if (mode === 'pipeline') pipelineSetStep(i, animate, lanes, stages, ax);
|
|
716
|
+
else if (mode === 'diagram') diagramSetStep(i, animate, lanes, stages, ax);
|
|
717
|
+
else highlightArrow(i, animate);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function renderForMode() {
|
|
721
|
+
const ax = argCtx();
|
|
722
|
+
if (mode === 'pipeline') drawPipeline(lanes, stages);
|
|
723
|
+
else if (mode === 'diagram') drawDiagram(lanes, stages, ax);
|
|
724
|
+
else drawLearnSequence(lanes, stages, ax);
|
|
725
|
+
updateModeButtons();
|
|
726
|
+
applyStep(cur, false);
|
|
727
|
+
wireArgPicker();
|
|
728
|
+
if (window.lucide) lucide.createIcons();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function refreshArgPickerActive() {
|
|
732
|
+
const host = document.getElementById('learn-arg-picker-host');
|
|
733
|
+
if (!host) return;
|
|
734
|
+
host.querySelectorAll('.cg-arg-chip').forEach(btn => {
|
|
735
|
+
const k = btn.getAttribute('data-arg-key');
|
|
736
|
+
const active = k === selectedArg;
|
|
737
|
+
btn.classList.toggle('is-active', active);
|
|
738
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function wireArgPicker() {
|
|
743
|
+
const host = document.getElementById('learn-arg-picker-host');
|
|
744
|
+
if (!host) return;
|
|
745
|
+
host.querySelectorAll('.cg-arg-chip').forEach(btn => {
|
|
746
|
+
btn.addEventListener('click', () => {
|
|
747
|
+
const k = btn.getAttribute('data-arg-key');
|
|
748
|
+
if (!k || k === selectedArg) return;
|
|
749
|
+
selectedArg = k;
|
|
750
|
+
refreshArgPickerActive();
|
|
751
|
+
renderForMode();
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function updateModeButtons() {
|
|
757
|
+
['pipeline', 'diagram', 'lanes'].forEach(m => {
|
|
758
|
+
const el = document.getElementById('learn-mode-' + m);
|
|
759
|
+
if (el) el.classList.toggle('is-active', mode === m);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function switchMode(newMode) {
|
|
764
|
+
if (newMode === mode) return;
|
|
765
|
+
mode = newMode;
|
|
766
|
+
try { localStorage.setItem('arch.lifecycleMode', mode); } catch (_) {}
|
|
767
|
+
renderForMode();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function setStep(i, opts) {
|
|
771
|
+
cur = Math.max(0, Math.min(stages.length - 1, i));
|
|
772
|
+
const s = stages[cur];
|
|
773
|
+
document.getElementById('learn-step-num').textContent = cur + 1;
|
|
774
|
+
document.getElementById('learn-stage-label').textContent = s.label;
|
|
775
|
+
const kindEl = document.getElementById('learn-stage-kind');
|
|
776
|
+
kindEl.textContent = s.kind === 'net' ? 'Network · TCP' :
|
|
777
|
+
s.kind === 'tls' ? 'Network · TLS' :
|
|
778
|
+
s.kind === 'data' ? 'Data layer' : 'Application';
|
|
779
|
+
kindEl.style.color = s.kind === 'net' ? '#94a3b8' :
|
|
780
|
+
s.kind === 'tls' ? '#fbbf24' :
|
|
781
|
+
s.kind === 'data' ? '#22d3ee' : '#a78bfa';
|
|
782
|
+
document.getElementById('learn-stage-detail').textContent = s.detail;
|
|
783
|
+
applyStep(cur, opts && opts.animate);
|
|
784
|
+
}
|
|
785
|
+
function play() {
|
|
786
|
+
if (cur >= stages.length - 1) cur = -1;
|
|
787
|
+
playing = true;
|
|
788
|
+
document.getElementById('learn-play').innerHTML = '<i data-lucide="pause" class="w-4 h-4"></i><span>Pause</span>';
|
|
789
|
+
lucide.createIcons();
|
|
790
|
+
tick();
|
|
791
|
+
}
|
|
792
|
+
function pause() {
|
|
793
|
+
playing = false;
|
|
794
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
795
|
+
document.getElementById('learn-play').innerHTML = '<i data-lucide="play" class="w-4 h-4"></i><span>Play</span>';
|
|
796
|
+
lucide.createIcons();
|
|
797
|
+
}
|
|
798
|
+
function tick() {
|
|
799
|
+
if (!playing) return;
|
|
800
|
+
if (cur >= stages.length - 1) { pause(); return; }
|
|
801
|
+
setStep(cur + 1, { animate: true });
|
|
802
|
+
const dur = stages[cur].kind === 'data' ? 1400 : 1100;
|
|
803
|
+
timer = setTimeout(tick, dur);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
renderForMode();
|
|
807
|
+
setStep(0, { animate: true });
|
|
808
|
+
lucide.createIcons();
|
|
809
|
+
document.getElementById('learn-play').onclick = () => playing ? pause() : play();
|
|
810
|
+
document.getElementById('learn-prev').onclick = () => { pause(); setStep(cur - 1, { animate: true }); };
|
|
811
|
+
document.getElementById('learn-next').onclick = () => { pause(); setStep(cur + 1, { animate: true }); };
|
|
812
|
+
document.getElementById('learn-close').onclick = closeLearnModal;
|
|
813
|
+
document.getElementById('learn-mode-pipeline').onclick = () => switchMode('pipeline');
|
|
814
|
+
document.getElementById('learn-mode-diagram').onclick = () => switchMode('diagram');
|
|
815
|
+
document.getElementById('learn-mode-lanes').onclick = () => switchMode('lanes');
|
|
816
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeLearnModal(); });
|
|
817
|
+
document.addEventListener('keydown', learnKeyHandler);
|
|
818
|
+
|
|
819
|
+
function learnKeyHandler(e) {
|
|
820
|
+
if (!document.getElementById('learn-modal')) {
|
|
821
|
+
document.removeEventListener('keydown', learnKeyHandler);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (e.key === 'Escape') { closeLearnModal(); }
|
|
825
|
+
else if (e.key === 'ArrowLeft') { pause(); setStep(cur - 1, { animate: true }); }
|
|
826
|
+
else if (e.key === 'ArrowRight') { pause(); setStep(cur + 1, { animate: true }); }
|
|
827
|
+
else if (e.key === ' ') { e.preventDefault(); playing ? pause() : play(); }
|
|
828
|
+
else if (e.key === 'v' || e.key === 'V') {
|
|
829
|
+
const order = ['pipeline', 'diagram', 'lanes'];
|
|
830
|
+
switchMode(order[(order.indexOf(mode) + 1) % order.length]);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Auto-play after a brief beat so user sees the first step.
|
|
835
|
+
setTimeout(() => { if (document.getElementById('learn-modal')) play(); }, 600);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function closeLearnModal() {
|
|
839
|
+
const m = document.getElementById('learn-modal');
|
|
840
|
+
if (m) m.remove();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ---------- Pipeline mode (horizontal strip + scrolling log) ----------
|
|
844
|
+
|
|
845
|
+
function ensurePipelineStyles() {
|
|
846
|
+
if (document.getElementById('arch-pipeline-styles')) return;
|
|
847
|
+
const style = document.createElement('style');
|
|
848
|
+
style.id = 'arch-pipeline-styles';
|
|
849
|
+
style.textContent = `
|
|
850
|
+
.learn-mode-btn {
|
|
851
|
+
background: rgba(15,23,42,0.55);
|
|
852
|
+
color: #94a3b8;
|
|
853
|
+
padding: 6px 12px;
|
|
854
|
+
font-size: 11px;
|
|
855
|
+
font-weight: 600;
|
|
856
|
+
letter-spacing: 0.04em;
|
|
857
|
+
text-transform: uppercase;
|
|
858
|
+
transition: background 0.15s, color 0.15s;
|
|
859
|
+
}
|
|
860
|
+
.learn-mode-btn:hover { background: rgba(99,102,241,0.18); color: #c7d2fe; }
|
|
861
|
+
.learn-mode-btn.is-active {
|
|
862
|
+
background: linear-gradient(135deg, #6366f1, #06b6d4);
|
|
863
|
+
color: #0b1020;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#learn-pipeline-host { display:flex; flex-direction:column; gap:18px; }
|
|
867
|
+
|
|
868
|
+
#pipe-strip {
|
|
869
|
+
position: relative;
|
|
870
|
+
display: flex;
|
|
871
|
+
flex-wrap: wrap;
|
|
872
|
+
align-items: center;
|
|
873
|
+
gap: 10px;
|
|
874
|
+
padding: 18px 16px 14px;
|
|
875
|
+
background: rgba(15,23,42,0.45);
|
|
876
|
+
border: 1px solid rgba(91,107,140,0.35);
|
|
877
|
+
border-radius: 14px;
|
|
878
|
+
}
|
|
879
|
+
.pipe-box {
|
|
880
|
+
--lane-color: #94a3b8;
|
|
881
|
+
display: flex; align-items: center; gap: 7px;
|
|
882
|
+
padding: 9px 13px; border-radius: 10px;
|
|
883
|
+
background: rgba(15,23,42,0.65);
|
|
884
|
+
border: 1.5px solid rgba(148,163,184,0.35);
|
|
885
|
+
color: #e2e8f0;
|
|
886
|
+
font-size: 12px; font-weight: 500;
|
|
887
|
+
opacity: 0.55;
|
|
888
|
+
transition: all 0.18s ease;
|
|
889
|
+
white-space: nowrap;
|
|
890
|
+
}
|
|
891
|
+
.pipe-box.is-past {
|
|
892
|
+
opacity: 0.85;
|
|
893
|
+
background: rgba(15,23,42,0.7);
|
|
894
|
+
border-color: var(--lane-color);
|
|
895
|
+
}
|
|
896
|
+
.pipe-box.is-active {
|
|
897
|
+
opacity: 1;
|
|
898
|
+
background: rgba(15,23,42,0.85);
|
|
899
|
+
border-color: var(--lane-color);
|
|
900
|
+
box-shadow: 0 0 0 3px rgba(99,102,241,0.18),
|
|
901
|
+
0 0 22px rgba(99,102,241,0.35);
|
|
902
|
+
transform: translateY(-1px);
|
|
903
|
+
}
|
|
904
|
+
.pipe-arrow {
|
|
905
|
+
color: rgba(148,163,184,0.55);
|
|
906
|
+
font-size: 16px;
|
|
907
|
+
user-select: none;
|
|
908
|
+
}
|
|
909
|
+
#pipe-dot {
|
|
910
|
+
position: absolute;
|
|
911
|
+
width: 11px; height: 11px;
|
|
912
|
+
border-radius: 50%;
|
|
913
|
+
background: #22d3ee;
|
|
914
|
+
box-shadow: 0 0 14px #22d3ee, 0 0 4px #fff;
|
|
915
|
+
pointer-events: none;
|
|
916
|
+
opacity: 0;
|
|
917
|
+
transition: left 600ms cubic-bezier(0.4,0,0.2,1),
|
|
918
|
+
top 600ms cubic-bezier(0.4,0,0.2,1),
|
|
919
|
+
opacity 0.2s linear;
|
|
920
|
+
z-index: 5;
|
|
921
|
+
}
|
|
922
|
+
#pipe-dot.is-on { opacity: 1; }
|
|
923
|
+
|
|
924
|
+
#pipe-log {
|
|
925
|
+
max-height: 360px;
|
|
926
|
+
overflow-y: auto;
|
|
927
|
+
padding: 12px 16px;
|
|
928
|
+
background: rgba(2,6,23,0.7);
|
|
929
|
+
border: 1px solid rgba(91,107,140,0.35);
|
|
930
|
+
border-radius: 12px;
|
|
931
|
+
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
932
|
+
font-size: 12px;
|
|
933
|
+
line-height: 1.7;
|
|
934
|
+
}
|
|
935
|
+
.pipe-log-row {
|
|
936
|
+
display: grid;
|
|
937
|
+
grid-template-columns: 28px 90px auto 1fr;
|
|
938
|
+
gap: 10px;
|
|
939
|
+
padding: 1px 0;
|
|
940
|
+
animation: pipeLogIn 0.32s ease both;
|
|
941
|
+
}
|
|
942
|
+
.pipe-log-row .col-num { color: #475569; }
|
|
943
|
+
.pipe-log-row .col-time { color: #64748b; }
|
|
944
|
+
.pipe-log-row .col-tag { font-weight: 500; white-space: nowrap; }
|
|
945
|
+
.pipe-log-row .col-msg { color: #cbd5e1; }
|
|
946
|
+
.pipe-log-row.is-current .col-num,
|
|
947
|
+
.pipe-log-row.is-current .col-time { color: #94a3b8; }
|
|
948
|
+
.pipe-log-row.is-current .col-msg { color: #f8fafc; font-weight: 500; }
|
|
949
|
+
@keyframes pipeLogIn {
|
|
950
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
951
|
+
to { opacity: 1; transform: translateY(0); }
|
|
952
|
+
}
|
|
953
|
+
`;
|
|
954
|
+
document.head.appendChild(style);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function drawPipeline(lanes /*, stages */) {
|
|
958
|
+
const host = document.getElementById('learn-svg-host');
|
|
959
|
+
if (!host) return;
|
|
960
|
+
let html = '<div id="learn-pipeline-host">';
|
|
961
|
+
html += '<div id="pipe-strip">';
|
|
962
|
+
lanes.forEach((L, i) => {
|
|
963
|
+
if (i > 0) html += '<div class="pipe-arrow">→</div>';
|
|
964
|
+
html += `<div class="pipe-box" data-lane="${escapeHtml(L.id)}" style="--lane-color:${L.color}; color:#e6ecf5;">
|
|
965
|
+
<i data-lucide="${escapeHtml(L.icon)}" style="width:14px;height:14px;color:${L.color};"></i>
|
|
966
|
+
<span>${escapeHtml(L.label)}</span>
|
|
967
|
+
</div>`;
|
|
968
|
+
});
|
|
969
|
+
html += '<div id="pipe-dot"></div>';
|
|
970
|
+
html += '</div>';
|
|
971
|
+
html += '<div id="pipe-log" aria-live="polite"></div>';
|
|
972
|
+
html += '</div>';
|
|
973
|
+
host.innerHTML = html;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function pipelineSetStep(i, animate, lanes, stages, argCtx) {
|
|
977
|
+
const stripBoxes = document.querySelectorAll('#pipe-strip .pipe-box');
|
|
978
|
+
if (!stripBoxes.length) return;
|
|
979
|
+
const stage = stages[i];
|
|
980
|
+
if (!stage) return;
|
|
981
|
+
|
|
982
|
+
const visited = new Set();
|
|
983
|
+
for (let k = 0; k <= i; k++) {
|
|
984
|
+
visited.add(stages[k].from);
|
|
985
|
+
visited.add(stages[k].to);
|
|
986
|
+
}
|
|
987
|
+
stripBoxes.forEach(box => {
|
|
988
|
+
const lane = box.dataset.lane;
|
|
989
|
+
box.classList.remove('is-active', 'is-past');
|
|
990
|
+
if (lane === stage.from || lane === stage.to) box.classList.add('is-active');
|
|
991
|
+
else if (visited.has(lane)) box.classList.add('is-past');
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
const log = document.getElementById('pipe-log');
|
|
995
|
+
if (log) {
|
|
996
|
+
const have = new Map();
|
|
997
|
+
log.querySelectorAll('.pipe-log-row').forEach(r => {
|
|
998
|
+
have.set(parseInt(r.dataset.step, 10), r);
|
|
999
|
+
});
|
|
1000
|
+
// arg-flow may shift highlights between renders, so always rebuild rows.
|
|
1001
|
+
have.forEach((row) => row.remove());
|
|
1002
|
+
for (let k = 0; k <= i; k++) {
|
|
1003
|
+
log.appendChild(buildLogRow(k, stages[k], argCtx));
|
|
1004
|
+
}
|
|
1005
|
+
log.querySelectorAll('.pipe-log-row').forEach(r => {
|
|
1006
|
+
r.classList.toggle('is-current', parseInt(r.dataset.step, 10) === i);
|
|
1007
|
+
});
|
|
1008
|
+
// Pin newest line into view.
|
|
1009
|
+
const last = log.querySelector(`.pipe-log-row[data-step="${i}"]`);
|
|
1010
|
+
if (last) log.scrollTop = last.offsetTop - 24;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
movePipelineDot(stage.to, animate);
|
|
1014
|
+
if (window.lucide) lucide.createIcons();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function buildLogRow(i, stage, argCtx) {
|
|
1018
|
+
const row = document.createElement('div');
|
|
1019
|
+
row.className = 'pipe-log-row';
|
|
1020
|
+
row.dataset.step = String(i);
|
|
1021
|
+
const color = stage.kind === 'net' ? '#94a3b8' :
|
|
1022
|
+
stage.kind === 'tls' ? '#fbbf24' :
|
|
1023
|
+
stage.kind === 'data' ? '#22d3ee' : '#a78bfa';
|
|
1024
|
+
// Cosmetic monotonic timestamp (not real time).
|
|
1025
|
+
const totalMs = i * 23 + 12;
|
|
1026
|
+
const sec = Math.floor(totalMs / 1000) % 60;
|
|
1027
|
+
const ms = totalMs % 1000;
|
|
1028
|
+
const ts = `18:42:${String(sec).padStart(2,'0')}.${String(ms).padStart(3,'0')}`;
|
|
1029
|
+
row.appendChild(buildLogCell('col-num', String(i + 1).padStart(2, '0')));
|
|
1030
|
+
row.appendChild(buildLogCell('col-time', ts));
|
|
1031
|
+
const tag = buildLogCell('col-tag', '[' + stage.from + ' → ' + stage.to + ']');
|
|
1032
|
+
tag.style.color = color;
|
|
1033
|
+
row.appendChild(tag);
|
|
1034
|
+
const hl = argCtx && argCtx.selected
|
|
1035
|
+
? argHighlightForStage(stage, argCtx.selected, argCtx.color)
|
|
1036
|
+
: null;
|
|
1037
|
+
const msg = document.createElement('span');
|
|
1038
|
+
msg.className = 'col-msg';
|
|
1039
|
+
if (hl) {
|
|
1040
|
+
msg.innerHTML = highlightLabelHtml(stage.label, hl);
|
|
1041
|
+
} else {
|
|
1042
|
+
msg.textContent = String(stage.label);
|
|
1043
|
+
}
|
|
1044
|
+
const hop = stage.hop;
|
|
1045
|
+
if (hop && hop.qualname) {
|
|
1046
|
+
msg.dataset.hopQn = hop.qualname;
|
|
1047
|
+
if (hop.file) msg.dataset.hopFile = hop.file;
|
|
1048
|
+
if (hop.line) msg.dataset.hopLine = String(hop.line);
|
|
1049
|
+
msg.style.cursor = 'pointer';
|
|
1050
|
+
msg.title = (hop.file || hop.qualname) + (hop.line ? ':' + hop.line : '');
|
|
1051
|
+
msg.addEventListener('click', () => {
|
|
1052
|
+
if (typeof window !== 'undefined' && typeof window.jumpToQualname === 'function') {
|
|
1053
|
+
window.jumpToQualname(hop.qualname);
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
row.appendChild(msg);
|
|
1058
|
+
return row;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function buildLogCell(cls, text) {
|
|
1062
|
+
const span = document.createElement('span');
|
|
1063
|
+
span.className = cls;
|
|
1064
|
+
span.textContent = String(text);
|
|
1065
|
+
return span;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function movePipelineDot(laneId, animate) {
|
|
1069
|
+
const strip = document.getElementById('pipe-strip');
|
|
1070
|
+
const dot = document.getElementById('pipe-dot');
|
|
1071
|
+
if (!strip || !dot) return;
|
|
1072
|
+
const target = strip.querySelector(`.pipe-box[data-lane="${laneId}"]`);
|
|
1073
|
+
if (!target) return;
|
|
1074
|
+
const stripRect = strip.getBoundingClientRect();
|
|
1075
|
+
const tRect = target.getBoundingClientRect();
|
|
1076
|
+
const left = tRect.left - stripRect.left + tRect.width / 2 - 5.5;
|
|
1077
|
+
const top = tRect.top - stripRect.top - 14;
|
|
1078
|
+
if (!animate) {
|
|
1079
|
+
const prev = dot.style.transition;
|
|
1080
|
+
dot.style.transition = 'none';
|
|
1081
|
+
dot.style.left = `${left}px`;
|
|
1082
|
+
dot.style.top = `${top}px`;
|
|
1083
|
+
dot.classList.add('is-on');
|
|
1084
|
+
// restore transition next frame
|
|
1085
|
+
requestAnimationFrame(() => { dot.style.transition = prev || ''; });
|
|
1086
|
+
} else {
|
|
1087
|
+
dot.classList.add('is-on');
|
|
1088
|
+
dot.style.left = `${left}px`;
|
|
1089
|
+
dot.style.top = `${top}px`;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Render the lane diagram into #learn-svg-host. Returns nothing — arrows
|
|
1094
|
+
// are looked up by id during animation.
|
|
1095
|
+
function drawLearnSequence(lanes, stages, argCtx) {
|
|
1096
|
+
const host = document.getElementById('learn-svg-host');
|
|
1097
|
+
const W = Math.max(900, host.clientWidth || 900);
|
|
1098
|
+
const LANE_H_TOP = 70;
|
|
1099
|
+
const ROW_H = 56;
|
|
1100
|
+
const H = LANE_H_TOP + 30 + stages.length * ROW_H + 30;
|
|
1101
|
+
|
|
1102
|
+
const laneX = new Map();
|
|
1103
|
+
lanes.forEach((L, i) => {
|
|
1104
|
+
laneX.set(L.id, ((i + 0.5) / lanes.length) * W);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
let svg = `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" xmlns="http://www.w3.org/2000/svg" id="learn-svg">`;
|
|
1108
|
+
|
|
1109
|
+
// Vertical lifelines
|
|
1110
|
+
lanes.forEach(L => {
|
|
1111
|
+
const x = laneX.get(L.id);
|
|
1112
|
+
svg += `<line x1="${x}" y1="${LANE_H_TOP}" x2="${x}" y2="${H - 20}" stroke="${L.color}" stroke-opacity="0.25" stroke-width="1.5" stroke-dasharray="2,4"/>`;
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// Lane headers
|
|
1116
|
+
lanes.forEach(L => {
|
|
1117
|
+
const x = laneX.get(L.id);
|
|
1118
|
+
const w = Math.min(160, (W / lanes.length) - 10);
|
|
1119
|
+
svg += `
|
|
1120
|
+
<g transform="translate(${x - w / 2}, 14)">
|
|
1121
|
+
<rect width="${w}" height="44" rx="10" ry="10"
|
|
1122
|
+
fill="${L.color}" fill-opacity="0.18" stroke="${L.color}" stroke-width="1.4"/>
|
|
1123
|
+
<text x="${w / 2}" y="27" text-anchor="middle"
|
|
1124
|
+
fill="#e6ecf5" font-size="12" font-family="Inter,sans-serif" font-weight="600">${escapeHtml(L.label)}</text>
|
|
1125
|
+
</g>`;
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Step rows + arrows
|
|
1129
|
+
stages.forEach((s, i) => {
|
|
1130
|
+
const y = LANE_H_TOP + 30 + i * ROW_H + ROW_H / 2;
|
|
1131
|
+
const x1 = laneX.get(s.from);
|
|
1132
|
+
const x2 = laneX.get(s.to);
|
|
1133
|
+
if (x1 == null || x2 == null) return;
|
|
1134
|
+
const dir = x2 >= x1 ? 1 : -1;
|
|
1135
|
+
const color = s.kind === 'net' ? '#94a3b8' :
|
|
1136
|
+
s.kind === 'tls' ? '#fbbf24' :
|
|
1137
|
+
s.kind === 'data' ? '#22d3ee' : '#a78bfa';
|
|
1138
|
+
const ax1 = x1 + dir * 6;
|
|
1139
|
+
const ax2 = x2 - dir * 6;
|
|
1140
|
+
|
|
1141
|
+
// Step number gutter
|
|
1142
|
+
svg += `<text x="14" y="${y + 4}" font-size="10" fill="#5b6b8c" font-family="Inter,sans-serif">${i + 1}</text>`;
|
|
1143
|
+
|
|
1144
|
+
// Faint baseline arrow (dimmed when inactive). For dataflow hops we
|
|
1145
|
+
// attach data-hop-qn so the click handler can jumpToQualname().
|
|
1146
|
+
const hop = s.hop;
|
|
1147
|
+
const hopAttrs = hop && hop.qualname
|
|
1148
|
+
? ` data-hop-qn="${escapeHtml(hop.qualname)}"`
|
|
1149
|
+
+ ` data-hop-file="${escapeHtml(hop.file || '')}"`
|
|
1150
|
+
+ ` data-hop-line="${escapeHtml(String(hop.line || ''))}"`
|
|
1151
|
+
+ ` style="cursor:pointer"`
|
|
1152
|
+
: '';
|
|
1153
|
+
const hl = argCtx && argCtx.selected
|
|
1154
|
+
? argHighlightForStage(s, argCtx.selected, argCtx.color)
|
|
1155
|
+
: null;
|
|
1156
|
+
const labelSvg = renderSequenceLabelSvg(s.label, hl);
|
|
1157
|
+
svg += `<g class="learn-arrow" id="learn-arrow-${i}" data-color="${color}" opacity="0.25"${hopAttrs}>
|
|
1158
|
+
<line x1="${ax1}" y1="${y}" x2="${ax2}" y2="${y}"
|
|
1159
|
+
stroke="${color}" stroke-width="2" marker-end="url(#learn-head-${i})"/>
|
|
1160
|
+
<text x="${(ax1 + ax2) / 2}" y="${y - 8}" text-anchor="middle"
|
|
1161
|
+
fill="#e6ecf5" font-size="11" font-family="Inter,sans-serif"
|
|
1162
|
+
font-weight="500">${labelSvg}</text>
|
|
1163
|
+
</g>
|
|
1164
|
+
<defs>
|
|
1165
|
+
<marker id="learn-head-${i}" viewBox="0 0 10 10" refX="9" refY="5"
|
|
1166
|
+
markerWidth="6" markerHeight="6" orient="auto">
|
|
1167
|
+
<path d="M0,0 L10,5 L0,10 z" fill="${color}"/>
|
|
1168
|
+
</marker>
|
|
1169
|
+
</defs>`;
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// Animated dot host
|
|
1173
|
+
svg += '<g id="learn-dot-host"></g>';
|
|
1174
|
+
svg += '</svg>';
|
|
1175
|
+
host.innerHTML = svg;
|
|
1176
|
+
|
|
1177
|
+
// Wire hop click → jumpToQualname for swimlane arrows.
|
|
1178
|
+
host.querySelectorAll('.learn-arrow[data-hop-qn]').forEach(g => {
|
|
1179
|
+
g.addEventListener('click', () => {
|
|
1180
|
+
const qn = g.getAttribute('data-hop-qn');
|
|
1181
|
+
if (qn && typeof window !== 'undefined' && typeof window.jumpToQualname === 'function') {
|
|
1182
|
+
window.jumpToQualname(qn);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function highlightArrow(i, animate) {
|
|
1189
|
+
document.querySelectorAll('.learn-arrow').forEach((g, idx) => {
|
|
1190
|
+
const past = idx < i;
|
|
1191
|
+
const cur = idx === i;
|
|
1192
|
+
g.setAttribute('opacity', cur ? '1' : (past ? '0.55' : '0.18'));
|
|
1193
|
+
const line = g.querySelector('line');
|
|
1194
|
+
if (line) line.setAttribute('stroke-width', cur ? '3.2' : '2');
|
|
1195
|
+
});
|
|
1196
|
+
if (!animate) return;
|
|
1197
|
+
const g = document.getElementById('learn-arrow-' + i);
|
|
1198
|
+
if (!g) return;
|
|
1199
|
+
const line = g.querySelector('line');
|
|
1200
|
+
if (!line) return;
|
|
1201
|
+
const x1 = parseFloat(line.getAttribute('x1'));
|
|
1202
|
+
const y1 = parseFloat(line.getAttribute('y1'));
|
|
1203
|
+
const x2 = parseFloat(line.getAttribute('x2'));
|
|
1204
|
+
const color = g.getAttribute('data-color') || '#22d3ee';
|
|
1205
|
+
const host = document.getElementById('learn-dot-host');
|
|
1206
|
+
if (!host) return;
|
|
1207
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
1208
|
+
const dot = document.createElementNS(ns, 'circle');
|
|
1209
|
+
dot.setAttribute('r', '5');
|
|
1210
|
+
dot.setAttribute('fill', color);
|
|
1211
|
+
dot.setAttribute('cy', y1);
|
|
1212
|
+
dot.setAttribute('cx', x1);
|
|
1213
|
+
host.appendChild(dot);
|
|
1214
|
+
const start = performance.now();
|
|
1215
|
+
const dur = 700;
|
|
1216
|
+
function step(now) {
|
|
1217
|
+
const t = Math.min(1, (now - start) / dur);
|
|
1218
|
+
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
1219
|
+
dot.setAttribute('cx', x1 + (x2 - x1) * eased);
|
|
1220
|
+
if (t < 1) requestAnimationFrame(step);
|
|
1221
|
+
else { dot.setAttribute('opacity', '0'); setTimeout(() => dot.remove(), 180); }
|
|
1222
|
+
}
|
|
1223
|
+
requestAnimationFrame(step);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// ---------- Component evidence ----------
|
|
1227
|
+
|
|
1228
|
+
function renderEvidence(components) {
|
|
1229
|
+
const host = document.getElementById('arch-evidence');
|
|
1230
|
+
if (!components.length) {
|
|
1231
|
+
host.innerHTML = '<div class="text-ink-300 text-xs">none</div>';
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
host.innerHTML = components.map(c => `
|
|
1235
|
+
<div class="rounded-md border border-ink-600/50 bg-ink-900/40 px-3 py-2.5">
|
|
1236
|
+
<div class="flex items-center gap-2 mb-1.5">
|
|
1237
|
+
<span class="inline-block w-2.5 h-2.5 rounded-sm" style="background:${c.color}"></span>
|
|
1238
|
+
<span class="font-medium text-sm">${escapeHtml(c.label)}</span>
|
|
1239
|
+
<span class="text-[10px] uppercase tracking-[0.1em] text-ink-300">${escapeHtml(c.kind)}</span>
|
|
1240
|
+
<span class="ml-auto text-[10px] text-ink-300">${c.count} import${c.count === 1 ? '' : 's'} in ${c.files.length} file${c.files.length === 1 ? '' : 's'}</span>
|
|
1241
|
+
</div>
|
|
1242
|
+
<div class="text-[11px] font-mono text-ink-200 space-y-0.5">
|
|
1243
|
+
${(c.evidence || []).map(e => `<div class="truncate">${escapeHtml(e)}</div>`).join('')}
|
|
1244
|
+
</div>
|
|
1245
|
+
</div>`).join('');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ---------- Diagram mode (ByteMonk-style system map + traveling packet) ----------
|
|
1249
|
+
|
|
1250
|
+
const DIAG_ROW = {
|
|
1251
|
+
client: 0, net: 0, tls: 0,
|
|
1252
|
+
server: 1, mw: 1, handler: 1,
|
|
1253
|
+
cache: 2, db: 2, queue: 2, ext: 2,
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
function ensureDiagramStyles() {
|
|
1257
|
+
if (document.getElementById('arch-diagram-styles')) return;
|
|
1258
|
+
const style = document.createElement('style');
|
|
1259
|
+
style.id = 'arch-diagram-styles';
|
|
1260
|
+
style.textContent = `
|
|
1261
|
+
#diagram-host {
|
|
1262
|
+
position: relative;
|
|
1263
|
+
background:
|
|
1264
|
+
radial-gradient(ellipse at top, rgba(99,102,241,0.07), transparent 60%),
|
|
1265
|
+
rgba(2,6,23,0.55);
|
|
1266
|
+
border: 1px solid rgba(91,107,140,0.35);
|
|
1267
|
+
border-radius: 14px;
|
|
1268
|
+
overflow: hidden;
|
|
1269
|
+
}
|
|
1270
|
+
#diagram-svg { display: block; }
|
|
1271
|
+
.diag-card {
|
|
1272
|
+
--lane-color: #94a3b8;
|
|
1273
|
+
display: flex;
|
|
1274
|
+
flex-direction: column;
|
|
1275
|
+
align-items: center;
|
|
1276
|
+
justify-content: center;
|
|
1277
|
+
gap: 4px;
|
|
1278
|
+
background: rgba(15,23,42,0.85);
|
|
1279
|
+
border: 1.5px solid rgba(148,163,184,0.32);
|
|
1280
|
+
border-radius: 14px;
|
|
1281
|
+
padding: 10px 8px;
|
|
1282
|
+
color: #e6ecf5;
|
|
1283
|
+
font-size: 12px; font-weight: 500;
|
|
1284
|
+
text-align: center;
|
|
1285
|
+
transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease, opacity 0.22s ease;
|
|
1286
|
+
opacity: 0.65;
|
|
1287
|
+
box-sizing: border-box;
|
|
1288
|
+
z-index: 2;
|
|
1289
|
+
}
|
|
1290
|
+
.diag-card .diag-icon {
|
|
1291
|
+
display: flex;
|
|
1292
|
+
align-items: center;
|
|
1293
|
+
justify-content: center;
|
|
1294
|
+
width: 36px; height: 36px;
|
|
1295
|
+
border-radius: 50%;
|
|
1296
|
+
background: color-mix(in srgb, var(--lane-color) 18%, rgba(15,23,42,0.6));
|
|
1297
|
+
margin-bottom: 2px;
|
|
1298
|
+
}
|
|
1299
|
+
.diag-card .diag-label { line-height: 1.2; padding: 0 2px; }
|
|
1300
|
+
.diag-card.is-visited {
|
|
1301
|
+
opacity: 0.92;
|
|
1302
|
+
border-color: var(--lane-color);
|
|
1303
|
+
}
|
|
1304
|
+
.diag-card.is-active {
|
|
1305
|
+
opacity: 1;
|
|
1306
|
+
border-color: var(--lane-color);
|
|
1307
|
+
box-shadow:
|
|
1308
|
+
0 0 0 3px rgba(34,211,238,0.22),
|
|
1309
|
+
0 0 26px rgba(34,211,238,0.45);
|
|
1310
|
+
}
|
|
1311
|
+
.diag-card.is-pulse { animation: diagPulse 0.6s ease both; }
|
|
1312
|
+
@keyframes diagPulse {
|
|
1313
|
+
0% { transform: scale(1); }
|
|
1314
|
+
45% { transform: scale(1.07); }
|
|
1315
|
+
100% { transform: scale(1); }
|
|
1316
|
+
}
|
|
1317
|
+
.diag-badge {
|
|
1318
|
+
position: absolute;
|
|
1319
|
+
top: -8px; right: -8px;
|
|
1320
|
+
min-width: 20px; height: 20px;
|
|
1321
|
+
padding: 0 6px;
|
|
1322
|
+
border-radius: 999px;
|
|
1323
|
+
background: linear-gradient(135deg, #6366f1, #06b6d4);
|
|
1324
|
+
color: #0b1020;
|
|
1325
|
+
font-size: 11px; font-weight: 700;
|
|
1326
|
+
display: flex; align-items: center; justify-content: center;
|
|
1327
|
+
border: 2px solid #0b1020;
|
|
1328
|
+
z-index: 3;
|
|
1329
|
+
}
|
|
1330
|
+
#diag-active-label {
|
|
1331
|
+
position: absolute;
|
|
1332
|
+
padding: 6px 10px;
|
|
1333
|
+
background: rgba(2,6,23,0.92);
|
|
1334
|
+
border: 1px solid rgba(34,211,238,0.6);
|
|
1335
|
+
border-radius: 999px;
|
|
1336
|
+
font-size: 11px; font-weight: 500;
|
|
1337
|
+
color: #e6ecf5;
|
|
1338
|
+
white-space: nowrap;
|
|
1339
|
+
pointer-events: none;
|
|
1340
|
+
transform: translate(-50%, -50%);
|
|
1341
|
+
opacity: 0;
|
|
1342
|
+
transition: opacity 0.2s ease, left 0.6s cubic-bezier(0.4,0,0.2,1), top 0.6s cubic-bezier(0.4,0,0.2,1);
|
|
1343
|
+
z-index: 4;
|
|
1344
|
+
}
|
|
1345
|
+
#diag-active-label.is-on { opacity: 1; }
|
|
1346
|
+
`;
|
|
1347
|
+
document.head.appendChild(style);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function ensureArgFlowStyles() {
|
|
1351
|
+
if (document.getElementById('arch-argflow-styles')) return;
|
|
1352
|
+
const style = document.createElement('style');
|
|
1353
|
+
style.id = 'arch-argflow-styles';
|
|
1354
|
+
style.textContent = `
|
|
1355
|
+
.cg-arg-picker {
|
|
1356
|
+
display: flex;
|
|
1357
|
+
flex-wrap: wrap;
|
|
1358
|
+
align-items: center;
|
|
1359
|
+
gap: 8px;
|
|
1360
|
+
padding: 10px 12px;
|
|
1361
|
+
margin-bottom: 12px;
|
|
1362
|
+
background: rgba(2,6,23,0.55);
|
|
1363
|
+
border: 1px solid rgba(91,107,140,0.3);
|
|
1364
|
+
border-radius: 12px;
|
|
1365
|
+
}
|
|
1366
|
+
.cg-arg-picker-hint {
|
|
1367
|
+
font-size: 10.5px;
|
|
1368
|
+
text-transform: uppercase;
|
|
1369
|
+
letter-spacing: 0.12em;
|
|
1370
|
+
color: #94a3b8;
|
|
1371
|
+
margin-right: 4px;
|
|
1372
|
+
}
|
|
1373
|
+
.cg-arg-chip {
|
|
1374
|
+
--arg-color: #94a3b8;
|
|
1375
|
+
display: inline-flex;
|
|
1376
|
+
align-items: center;
|
|
1377
|
+
gap: 6px;
|
|
1378
|
+
padding: 4px 10px;
|
|
1379
|
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
1380
|
+
font-size: 11.5px;
|
|
1381
|
+
font-weight: 600;
|
|
1382
|
+
background: rgba(15,23,42,0.7);
|
|
1383
|
+
border: 1.5px solid color-mix(in srgb, var(--arg-color) 35%, transparent);
|
|
1384
|
+
border-radius: 999px;
|
|
1385
|
+
cursor: pointer;
|
|
1386
|
+
transition: background 0.15s, border-color 0.15s, transform 0.15s;
|
|
1387
|
+
}
|
|
1388
|
+
.cg-arg-chip:hover {
|
|
1389
|
+
background: color-mix(in srgb, var(--arg-color) 14%, rgba(15,23,42,0.9));
|
|
1390
|
+
border-color: var(--arg-color);
|
|
1391
|
+
}
|
|
1392
|
+
.cg-arg-chip.is-active {
|
|
1393
|
+
background: color-mix(in srgb, var(--arg-color) 22%, rgba(15,23,42,0.9));
|
|
1394
|
+
border-color: var(--arg-color);
|
|
1395
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--arg-color) 35%, transparent);
|
|
1396
|
+
transform: translateY(-1px);
|
|
1397
|
+
}
|
|
1398
|
+
.cg-arg-chip-dot {
|
|
1399
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
1400
|
+
}
|
|
1401
|
+
.cg-arg-active {
|
|
1402
|
+
font-weight: 700;
|
|
1403
|
+
text-shadow: 0 0 8px currentColor;
|
|
1404
|
+
}
|
|
1405
|
+
.cg-arg-rename {
|
|
1406
|
+
color: #94a3b8;
|
|
1407
|
+
font-style: italic;
|
|
1408
|
+
font-size: 0.92em;
|
|
1409
|
+
margin-left: 2px;
|
|
1410
|
+
}
|
|
1411
|
+
.cg-arg-traveler {
|
|
1412
|
+
filter: drop-shadow(0 0 4px currentColor);
|
|
1413
|
+
opacity: 0.95;
|
|
1414
|
+
}
|
|
1415
|
+
`;
|
|
1416
|
+
document.head.appendChild(style);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function diagBezierPath(x1, y1, x2, y2) {
|
|
1420
|
+
if (Math.abs(y1 - y2) < 30) {
|
|
1421
|
+
// same row → arc upward to avoid overlapping cards
|
|
1422
|
+
const mx = (x1 + x2) / 2;
|
|
1423
|
+
const cy = y1 - 50;
|
|
1424
|
+
return `M ${x1} ${y1} Q ${mx} ${cy} ${x2} ${y2}`;
|
|
1425
|
+
}
|
|
1426
|
+
const my = (y1 + y2) / 2;
|
|
1427
|
+
return `M ${x1} ${y1} C ${x1} ${my} ${x2} ${my} ${x2} ${y2}`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function drawDiagram(lanes, stages, argCtx) {
|
|
1431
|
+
const host = document.getElementById('learn-svg-host');
|
|
1432
|
+
if (!host) return;
|
|
1433
|
+
const W = Math.max(640, host.clientWidth || 920);
|
|
1434
|
+
const cardW = 132, cardH = 84;
|
|
1435
|
+
const ROW_Y = [80, 240, 400];
|
|
1436
|
+
const H = ROW_Y[2] + cardH / 2 + 40;
|
|
1437
|
+
|
|
1438
|
+
const rows = [[], [], []];
|
|
1439
|
+
lanes.forEach(L => {
|
|
1440
|
+
const r = DIAG_ROW[L.id] != null ? DIAG_ROW[L.id] : 1;
|
|
1441
|
+
rows[r].push(L);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
const pos = new Map();
|
|
1445
|
+
rows.forEach((rowLanes, r) => {
|
|
1446
|
+
const n = rowLanes.length;
|
|
1447
|
+
rowLanes.forEach((L, i) => {
|
|
1448
|
+
const x = ((i + 1) / (n + 1)) * W;
|
|
1449
|
+
pos.set(L.id, { x, y: ROW_Y[r] });
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// Unique edges from real stage transitions.
|
|
1454
|
+
const seen = new Set();
|
|
1455
|
+
const edges = [];
|
|
1456
|
+
stages.forEach(s => {
|
|
1457
|
+
const k = s.from + '|' + s.to;
|
|
1458
|
+
if (seen.has(k) || !pos.has(s.from) || !pos.has(s.to)) return;
|
|
1459
|
+
seen.add(k);
|
|
1460
|
+
edges.push({ from: s.from, to: s.to });
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
let html = `<div id="diagram-host" style="width:${W}px;height:${H}px;">`;
|
|
1464
|
+
html += `<svg id="diagram-svg" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:0;pointer-events:none;">`;
|
|
1465
|
+
html += `<defs>
|
|
1466
|
+
<filter id="diag-glow" x="-100%" y="-100%" width="300%" height="300%">
|
|
1467
|
+
<feGaussianBlur stdDeviation="5"/>
|
|
1468
|
+
</filter>
|
|
1469
|
+
</defs>`;
|
|
1470
|
+
html += '<g id="diagram-edges">';
|
|
1471
|
+
edges.forEach(e => {
|
|
1472
|
+
const a = pos.get(e.from), b = pos.get(e.to);
|
|
1473
|
+
const d = diagBezierPath(a.x, a.y, b.x, b.y);
|
|
1474
|
+
const id = `dge-${escapeHtml(e.from)}-${escapeHtml(e.to)}`;
|
|
1475
|
+
html += `<path id="${id}" d="${d}" stroke="rgba(148,163,184,0.22)" stroke-width="1.5" fill="none"/>`;
|
|
1476
|
+
});
|
|
1477
|
+
html += '</g>';
|
|
1478
|
+
html += '<circle id="diag-packet-glow" r="14" fill="#22d3ee" opacity="0" filter="url(#diag-glow)"/>';
|
|
1479
|
+
html += '<circle id="diag-packet" r="6" fill="#22d3ee" stroke="#fff" stroke-width="1.5" opacity="0"/>';
|
|
1480
|
+
|
|
1481
|
+
// Arg-flow travellers: a SMIL-only (CSS/SVG) loop of dots that ride the
|
|
1482
|
+
// bezier paths between hops where the selected param is present. No JS
|
|
1483
|
+
// animation loop required.
|
|
1484
|
+
if (argCtx && argCtx.selected) {
|
|
1485
|
+
const sel = argCtx.selected;
|
|
1486
|
+
const color = argCtx.color;
|
|
1487
|
+
const segments = [];
|
|
1488
|
+
for (let k = 0; k < stages.length; k++) {
|
|
1489
|
+
const s = stages[k];
|
|
1490
|
+
if (!s.hop) continue;
|
|
1491
|
+
const here = hopArgLocalName(s.hop, sel);
|
|
1492
|
+
if (!here || here.local == null) continue;
|
|
1493
|
+
const pathId = `dge-${s.from}-${s.to}`;
|
|
1494
|
+
if (!pos.has(s.from) || !pos.has(s.to)) continue;
|
|
1495
|
+
segments.push({ pathId, idx: segments.length });
|
|
1496
|
+
}
|
|
1497
|
+
const SEG_DUR = 0.9; // seconds per segment
|
|
1498
|
+
const cycle = Math.max(1, segments.length) * SEG_DUR + 0.6;
|
|
1499
|
+
segments.forEach((seg) => {
|
|
1500
|
+
const begin = (seg.idx * SEG_DUR).toFixed(2) + 's';
|
|
1501
|
+
html += `<circle class="cg-arg-traveler" r="5"
|
|
1502
|
+
fill="${color}" stroke="#fff" stroke-width="1.2"
|
|
1503
|
+
data-arg-segment="${seg.idx}">
|
|
1504
|
+
<animateMotion dur="${SEG_DUR}s"
|
|
1505
|
+
begin="${begin};travel-cycle-${seg.idx}.end+${(cycle - SEG_DUR).toFixed(2)}s"
|
|
1506
|
+
id="travel-cycle-${seg.idx}"
|
|
1507
|
+
fill="freeze" rotate="auto">
|
|
1508
|
+
<mpath xlink:href="#${seg.pathId}" href="#${seg.pathId}"/>
|
|
1509
|
+
</animateMotion>
|
|
1510
|
+
</circle>`;
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
html += '</svg>';
|
|
1515
|
+
|
|
1516
|
+
lanes.forEach(L => {
|
|
1517
|
+
const p = pos.get(L.id);
|
|
1518
|
+
if (!p) return;
|
|
1519
|
+
const left = p.x - cardW / 2;
|
|
1520
|
+
const top = p.y - cardH / 2;
|
|
1521
|
+
html += `
|
|
1522
|
+
<div class="diag-card" data-lane="${escapeHtml(L.id)}"
|
|
1523
|
+
style="position:absolute;left:${left}px;top:${top}px;width:${cardW}px;height:${cardH}px;--lane-color:${L.color};">
|
|
1524
|
+
<span class="diag-badge" style="display:none">1</span>
|
|
1525
|
+
<div class="diag-icon"><i data-lucide="${escapeHtml(L.icon)}" style="width:20px;height:20px;color:${L.color};"></i></div>
|
|
1526
|
+
<div class="diag-label">${escapeHtml(L.label)}</div>
|
|
1527
|
+
</div>`;
|
|
1528
|
+
});
|
|
1529
|
+
html += '<div id="diag-active-label"></div>';
|
|
1530
|
+
html += '</div>';
|
|
1531
|
+
host.innerHTML = html;
|
|
1532
|
+
if (window.lucide) lucide.createIcons();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function diagramSetStep(i, animate, lanes, stages, argCtx) {
|
|
1536
|
+
const stage = stages[i];
|
|
1537
|
+
if (!stage) return;
|
|
1538
|
+
const cards = document.querySelectorAll('#diagram-host .diag-card');
|
|
1539
|
+
if (!cards.length) return;
|
|
1540
|
+
|
|
1541
|
+
// First step number per lane (so we keep the entry order on the badge).
|
|
1542
|
+
const visitNum = new Map();
|
|
1543
|
+
for (let k = 0; k <= i; k++) {
|
|
1544
|
+
const s = stages[k];
|
|
1545
|
+
if (!visitNum.has(s.from)) visitNum.set(s.from, k + 1);
|
|
1546
|
+
if (!visitNum.has(s.to)) visitNum.set(s.to, k + 1);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
cards.forEach(card => {
|
|
1550
|
+
const lane = card.dataset.lane;
|
|
1551
|
+
const isActive = (lane === stage.from || lane === stage.to);
|
|
1552
|
+
const visited = visitNum.has(lane);
|
|
1553
|
+
card.classList.remove('is-active', 'is-visited', 'is-pulse');
|
|
1554
|
+
if (isActive) card.classList.add('is-active');
|
|
1555
|
+
else if (visited) card.classList.add('is-visited');
|
|
1556
|
+
const badge = card.querySelector('.diag-badge');
|
|
1557
|
+
if (badge) {
|
|
1558
|
+
if (visited) {
|
|
1559
|
+
badge.style.display = '';
|
|
1560
|
+
badge.textContent = String(visitNum.get(lane));
|
|
1561
|
+
} else {
|
|
1562
|
+
badge.style.display = 'none';
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
// Edge highlighting.
|
|
1568
|
+
document.querySelectorAll('#diagram-edges path').forEach(p => {
|
|
1569
|
+
p.setAttribute('stroke', 'rgba(148,163,184,0.18)');
|
|
1570
|
+
p.setAttribute('stroke-width', '1.5');
|
|
1571
|
+
});
|
|
1572
|
+
const edgeId = `dge-${stage.from}-${stage.to}`;
|
|
1573
|
+
const activeEdge = document.getElementById(edgeId);
|
|
1574
|
+
const kindColor = stage.kind === 'net' ? '#94a3b8' :
|
|
1575
|
+
stage.kind === 'tls' ? '#fbbf24' :
|
|
1576
|
+
stage.kind === 'data' ? '#22d3ee' : '#a78bfa';
|
|
1577
|
+
if (activeEdge) {
|
|
1578
|
+
activeEdge.setAttribute('stroke', kindColor);
|
|
1579
|
+
activeEdge.setAttribute('stroke-width', '2.5');
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
animateDiagPacket(activeEdge, stage, animate, kindColor);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function animateDiagPacket(pathEl, stage, animate, color) {
|
|
1586
|
+
const dot = document.getElementById('diag-packet');
|
|
1587
|
+
const glow = document.getElementById('diag-packet-glow');
|
|
1588
|
+
const lab = document.getElementById('diag-active-label');
|
|
1589
|
+
if (!dot || !glow) return;
|
|
1590
|
+
|
|
1591
|
+
if (!pathEl) {
|
|
1592
|
+
dot.setAttribute('opacity', '0');
|
|
1593
|
+
glow.setAttribute('opacity', '0');
|
|
1594
|
+
if (lab) lab.classList.remove('is-on');
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
dot.setAttribute('fill', color);
|
|
1598
|
+
glow.setAttribute('fill', color);
|
|
1599
|
+
|
|
1600
|
+
const len = pathEl.getTotalLength();
|
|
1601
|
+
const dur = 700;
|
|
1602
|
+
|
|
1603
|
+
const setAt = (t) => {
|
|
1604
|
+
const p = pathEl.getPointAtLength(len * t);
|
|
1605
|
+
dot.setAttribute('cx', p.x); dot.setAttribute('cy', p.y);
|
|
1606
|
+
glow.setAttribute('cx', p.x); glow.setAttribute('cy', p.y);
|
|
1607
|
+
if (lab && t > 0.45 && t < 0.6) {
|
|
1608
|
+
lab.style.left = p.x + 'px';
|
|
1609
|
+
lab.style.top = (p.y - 22) + 'px';
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
dot.setAttribute('opacity', '1');
|
|
1614
|
+
glow.setAttribute('opacity', '0.55');
|
|
1615
|
+
if (lab) {
|
|
1616
|
+
lab.textContent = stage.label;
|
|
1617
|
+
lab.classList.add('is-on');
|
|
1618
|
+
const mid = pathEl.getPointAtLength(len * 0.5);
|
|
1619
|
+
lab.style.left = mid.x + 'px';
|
|
1620
|
+
lab.style.top = (mid.y - 22) + 'px';
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (!animate) { setAt(1); return; }
|
|
1624
|
+
|
|
1625
|
+
const start = performance.now();
|
|
1626
|
+
function step(now) {
|
|
1627
|
+
const t = Math.min(1, (now - start) / dur);
|
|
1628
|
+
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
1629
|
+
setAt(eased);
|
|
1630
|
+
if (t < 1) {
|
|
1631
|
+
requestAnimationFrame(step);
|
|
1632
|
+
} else {
|
|
1633
|
+
const target = document.querySelector(`.diag-card[data-lane="${stage.to}"]`);
|
|
1634
|
+
if (target) {
|
|
1635
|
+
target.classList.remove('is-pulse');
|
|
1636
|
+
// restart the animation
|
|
1637
|
+
void target.offsetWidth;
|
|
1638
|
+
target.classList.add('is-pulse');
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
requestAnimationFrame(step);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
// Expose entry point so app.js can dispatch into us.
|
|
1647
|
+
if (typeof window !== 'undefined') {
|
|
1648
|
+
window.renderArchitectureView = renderArchitecture;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// CommonJS export for Node `--test` unit tests. Exposes only the pure
|
|
1652
|
+
// helpers (no DOM dependency).
|
|
1653
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1654
|
+
module.exports = {
|
|
1655
|
+
buildDataflowSegment,
|
|
1656
|
+
hopLaneId,
|
|
1657
|
+
hopLabel,
|
|
1658
|
+
formatHopArgs,
|
|
1659
|
+
DF_ROLE_LANES,
|
|
1660
|
+
renderDataflowChip,
|
|
1661
|
+
// DF5 / arg-flow stretch
|
|
1662
|
+
ARG_PALETTE,
|
|
1663
|
+
argColor,
|
|
1664
|
+
getArgFlowKeys,
|
|
1665
|
+
hopArgLocalName,
|
|
1666
|
+
renderArgPicker,
|
|
1667
|
+
argHighlightForStage,
|
|
1668
|
+
highlightLabelHtml,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
})();
|