@geml/geml 1.0.0 → 1.3.2
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/LICENSE +21 -21
- package/README.md +109 -72
- package/codemap/adapters/crg.mjs +120 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +658 -0
- package/codemap/browser-stub.mjs +29 -0
- package/codemap/build.mjs +579 -0
- package/codemap/detect.mjs +399 -0
- package/codemap/emit.mjs +432 -0
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +172 -0
- package/codemap/normalize.mjs +272 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +578 -0
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +143 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +7 -1
- package/dist/geml.js +700 -52
- package/dist/history.d.ts +30 -0
- package/dist/history.js +212 -32
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +91 -2
- package/dist/render.js +1916 -50
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +5 -3
- package/package.json +63 -54
package/dist/render.js
CHANGED
|
@@ -15,24 +15,55 @@ const PALETTE = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed", "#db2777
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Escaping
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
|
-
function esc(s) {
|
|
18
|
+
export function esc(s) {
|
|
19
19
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
20
20
|
}
|
|
21
|
-
function escAttr(s) {
|
|
21
|
+
export function escAttr(s) {
|
|
22
22
|
return esc(s).replace(/"/g, """);
|
|
23
23
|
}
|
|
24
|
+
// Build a `class="…"` value from document-author-controlled tokens. Class names
|
|
25
|
+
// are dropped to the HTML token charset ([A-Za-z0-9_-]) so a crafted `.class`
|
|
26
|
+
// (e.g. `.x" onmouseover="alert(1)`) cannot break out of the attribute, then the
|
|
27
|
+
// joined result is escAttr'd as well (defense-in-depth). §4.
|
|
28
|
+
function classAttr(tokens) {
|
|
29
|
+
const safe = tokens
|
|
30
|
+
.map((t) => t.replace(/[^A-Za-z0-9_-]/g, ""))
|
|
31
|
+
.filter((t) => t !== "");
|
|
32
|
+
return escAttr(safe.join(" "));
|
|
33
|
+
}
|
|
34
|
+
// Maximum block-nesting depth the renderer will descend before bailing out with
|
|
35
|
+
// a diagnostic instead of overflowing the call stack (block()↔list()↔typed() are
|
|
36
|
+
// mutually recursive). 256 is far past any legitimate document yet well under the
|
|
37
|
+
// few-thousand-frame native stack limit. Kept in step with the parser's cap.
|
|
38
|
+
const MAX_NESTING = 256;
|
|
24
39
|
// ---------------------------------------------------------------------------
|
|
25
40
|
// Render context
|
|
26
41
|
// ---------------------------------------------------------------------------
|
|
27
|
-
class RenderCtx {
|
|
42
|
+
export class RenderCtx {
|
|
28
43
|
doc;
|
|
44
|
+
opts;
|
|
29
45
|
usedMath = false;
|
|
30
46
|
usedMermaid = false;
|
|
47
|
+
usedCodeGraph = false;
|
|
48
|
+
renderDepth = 0;
|
|
31
49
|
labels = new Map(); // id -> link label for [[#id]] auto-refs
|
|
32
|
-
constructor(doc) {
|
|
50
|
+
constructor(doc, opts = {}) {
|
|
33
51
|
this.doc = doc;
|
|
52
|
+
this.opts = opts;
|
|
34
53
|
this.indexLabels(doc.children);
|
|
35
54
|
}
|
|
55
|
+
// Codemap documents (meta declares module= / container=) are machine data:
|
|
56
|
+
// their oversized tables fold shut by default. Everywhere else a big table
|
|
57
|
+
// is still the document's CONTENT — it truncates for the DOM's sake but
|
|
58
|
+
// stays visible.
|
|
59
|
+
get isCodemapDoc() {
|
|
60
|
+
for (const b of this.doc.children) {
|
|
61
|
+
if (b.kind === "block" && b.type === "meta" && b.data) {
|
|
62
|
+
return b.data["module"] !== undefined || b.data["container"] !== undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
36
67
|
// Build the id -> label map: a heading's text, or a block's caption, or its id.
|
|
37
68
|
indexLabels(blocks) {
|
|
38
69
|
for (const b of blocks) {
|
|
@@ -106,6 +137,20 @@ class RenderCtx {
|
|
|
106
137
|
}
|
|
107
138
|
// ----- blocks -----
|
|
108
139
|
block(b) {
|
|
140
|
+
// Guard the block()↔list()↔typed() mutual recursion so a pathologically
|
|
141
|
+
// nested document degrades to a diagnostic rather than a RangeError.
|
|
142
|
+
if (this.renderDepth >= MAX_NESTING) {
|
|
143
|
+
return `<div class="render-error">block nesting too deep (max ${MAX_NESTING})</div>`;
|
|
144
|
+
}
|
|
145
|
+
this.renderDepth++;
|
|
146
|
+
try {
|
|
147
|
+
return this.blockInner(b);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
this.renderDepth--;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
blockInner(b) {
|
|
109
154
|
switch (b.kind) {
|
|
110
155
|
case "hidden": return "";
|
|
111
156
|
case "heading": {
|
|
@@ -156,12 +201,18 @@ class RenderCtx {
|
|
|
156
201
|
case "math":
|
|
157
202
|
this.usedMath = true;
|
|
158
203
|
return `<div class="math-block"${idAttr}>\\[${esc(raw)}\\]</div>`;
|
|
159
|
-
case "note":
|
|
160
|
-
|
|
161
|
-
const classes = ["callout", b.type, ...b.classes].join(" ");
|
|
204
|
+
case "note": {
|
|
205
|
+
const classes = classAttr(["callout", b.type, ...b.classes]);
|
|
162
206
|
const inner = (b.children ?? []).map((c) => this.block(c)).filter((s) => s).join("\n");
|
|
163
207
|
return `<aside class="${classes}"${idAttr}>\n${inner}\n</aside>`;
|
|
164
208
|
}
|
|
209
|
+
case "text": {
|
|
210
|
+
// Addressable prose (§3): flow children in a NEUTRAL container — the
|
|
211
|
+
// block exists for its id/attrs, not for callout chrome (that's note).
|
|
212
|
+
const inner = (b.children ?? []).map((c) => this.block(c)).filter((s) => s).join("\n");
|
|
213
|
+
const classes = classAttr(["text", ...b.classes]);
|
|
214
|
+
return `<div class="${classes}"${idAttr}>\n${inner}\n</div>`;
|
|
215
|
+
}
|
|
165
216
|
case "table":
|
|
166
217
|
return b.table ? this.table(b.table, b.id, caption) : `<p class="render-error">table failed to parse</p>`;
|
|
167
218
|
case "diagram":
|
|
@@ -182,6 +233,10 @@ class RenderCtx {
|
|
|
182
233
|
return `<figure class="chart"${idAttr}>${chartSvg(b.chart, caption)}${cap}</figure>`;
|
|
183
234
|
return `<figure${idAttr}><p class="render-error">chart could not be built (see diagnostics)</p>${cap}</figure>`;
|
|
184
235
|
}
|
|
236
|
+
if (fmt === "geml-code-graph") {
|
|
237
|
+
const src = typeof b.attrs["src"] === "string" ? b.attrs["src"] : "";
|
|
238
|
+
return this.codeGraphFigure(src, idAttr, cap);
|
|
239
|
+
}
|
|
185
240
|
if (fmt === "mermaid") {
|
|
186
241
|
this.usedMermaid = true;
|
|
187
242
|
return `<figure${idAttr}><pre class="mermaid">${esc(raw)}</pre>${cap}</figure>`;
|
|
@@ -190,16 +245,58 @@ class RenderCtx {
|
|
|
190
245
|
return `<figure${idAttr}><pre class="diagram-src" data-format="${escAttr(fmt)}">${esc(raw)}</pre>` +
|
|
191
246
|
`<figcaption>${caption ? esc(caption) + " — " : ""}<code>${esc(fmt || "diagram")}</code> (no bundled renderer in this build)</figcaption></figure>`;
|
|
192
247
|
}
|
|
248
|
+
// geml-code-graph embed (GEP-0003): build the call-graph slice from the
|
|
249
|
+
// codemap document `src` points at (roots/depth from ITS meta), embed the
|
|
250
|
+
// data, and let the in-page runtime lay it out at draw time — that is what
|
|
251
|
+
// makes click-to-re-root possible.
|
|
252
|
+
codeGraphFigure(src, idAttr, cap) {
|
|
253
|
+
if (!src) {
|
|
254
|
+
return `<figure class="code-graph"${idAttr}><p class="render-error">geml-code-graph: missing <code>src=</code></p>${cap}</figure>`;
|
|
255
|
+
}
|
|
256
|
+
if (this.opts.graphSidecar) {
|
|
257
|
+
// Sidecar mode (served pages): don't build the slice here at all — the
|
|
258
|
+
// page ships without the payload and the runtime fetches it from the
|
|
259
|
+
// sidecar route after first paint. Errors surface in the mount then.
|
|
260
|
+
this.usedCodeGraph = true;
|
|
261
|
+
return `<figure class="code-graph"${idAttr}><div class="cg-mount" data-start="${escAttr(src)}"` +
|
|
262
|
+
` data-graph-src="${escAttr(this.opts.graphSidecar + encodeURIComponent(src))}"></div>${cap}</figure>`;
|
|
263
|
+
}
|
|
264
|
+
const r = buildCodeGraph(src, this.opts);
|
|
265
|
+
if (r.error !== undefined) {
|
|
266
|
+
return `<figure class="code-graph"${idAttr}><p class="render-error">geml-code-graph: ${esc(r.error)}</p>${cap}</figure>`;
|
|
267
|
+
}
|
|
268
|
+
this.usedCodeGraph = true;
|
|
269
|
+
const note = r.truncated ? `<p class="cg-note">graph data capped at ${CG_MAX_NODES} nodes for this embed — the codemap documents themselves are complete</p>` : "";
|
|
270
|
+
// data-start carries the slice's own document path so a live module
|
|
271
|
+
// script (opts.liveGraph) can hook the mount without re-parsing the
|
|
272
|
+
// multi-MB payload attribute.
|
|
273
|
+
return `<figure class="code-graph"${idAttr}><div class="cg-mount" data-start="${escAttr(r.data.start)}" data-graph="${escAttr(JSON.stringify(r.data))}"></div>${note}${cap}</figure>`;
|
|
274
|
+
}
|
|
193
275
|
table(t, id, caption) {
|
|
194
276
|
const idAttr = id ? ` id="${escAttr(id)}"` : "";
|
|
195
277
|
const alignStyle = (a) => (a ? ` style="text-align:${a}"` : "");
|
|
278
|
+
// Parsing + laying out tens of thousands of <table> rows freezes the
|
|
279
|
+
// page for seconds, so the HTML view renders a bounded preview (the
|
|
280
|
+
// model keeps every row: charts, computed summaries and the code-graph
|
|
281
|
+
// never read the HTML). Codemap edge tables additionally fold shut —
|
|
282
|
+
// they are machine data; elsewhere the table is content and stays open.
|
|
283
|
+
// The codemap index's #modules table IS the page's content — the module
|
|
284
|
+
// inventory people scan and filter — so it renders in full. Edge tables
|
|
285
|
+
// (#calls / #called-by) stay previewed+folded: machine data at scale.
|
|
286
|
+
const maxRows = this.isCodemapDoc && id === "modules" ? Infinity : (this.opts.tableRows ?? 500);
|
|
287
|
+
const allRows = t.rows;
|
|
288
|
+
const rows = allRows.length > maxRows ? allRows.slice(0, maxRows) : allRows;
|
|
196
289
|
// Coverage grid for declared spans, so cells a span covers are not emitted.
|
|
197
|
-
const covered =
|
|
198
|
-
|
|
290
|
+
const covered = rows.map((r) => r.map(() => false));
|
|
291
|
+
rows.forEach((row, r) => row.forEach((cell, c) => {
|
|
199
292
|
if (!cell.span)
|
|
200
293
|
return;
|
|
201
|
-
|
|
202
|
-
|
|
294
|
+
// Bound the sweep to the rendered grid regardless of the declared span, so
|
|
295
|
+
// an oversized span can never drive an O(hugerows×hugecols) loop (DoS).
|
|
296
|
+
const spanRows = Math.min(cell.span.rows, rows.length - r);
|
|
297
|
+
const spanCols = Math.min(cell.span.cols, row.length - c);
|
|
298
|
+
for (let dr = 0; dr < spanRows; dr++)
|
|
299
|
+
for (let dc = 0; dc < spanCols; dc++) {
|
|
203
300
|
if (dr === 0 && dc === 0)
|
|
204
301
|
continue;
|
|
205
302
|
const rr = r + dr, cc = c + dc;
|
|
@@ -210,7 +307,7 @@ class RenderCtx {
|
|
|
210
307
|
const thead = t.header
|
|
211
308
|
? `<thead><tr>${t.columns.map((col, c) => `<th${alignStyle(t.align[c])}>${esc(col)}</th>`).join("")}</tr></thead>`
|
|
212
309
|
: "";
|
|
213
|
-
const bodyRows =
|
|
310
|
+
const bodyRows = rows.map((row, r) => {
|
|
214
311
|
const cells = row.map((cell, c) => {
|
|
215
312
|
if (covered[r]?.[c])
|
|
216
313
|
return "";
|
|
@@ -229,6 +326,16 @@ class RenderCtx {
|
|
|
229
326
|
: "";
|
|
230
327
|
const cap = caption ? `<figcaption>${esc(caption)}</figcaption>` : "";
|
|
231
328
|
const tools = `<div class="table-tools"><input class="table-filter" type="search" placeholder="Filter rows…" aria-label="Filter table rows"></div>`;
|
|
329
|
+
if (allRows.length > maxRows) {
|
|
330
|
+
const note = `<p class="table-note">showing the first ${maxRows} of ${allRows.length} rows — the complete table is in the document source</p>`;
|
|
331
|
+
if (this.isCodemapDoc) {
|
|
332
|
+
const summary = `${esc(id ? "#" + id : "table")} · ${allRows.length} rows (preview: first ${maxRows})`;
|
|
333
|
+
return `<figure class="table-figure"${idAttr}><details><summary>${summary}</summary>${tools}` +
|
|
334
|
+
`<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}</details>${cap}</figure>`;
|
|
335
|
+
}
|
|
336
|
+
return `<figure class="table-figure"${idAttr}>${tools}` +
|
|
337
|
+
`<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}${cap}</figure>`;
|
|
338
|
+
}
|
|
232
339
|
return `<figure class="table-figure"${idAttr}>${tools}` +
|
|
233
340
|
`<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${cap}</figure>`;
|
|
234
341
|
}
|
|
@@ -244,6 +351,401 @@ function niceMax(v) {
|
|
|
244
351
|
const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
|
|
245
352
|
return nice * pow;
|
|
246
353
|
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// geml-code-graph (GEP-0003) — slice builder. Traverses the codemap profile's
|
|
356
|
+
// #calls tables from the target document's meta `entry`, across documents,
|
|
357
|
+
// depth-limited; the layered LAYOUT happens in the page runtime (draw time).
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Hard payload ceiling only — the VIEW paces itself: the runtime draws the
|
|
360
|
+
// first 600 by BFS order and offers "+600"/"all" to walk deeper. The data in
|
|
361
|
+
// the codemap documents is always complete regardless.
|
|
362
|
+
const CG_MAX_NODES = 4000;
|
|
363
|
+
// Tiny posix-path helpers (no node:path dependency in the renderer).
|
|
364
|
+
function cgDir(p) { const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); }
|
|
365
|
+
function cgJoin(dir, rel) {
|
|
366
|
+
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
|
|
367
|
+
const out = [];
|
|
368
|
+
for (const seg of parts) {
|
|
369
|
+
if (seg === "" || seg === ".")
|
|
370
|
+
continue;
|
|
371
|
+
if (seg === "..")
|
|
372
|
+
out.pop();
|
|
373
|
+
else
|
|
374
|
+
out.push(seg);
|
|
375
|
+
}
|
|
376
|
+
return out.join("/");
|
|
377
|
+
}
|
|
378
|
+
function buildCodeGraph(startRel, opts, view) {
|
|
379
|
+
if (!opts.loadDoc || !opts.parseDoc)
|
|
380
|
+
return { error: "no document loader in this build (render via the geml CLI)" };
|
|
381
|
+
const cache = new Map();
|
|
382
|
+
const loadParsed = (rel) => {
|
|
383
|
+
if (!cache.has(rel)) {
|
|
384
|
+
const s = opts.loadDoc(rel);
|
|
385
|
+
cache.set(rel, s === null ? null : opts.parseDoc(s));
|
|
386
|
+
}
|
|
387
|
+
return cache.get(rel);
|
|
388
|
+
};
|
|
389
|
+
const metaOf = (d) => {
|
|
390
|
+
for (const b of d.children)
|
|
391
|
+
if (b.kind === "block" && b.type === "meta" && b.data)
|
|
392
|
+
return b.data;
|
|
393
|
+
return {};
|
|
394
|
+
};
|
|
395
|
+
const start = cgJoin("", startRel);
|
|
396
|
+
const doc0 = loadParsed(start);
|
|
397
|
+
if (!doc0)
|
|
398
|
+
return { error: `cannot load \`${startRel}\`` };
|
|
399
|
+
const meta0 = metaOf(doc0);
|
|
400
|
+
const entries = String(meta0["entry"] ?? "").split(/\s+/).filter(Boolean);
|
|
401
|
+
// A codemap INDEX (meta declares container=) renders the MODULE-level
|
|
402
|
+
// aggregation. The payload carries the RAW module rows and module edges;
|
|
403
|
+
// the runtime derives every view of the grouping tree from them (one tree
|
|
404
|
+
// node's children per view, single-child chains tunnelled) — so drilling
|
|
405
|
+
// through packages costs no refetch and old data needs no rebuild.
|
|
406
|
+
if (meta0["container"] !== undefined && !(view && view.node)) {
|
|
407
|
+
const findTable = (d, id) => {
|
|
408
|
+
for (const b of d.children)
|
|
409
|
+
if (b.kind === "block" && b.type === "table" && b.id === id && b.table)
|
|
410
|
+
return b.table;
|
|
411
|
+
return undefined;
|
|
412
|
+
};
|
|
413
|
+
const mods = findTable(doc0, "modules");
|
|
414
|
+
if (mods) {
|
|
415
|
+
const mi = mods.columns.indexOf("module"), di = mods.columns.indexOf("doc");
|
|
416
|
+
const mc = mods.columns.indexOf("methods");
|
|
417
|
+
if (mi < 0 || di < 0)
|
|
418
|
+
return { error: "#modules table lacks module/doc columns" };
|
|
419
|
+
const list = [];
|
|
420
|
+
for (const r of mods.rows) {
|
|
421
|
+
const name = r[mi]?.text ?? "", doc = r[di]?.text ?? "";
|
|
422
|
+
if (name && doc)
|
|
423
|
+
list.push({ p: name, doc, m: mc >= 0 ? Number(r[mc]?.text ?? "") || 0 : 0 });
|
|
424
|
+
}
|
|
425
|
+
const em = [];
|
|
426
|
+
const medges = findTable(doc0, "module-edges");
|
|
427
|
+
if (medges) {
|
|
428
|
+
const fi = medges.columns.indexOf("from"), ti = medges.columns.indexOf("to"), ci = medges.columns.indexOf("calls");
|
|
429
|
+
for (const r of medges.rows) {
|
|
430
|
+
const f = r[fi]?.text ?? "", t = r[ti]?.text ?? "";
|
|
431
|
+
if (f && t)
|
|
432
|
+
em.push([f, t, ci >= 0 ? Number(r[ci]?.text ?? "") || 1 : 1]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Containers holding app entries — every derived view marks the child
|
|
436
|
+
// that contains one of these as a root.
|
|
437
|
+
const entryDocs = [];
|
|
438
|
+
for (const e of entries) {
|
|
439
|
+
const h = e.indexOf("#");
|
|
440
|
+
if (h > 0) {
|
|
441
|
+
const d = cgJoin(cgDir(start), e.slice(0, h));
|
|
442
|
+
if (!entryDocs.includes(d))
|
|
443
|
+
entryDocs.push(d);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// …plus documents whose app entry is FILE-level (app-entry-docs meta:
|
|
447
|
+
// top-level bootstrap code with no function symbol, e.g. a Nuxt app.vue).
|
|
448
|
+
for (const t of String(meta0["app-entry-docs"] ?? "").split(/\s+/).filter(Boolean)) {
|
|
449
|
+
const d = cgJoin(cgDir(start), t);
|
|
450
|
+
if (!entryDocs.includes(d))
|
|
451
|
+
entryDocs.push(d);
|
|
452
|
+
}
|
|
453
|
+
return { data: { start, depth: 99, roots: [], nodes: {}, edges: [], mode: "modules", mods: list, medges: em, entryDocs } };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (!(view && view.node)) {
|
|
457
|
+
// A container view roots at its meta `entry` PLUS its in-degree-zero
|
|
458
|
+
// methods. `entry` = called from OUTSIDE the container; in-degree-zero =
|
|
459
|
+
// NO static caller at all — a JVM/agent entry point (`premain`), an AOP
|
|
460
|
+
// advice the instrumentation invokes, a reflective handler, or dead code.
|
|
461
|
+
// Such framework hooks have no in-repo caller, so seeding only from
|
|
462
|
+
// `entry` drops them (and everything they reach) from their OWN
|
|
463
|
+
// container's view. Union keeps a container's methods visible in it —
|
|
464
|
+
// symmetric with the module overview's entry ∪ in-degree-zero roots.
|
|
465
|
+
const ids = [];
|
|
466
|
+
const anchorOf = {};
|
|
467
|
+
const leaf = new Set();
|
|
468
|
+
const called = new Set();
|
|
469
|
+
for (const b of doc0.children) {
|
|
470
|
+
if (b.kind !== "block")
|
|
471
|
+
continue;
|
|
472
|
+
if (b.type === "code" && b.id) {
|
|
473
|
+
ids.push(b.id);
|
|
474
|
+
if (typeof b.attrs["anchor"] === "string")
|
|
475
|
+
anchorOf[b.id] = b.attrs["anchor"];
|
|
476
|
+
if (b.classes.includes("leaf"))
|
|
477
|
+
leaf.add(b.id);
|
|
478
|
+
}
|
|
479
|
+
if (b.type === "table" && b.table && (b.id === "calls" || b.id === "called-by")) {
|
|
480
|
+
const ti = b.table.columns.indexOf("to");
|
|
481
|
+
if (ti >= 0)
|
|
482
|
+
for (const r of b.table.rows) {
|
|
483
|
+
const t = r[ti]?.text ?? "";
|
|
484
|
+
if (t.startsWith("#"))
|
|
485
|
+
called.add(t.slice(1));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const have = new Set(entries.map((e) => e.replace(/^#/, "")));
|
|
490
|
+
// Synthetic methods — constructors (`<init>`/`<clinit>`), lambdas
|
|
491
|
+
// (`<lambda>`), and anonymous-class / unresolved-signature methods — are
|
|
492
|
+
// implementation artifacts, never entry points. Their in-degree is zero
|
|
493
|
+
// only because no static edge names them (fluent-API / reflective / lambda
|
|
494
|
+
// callers go unresolved), so seeding roots from them floods the view. Keep
|
|
495
|
+
// them out of the in-degree-zero roots; they still appear when a real root
|
|
496
|
+
// reaches them.
|
|
497
|
+
const synthetic = (id) => /<(?:init|clinit|lambda)>|<unresolvedSignature>/.test(anchorOf[id] || "");
|
|
498
|
+
// `.leaf` = zero out-edges: an in-degree-zero leaf is an ISOLATED node (no
|
|
499
|
+
// caller, nothing to expand) — a bean getter/setter, a constant, dead code.
|
|
500
|
+
// As a root it is pure clutter, so it never seeds one; it still appears if a
|
|
501
|
+
// real root reaches it. (An in-degree-zero method WITH out-edges — premain,
|
|
502
|
+
// an AOP advice — is a genuine entry and does seed a root.)
|
|
503
|
+
for (const id of ids)
|
|
504
|
+
if (!called.has(id) && !have.has(id) && !synthetic(id) && !leaf.has(id))
|
|
505
|
+
entries.push(`#${id}`);
|
|
506
|
+
}
|
|
507
|
+
if (!(view && view.node) && !entries.length)
|
|
508
|
+
return { error: `\`${startRel}\` declares no \`entry\` in its meta` };
|
|
509
|
+
const depth = Number(meta0["graph-depth"]) > 0 ? Number(meta0["graph-depth"]) : 6;
|
|
510
|
+
const resolveRef = (fromDoc, ref) => {
|
|
511
|
+
const h = ref.indexOf("#");
|
|
512
|
+
if (h < 0)
|
|
513
|
+
return null;
|
|
514
|
+
const id = ref.slice(h + 1);
|
|
515
|
+
return { doc: h === 0 ? fromDoc : cgJoin(cgDir(fromDoc), ref.slice(0, h)), id };
|
|
516
|
+
};
|
|
517
|
+
const nodes = {};
|
|
518
|
+
const edges = [];
|
|
519
|
+
const roots = [];
|
|
520
|
+
let truncated = false;
|
|
521
|
+
// Per-document indexes, built once on first touch. The BFS re-enters the
|
|
522
|
+
// same documents for every node it expands — a linear scan of a 30k-row
|
|
523
|
+
// #calls table per node turns the whole walk quadratic (seconds per page
|
|
524
|
+
// on a large codemap).
|
|
525
|
+
const blockIdxOf = (() => {
|
|
526
|
+
const cache = new Map();
|
|
527
|
+
return (docRel) => {
|
|
528
|
+
let idx = cache.get(docRel);
|
|
529
|
+
if (idx)
|
|
530
|
+
return idx;
|
|
531
|
+
idx = new Map();
|
|
532
|
+
const d = loadParsed(docRel);
|
|
533
|
+
if (d)
|
|
534
|
+
for (const b of d.children) {
|
|
535
|
+
if (b.kind !== "block" || !b.id || idx.has(b.id))
|
|
536
|
+
continue;
|
|
537
|
+
// Label with the real display name when the block carries one — the
|
|
538
|
+
// id is the sanitised form ("RenderCtx-block" for "RenderCtx.block").
|
|
539
|
+
const node = { n: typeof b.attrs["name"] === "string" ? b.attrs["name"] : b.id, doc: docRel };
|
|
540
|
+
if (typeof b.attrs["src"] === "string")
|
|
541
|
+
node.src = b.attrs["src"];
|
|
542
|
+
if (b.classes.includes("leaf"))
|
|
543
|
+
node.leaf = true;
|
|
544
|
+
if (b.classes.includes("test"))
|
|
545
|
+
node.test = true;
|
|
546
|
+
if (b.classes.includes("accessor"))
|
|
547
|
+
node.acc = true;
|
|
548
|
+
if (b.classes.includes("app-entry"))
|
|
549
|
+
node.entry = true;
|
|
550
|
+
idx.set(b.id, node);
|
|
551
|
+
}
|
|
552
|
+
cache.set(docRel, idx);
|
|
553
|
+
return idx;
|
|
554
|
+
};
|
|
555
|
+
})();
|
|
556
|
+
const blockInfo = (docRel, id) => blockIdxOf(docRel).get(id) ?? { n: id, doc: docRel };
|
|
557
|
+
const callIdxOf = (() => {
|
|
558
|
+
const cache = new Map();
|
|
559
|
+
return (docRel) => {
|
|
560
|
+
let idx = cache.get(docRel);
|
|
561
|
+
if (idx)
|
|
562
|
+
return idx;
|
|
563
|
+
idx = new Map();
|
|
564
|
+
const d = loadParsed(docRel);
|
|
565
|
+
if (d)
|
|
566
|
+
for (const b of d.children) {
|
|
567
|
+
if (b.kind === "block" && b.type === "table" && b.id === "calls" && b.table) {
|
|
568
|
+
const cols = b.table.columns;
|
|
569
|
+
const fi = cols.indexOf("from"), ti = cols.indexOf("to"), ki = cols.indexOf("kind"), ci = cols.indexOf("confidence");
|
|
570
|
+
if (fi < 0 || ti < 0)
|
|
571
|
+
break;
|
|
572
|
+
for (const r of b.table.rows) {
|
|
573
|
+
const from = r[fi]?.text ?? "";
|
|
574
|
+
if (!from.startsWith("#"))
|
|
575
|
+
continue;
|
|
576
|
+
let list = idx.get(from.slice(1));
|
|
577
|
+
if (!list) {
|
|
578
|
+
list = [];
|
|
579
|
+
idx.set(from.slice(1), list);
|
|
580
|
+
}
|
|
581
|
+
list.push({ to: r[ti]?.text ?? "", kind: r[ki]?.text || "call", conf: ci >= 0 ? (r[ci]?.text ?? "") : "" });
|
|
582
|
+
}
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
cache.set(docRel, idx);
|
|
587
|
+
return idx;
|
|
588
|
+
};
|
|
589
|
+
})();
|
|
590
|
+
const callRows = (docRel, id) => callIdxOf(docRel).get(id) ?? [];
|
|
591
|
+
// A caller-direction view (the runtime's ⊕ handle through a live loader):
|
|
592
|
+
// BFS over #called-by tables from one node. Edges are emitted REVERSED
|
|
593
|
+
// (callee -> caller), so roots=[focus] lets the standard layering flow from
|
|
594
|
+
// the method out to its ultimate callers — cycles fall out as back edges.
|
|
595
|
+
if (view && view.node && view.dir === "up") {
|
|
596
|
+
const hi = view.node.lastIndexOf("#");
|
|
597
|
+
if (hi <= 0)
|
|
598
|
+
return { error: `bad view node \`${view.node}\`` };
|
|
599
|
+
// Same once-per-document indexing as callRows — the upward BFS crosses
|
|
600
|
+
// documents through their #called-by tables just as hot.
|
|
601
|
+
const calledByIdxOf = (() => {
|
|
602
|
+
const cache = new Map();
|
|
603
|
+
return (docRel) => {
|
|
604
|
+
let idx = cache.get(docRel);
|
|
605
|
+
if (idx)
|
|
606
|
+
return idx;
|
|
607
|
+
idx = new Map();
|
|
608
|
+
const d = loadParsed(docRel);
|
|
609
|
+
if (d)
|
|
610
|
+
for (const b of d.children) {
|
|
611
|
+
if (b.kind === "block" && b.type === "table" && b.id === "called-by" && b.table) {
|
|
612
|
+
const cols = b.table.columns;
|
|
613
|
+
const fi = cols.indexOf("from"), ti = cols.indexOf("to"), ki = cols.indexOf("kind");
|
|
614
|
+
if (fi < 0 || ti < 0)
|
|
615
|
+
break;
|
|
616
|
+
for (const r of b.table.rows) {
|
|
617
|
+
const to = r[ti]?.text ?? "";
|
|
618
|
+
if (!to.startsWith("#"))
|
|
619
|
+
continue;
|
|
620
|
+
let list = idx.get(to.slice(1));
|
|
621
|
+
if (!list) {
|
|
622
|
+
list = [];
|
|
623
|
+
idx.set(to.slice(1), list);
|
|
624
|
+
}
|
|
625
|
+
list.push({ from: r[fi]?.text ?? "", kind: r[ki]?.text || "call" });
|
|
626
|
+
}
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
cache.set(docRel, idx);
|
|
631
|
+
return idx;
|
|
632
|
+
};
|
|
633
|
+
})();
|
|
634
|
+
const calledByRows = (docRel, id) => calledByIdxOf(docRel).get(id) ?? [];
|
|
635
|
+
const focus = view.node;
|
|
636
|
+
nodes[focus] = blockInfo(focus.slice(0, hi), focus.slice(hi + 1));
|
|
637
|
+
roots.push(focus);
|
|
638
|
+
let fr = [{ doc: focus.slice(0, hi), id: focus.slice(hi + 1) }];
|
|
639
|
+
const seenUp = new Set([focus]);
|
|
640
|
+
// The caller chain is not depth-limited: its whole point is reaching the
|
|
641
|
+
// app entry. The node cap (with its visible note) is the only guard.
|
|
642
|
+
const upDepth = 99;
|
|
643
|
+
for (let d = 0; d < upDepth && fr.length; d++) {
|
|
644
|
+
const next = [];
|
|
645
|
+
for (const cur of fr) {
|
|
646
|
+
const toKey = `${cur.doc}#${cur.id}`;
|
|
647
|
+
for (const row of calledByRows(cur.doc, cur.id)) {
|
|
648
|
+
const c = resolveRef(cur.doc, row.from);
|
|
649
|
+
if (!c)
|
|
650
|
+
continue;
|
|
651
|
+
const callerKey = `${c.doc}#${c.id}`;
|
|
652
|
+
if (!nodes[callerKey]) {
|
|
653
|
+
if (Object.keys(nodes).length >= CG_MAX_NODES) {
|
|
654
|
+
truncated = true;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
nodes[callerKey] = blockInfo(c.doc, c.id);
|
|
658
|
+
}
|
|
659
|
+
edges.push([toKey, callerKey, row.kind, ""]);
|
|
660
|
+
if (!seenUp.has(callerKey)) {
|
|
661
|
+
seenUp.add(callerKey);
|
|
662
|
+
next.push(c);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
fr = next;
|
|
667
|
+
}
|
|
668
|
+
return { data: { start, depth: upDepth, roots, nodes, edges, module: String(meta0["module"] ?? "") || undefined, dir: "up", focus }, truncated };
|
|
669
|
+
}
|
|
670
|
+
// BFS from the target document's entries, depth-limited (+1 ring of stubs so
|
|
671
|
+
// the horizon is visible as "more" markers rather than silently missing).
|
|
672
|
+
// A directed callee view (node-body click through a live loader) seeds from
|
|
673
|
+
// that one node key instead of the meta entries.
|
|
674
|
+
let frontier = [];
|
|
675
|
+
if (view && view.node) {
|
|
676
|
+
const hi = view.node.lastIndexOf("#");
|
|
677
|
+
if (hi <= 0)
|
|
678
|
+
return { error: `bad view node \`${view.node}\`` };
|
|
679
|
+
roots.push(view.node);
|
|
680
|
+
frontier.push({ doc: view.node.slice(0, hi), id: view.node.slice(hi + 1) });
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
const seeds = entries.map((e) => resolveRef(start, e)).filter(Boolean);
|
|
684
|
+
// A `.leaf` root has no callees to expand, so it renders as an ISOLATED dot
|
|
685
|
+
// — a getter/setter/constant called from another container, or dead code.
|
|
686
|
+
// Seed roots only from non-leaf entries so the view is call chains, not a
|
|
687
|
+
// field of dots; a leaf still appears when a real chain reaches it. Fall
|
|
688
|
+
// back to all seeds if EVERY entry is a leaf (a pure data container — a DTO
|
|
689
|
+
// of getters — must not come out blank).
|
|
690
|
+
const nonLeaf = seeds.filter((r) => !blockInfo(r.doc, r.id).leaf);
|
|
691
|
+
for (const r of (nonLeaf.length ? nonLeaf : seeds)) {
|
|
692
|
+
roots.push(`${r.doc}#${r.id}`);
|
|
693
|
+
frontier.push(r);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const seen = new Set(roots);
|
|
697
|
+
for (const r of frontier)
|
|
698
|
+
nodes[`${r.doc}#${r.id}`] = blockInfo(r.doc, r.id);
|
|
699
|
+
for (let d = 0; d < depth && frontier.length; d++) {
|
|
700
|
+
const next = [];
|
|
701
|
+
for (const cur of frontier) {
|
|
702
|
+
const fromKey = `${cur.doc}#${cur.id}`;
|
|
703
|
+
for (const row of callRows(cur.doc, cur.id)) {
|
|
704
|
+
const t = resolveRef(cur.doc, row.to);
|
|
705
|
+
if (!t)
|
|
706
|
+
continue;
|
|
707
|
+
const toKey = `${t.doc}#${t.id}`;
|
|
708
|
+
if (!nodes[toKey]) {
|
|
709
|
+
if (Object.keys(nodes).length >= CG_MAX_NODES) {
|
|
710
|
+
truncated = true;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
nodes[toKey] = blockInfo(t.doc, t.id);
|
|
714
|
+
}
|
|
715
|
+
edges.push([fromKey, toKey, row.kind, row.conf]);
|
|
716
|
+
if (!seen.has(toKey)) {
|
|
717
|
+
seen.add(toKey);
|
|
718
|
+
next.push(t);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
frontier = next;
|
|
723
|
+
}
|
|
724
|
+
// Horizon markers: anything still in the frontier that has further callees.
|
|
725
|
+
for (const cur of frontier) {
|
|
726
|
+
if (callRows(cur.doc, cur.id).length > 0)
|
|
727
|
+
nodes[`${cur.doc}#${cur.id}`].more = true;
|
|
728
|
+
}
|
|
729
|
+
// Drop ISOLATED nodes: a seeded root that ends up with no edge at all (no
|
|
730
|
+
// resolved callee to expand, no in-view caller) is a lone dot — a getter/
|
|
731
|
+
// setter/constant called only from elsewhere, or a method whose only calls
|
|
732
|
+
// were unresolved. They are clutter in a flow view. Keep them only if the
|
|
733
|
+
// WHOLE view is isolated dots (a pure data container mustn't come out blank).
|
|
734
|
+
const touched = new Set();
|
|
735
|
+
for (const e of edges) {
|
|
736
|
+
touched.add(e[0]);
|
|
737
|
+
touched.add(e[1]);
|
|
738
|
+
}
|
|
739
|
+
const connected = roots.filter((r) => touched.has(r));
|
|
740
|
+
let finalRoots = roots;
|
|
741
|
+
if (connected.length) {
|
|
742
|
+
for (const r of roots)
|
|
743
|
+
if (!touched.has(r))
|
|
744
|
+
delete nodes[r];
|
|
745
|
+
finalRoots = connected;
|
|
746
|
+
}
|
|
747
|
+
return { data: { start, depth, roots: finalRoots, nodes, edges, module: String(meta0["module"] ?? "") || undefined }, truncated };
|
|
748
|
+
}
|
|
247
749
|
function chartSvg(m, title) {
|
|
248
750
|
if (m.type === "pie")
|
|
249
751
|
return pieSvg(m, title);
|
|
@@ -386,7 +888,7 @@ function trunc(s, n) {
|
|
|
386
888
|
// ---------------------------------------------------------------------------
|
|
387
889
|
// Page shell, inline CSS, inline interactivity JS
|
|
388
890
|
// ---------------------------------------------------------------------------
|
|
389
|
-
const CSS = `
|
|
891
|
+
export const CSS = `
|
|
390
892
|
:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }
|
|
391
893
|
* { box-sizing: border-box; }
|
|
392
894
|
body { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; }
|
|
@@ -403,7 +905,10 @@ pre code { background:none; padding:0; font-size:.85em; }
|
|
|
403
905
|
pre.output { background:#0d1117; color:#e6edf3; }
|
|
404
906
|
pre.output code { color:inherit; }
|
|
405
907
|
ul,ol { padding-left:1.6em; } li { margin:.2em 0; }
|
|
406
|
-
ul.task-list { list-style:none; padding-left:.2em; }
|
|
908
|
+
ul.task-list { list-style:none; padding-left:.2em; }
|
|
909
|
+
li.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }
|
|
910
|
+
li.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }
|
|
911
|
+
li.task input[type=checkbox]:checked::after { content:"✓"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }
|
|
407
912
|
aside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }
|
|
408
913
|
aside.aside { border-left-color:#8b949e; background:#f6f8fa; }
|
|
409
914
|
aside.warning { border-left-color:#d97706; background:#fff8f0; }
|
|
@@ -420,6 +925,8 @@ table.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }
|
|
|
420
925
|
table.geml-table td.computed { color:#0a7c52; }
|
|
421
926
|
table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }
|
|
422
927
|
.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }
|
|
928
|
+
.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }
|
|
929
|
+
.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }
|
|
423
930
|
.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }
|
|
424
931
|
.c-title { font-size:15px; font-weight:600; fill:var(--fg); }
|
|
425
932
|
.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }
|
|
@@ -429,8 +936,59 @@ table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-t
|
|
|
429
936
|
sup.fn a { font-size:.75em; }
|
|
430
937
|
.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }
|
|
431
938
|
.geml-footer code { font-size:.95em; }
|
|
939
|
+
.code-graph { margin:1.4em 0; }
|
|
940
|
+
.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
|
|
941
|
+
.cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }
|
|
942
|
+
.cg-svg { display:block; }
|
|
943
|
+
.cg-search-wrap { position:relative; display:inline-block; }
|
|
944
|
+
.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }
|
|
945
|
+
.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }
|
|
946
|
+
.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }
|
|
947
|
+
.cg-search-row:hover { background:var(--bd); }
|
|
948
|
+
.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }
|
|
949
|
+
.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }
|
|
950
|
+
.cg-search-grp:first-of-type { border-top:0; }
|
|
951
|
+
.cg-stage { display:flex; gap:10px; align-items:flex-start; }
|
|
952
|
+
.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }
|
|
953
|
+
.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }
|
|
954
|
+
.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }
|
|
955
|
+
.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }
|
|
956
|
+
.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }
|
|
957
|
+
.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }
|
|
958
|
+
.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }
|
|
959
|
+
.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }
|
|
960
|
+
.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }
|
|
961
|
+
.cg-crumb .cg-seg:hover { text-decoration:underline; }
|
|
962
|
+
.cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }
|
|
963
|
+
.cg-flash { color:#b42318; }
|
|
964
|
+
.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }
|
|
965
|
+
.cg-upbtn { cursor:pointer; }
|
|
966
|
+
.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }
|
|
967
|
+
.cg-upbtn text { font-size:11px; fill:#57606a; }
|
|
968
|
+
.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }
|
|
969
|
+
.cg-upbtn:hover text { fill:var(--accent); }
|
|
970
|
+
.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }
|
|
971
|
+
.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }
|
|
972
|
+
.cg-chip { display:inline-flex; align-items:center; gap:4px; }
|
|
973
|
+
.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }
|
|
974
|
+
.cg-note { font-size:.8em; color:#9a6700; }
|
|
975
|
+
.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }
|
|
976
|
+
.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }
|
|
977
|
+
.cg-n { cursor:pointer; }
|
|
978
|
+
.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }
|
|
979
|
+
.cg-n.leaf { opacity:.45; }
|
|
980
|
+
.cg-n.test rect { stroke-dasharray:3 2; }
|
|
981
|
+
.cg-n.grp rect { stroke-width:1.8; }
|
|
982
|
+
.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }
|
|
983
|
+
.cg-e.cand { stroke-dasharray:2 3; }
|
|
984
|
+
.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }
|
|
985
|
+
.cg-e.soft { opacity:.55; }
|
|
986
|
+
.cg-svg.hl .cg-n { opacity:.22; }
|
|
987
|
+
.cg-svg.hl .cg-e { opacity:.1; }
|
|
988
|
+
.cg-svg.hl .cg-n.hl { opacity:1; }
|
|
989
|
+
.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }
|
|
432
990
|
`;
|
|
433
|
-
const JS = `
|
|
991
|
+
export const JS = `
|
|
434
992
|
(function () {
|
|
435
993
|
function cmp(a, b) {
|
|
436
994
|
var na = a.dataset.sort, nb = b.dataset.sort;
|
|
@@ -467,42 +1025,1350 @@ const JS = `
|
|
|
467
1025
|
});
|
|
468
1026
|
})();
|
|
469
1027
|
`;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1028
|
+
// geml-code-graph runtime: layered layout AT DRAW TIME (GEP-0003 / v2-D8) so
|
|
1029
|
+
// clicking a node re-roots the view inside the embedded slice. Algorithm as
|
|
1030
|
+
// specified: BFS slice from roots -> DFS back-edge marking -> longest-path
|
|
1031
|
+
// layering over forward edges -> stable in-layer order. O(V+E) per redraw.
|
|
1032
|
+
//
|
|
1033
|
+
// ONE implementation, two consumers: the CLI inlines `codeGraphRuntime`
|
|
1034
|
+
// verbatim (Function.prototype.toString) into the self-contained HTML; the
|
|
1035
|
+
// browser extension / playground import it and call it after their async
|
|
1036
|
+
// upgrade step has attached data-graph payloads. Browser-only code — it must
|
|
1037
|
+
// stay self-contained (no captured module-scope identifiers).
|
|
1038
|
+
export function codeGraphRuntime(root) {
|
|
1039
|
+
function h(tag, attrs) {
|
|
1040
|
+
var el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
1041
|
+
for (var k in attrs)
|
|
1042
|
+
el.setAttribute(k, String(attrs[k]));
|
|
1043
|
+
return el;
|
|
1044
|
+
}
|
|
1045
|
+
// Arrow-marker ids must be unique per drawn svg — several mounts share one
|
|
1046
|
+
// document, and duplicate ids would make every graph point at the first.
|
|
1047
|
+
var arrowSeq = 0;
|
|
1048
|
+
function boot(mount, data0, gpath) {
|
|
1049
|
+
var data, out;
|
|
1050
|
+
function setData(d) {
|
|
1051
|
+
data = d;
|
|
1052
|
+
out = {};
|
|
1053
|
+
data.edges.forEach(function (e) { (out[e[0]] = out[e[0]] || []).push(e); });
|
|
1054
|
+
}
|
|
1055
|
+
// Grouped module navigation (GEP-0003 §4): a SHALLOW two-tier model.
|
|
1056
|
+
// Tier 1 (gpath = []) is one node per top path segment — the module-ish
|
|
1057
|
+
// roots. Tier 2 (gpath = [seg]) is that segment's containers FLAT, labelled
|
|
1058
|
+
// by their intra-module path; clicking a container opens its methods. At
|
|
1059
|
+
// most ONE grouping level, so a method is always two clicks from the top —
|
|
1060
|
+
// a deep package chain (core/service/impl) reads as a flat label, never a
|
|
1061
|
+
// click-through. Calls leaving the subtree aggregate into dimmed external
|
|
1062
|
+
// stubs so no dependency is hidden.
|
|
1063
|
+
function deriveView(gpath) {
|
|
1064
|
+
function first(p) { var c = p.indexOf("/"); return c < 0 ? p : p.slice(0, c); }
|
|
1065
|
+
var pByDoc = {}, docByP = {};
|
|
1066
|
+
data0.mods.forEach(function (m) { pByDoc[m.doc] = m.p; docByP[m.p] = m.doc; });
|
|
1067
|
+
// A single top segment (one-module repo) is ceremony: land straight on
|
|
1068
|
+
// its containers, with the breadcrumb still at root — a lone top node is
|
|
1069
|
+
// never worth a click. `reported` keeps the crumb showing `modules`.
|
|
1070
|
+
var reported = gpath;
|
|
1071
|
+
if (!gpath.length) {
|
|
1072
|
+
var tops = {};
|
|
1073
|
+
data0.mods.forEach(function (m) { var s = first(m.p); tops[s] = (tops[s] || 0) + 1; });
|
|
1074
|
+
var tk = Object.keys(tops);
|
|
1075
|
+
if (tk.length === 1) {
|
|
1076
|
+
var whole = false;
|
|
1077
|
+
data0.mods.forEach(function (m) { if (m.p === tk[0])
|
|
1078
|
+
whole = true; });
|
|
1079
|
+
if (!(tops[tk[0]] === 1 && whole))
|
|
1080
|
+
gpath = [tk[0]]; // descend past the sole group
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
var nodes = {}, keyOf;
|
|
1084
|
+
if (!gpath.length) {
|
|
1085
|
+
// Tier 1: one node per top segment. A segment that is a single whole
|
|
1086
|
+
// container (its path IS the segment) is a leaf — one click to methods.
|
|
1087
|
+
var segCount = {}, segWhole = {};
|
|
1088
|
+
data0.mods.forEach(function (m) {
|
|
1089
|
+
var s = first(m.p);
|
|
1090
|
+
segCount[s] = (segCount[s] || 0) + 1;
|
|
1091
|
+
if (m.p === s)
|
|
1092
|
+
segWhole[s] = m.doc;
|
|
1093
|
+
});
|
|
1094
|
+
Object.keys(segCount).sort().forEach(function (s) {
|
|
1095
|
+
if (segCount[s] === 1 && segWhole[s])
|
|
1096
|
+
nodes[segWhole[s]] = { n: s, doc: segWhole[s] };
|
|
1097
|
+
else
|
|
1098
|
+
nodes["g:" + s] = { n: s, grp: [s] };
|
|
1099
|
+
});
|
|
1100
|
+
keyOf = function (p) { var s = first(p); return (segCount[s] === 1 && segWhole[s]) ? segWhole[s] : "g:" + s; };
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
// Tier 2: every container under this segment, FLAT.
|
|
1104
|
+
var mod = gpath.join("/"), pre = mod + "/";
|
|
1105
|
+
data0.mods.forEach(function (m) {
|
|
1106
|
+
if (m.p !== mod && m.p.indexOf(pre) !== 0)
|
|
1107
|
+
return;
|
|
1108
|
+
var label = m.p === mod ? (mod.indexOf("/") < 0 ? mod : mod.slice(mod.lastIndexOf("/") + 1)) : m.p.slice(pre.length);
|
|
1109
|
+
nodes[m.doc] = { n: label, doc: m.doc };
|
|
1110
|
+
});
|
|
1111
|
+
keyOf = function (p) {
|
|
1112
|
+
if (p === mod || p.indexOf(pre) === 0)
|
|
1113
|
+
return docByP[p] || null;
|
|
1114
|
+
return "x:" + first(p);
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
var agg = {};
|
|
1118
|
+
data0.medges.forEach(function (e) {
|
|
1119
|
+
var a = keyOf(e[0]), b = keyOf(e[1]);
|
|
1120
|
+
if (!a || !b || a === b)
|
|
1121
|
+
return;
|
|
1122
|
+
if (a.indexOf("x:") === 0 && b.indexOf("x:") === 0)
|
|
1123
|
+
return;
|
|
1124
|
+
[a, b].forEach(function (kk) { if (kk.indexOf("x:") === 0 && !nodes[kk])
|
|
1125
|
+
nodes[kk] = { n: "↗ " + kk.slice(2), ext: 1, leaf: 1 }; });
|
|
1126
|
+
agg[a + ">" + b] = (agg[a + ">" + b] || 0) + (Number(e[2]) || 1);
|
|
1127
|
+
});
|
|
1128
|
+
var edges = [];
|
|
1129
|
+
for (var ek in agg) {
|
|
1130
|
+
var i2 = ek.indexOf(">");
|
|
1131
|
+
edges.push([ek.slice(0, i2), ek.slice(i2 + 1), "call", String(agg[ek])]);
|
|
1132
|
+
}
|
|
1133
|
+
// roots: nodes holding app entries, plus in-degree-zero nodes
|
|
1134
|
+
var roots = [];
|
|
1135
|
+
(data0.entryDocs || []).forEach(function (d) {
|
|
1136
|
+
var p = pByDoc[d];
|
|
1137
|
+
if (!p)
|
|
1138
|
+
return;
|
|
1139
|
+
var kk = keyOf(p);
|
|
1140
|
+
if (kk && kk.indexOf("x:") !== 0) {
|
|
1141
|
+
if (roots.indexOf(kk) < 0)
|
|
1142
|
+
roots.push(kk);
|
|
1143
|
+
// Badge the module (or the group holding it): this is where the
|
|
1144
|
+
// program starts — the ▶ the label renderer prepends.
|
|
1145
|
+
if (nodes[kk])
|
|
1146
|
+
nodes[kk].appEntry = 1;
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
var hasIn = {};
|
|
1150
|
+
edges.forEach(function (e) { hasIn[e[1]] = 1; });
|
|
1151
|
+
for (var nk in nodes)
|
|
1152
|
+
if (!hasIn[nk] && !nodes[nk].ext && roots.indexOf(nk) < 0)
|
|
1153
|
+
roots.push(nk);
|
|
1154
|
+
if (!roots.length)
|
|
1155
|
+
for (var nk2 in nodes)
|
|
1156
|
+
roots.push(nk2);
|
|
1157
|
+
return { start: data0.start, depth: 99, mode: "modules", gpath: reported, roots: roots, nodes: nodes, edges: edges };
|
|
1158
|
+
}
|
|
1159
|
+
function homeData() {
|
|
1160
|
+
return data0.mode === "modules" && data0.mods ? deriveView([]) : data0;
|
|
1161
|
+
}
|
|
1162
|
+
setData(data0.mode === "modules" && data0.mods && gpath && gpath.length ? deriveView(gpath) : homeData());
|
|
1163
|
+
// scale null = fit-to-width on first draw. Left-right is the default —
|
|
1164
|
+
// call flow reads with the text; the toggle persists per reader.
|
|
1165
|
+
var state = { roots: data.roots.slice(), trail: [], scale: null, dir: "LR", frame: null, cap: 600, showAcc: false };
|
|
1166
|
+
// Direction survives module -> container navigation (each page is a fresh
|
|
1167
|
+
// document); best-effort only — file:// or the DOM stub may lack storage.
|
|
1168
|
+
try {
|
|
1169
|
+
var sd = window.localStorage.getItem("geml-cg-dir");
|
|
1170
|
+
if (sd === "TB" || sd === "LR")
|
|
1171
|
+
state.dir = sd;
|
|
1172
|
+
}
|
|
1173
|
+
catch (e) { /* no storage */ }
|
|
1174
|
+
function slice(roots) {
|
|
1175
|
+
var keep = {}, layer = {}, q = [], qi = 0, order = [];
|
|
1176
|
+
// Accessor noise (bean get/set/is leaves, .accessor) is hidden unless
|
|
1177
|
+
// toggled on; the walk COUNTS what it hides so the toolbar can say so.
|
|
1178
|
+
var hideAcc = data.mode !== "modules" && !state.showAcc;
|
|
1179
|
+
var accSeen = {}, accHidden = 0;
|
|
1180
|
+
roots.forEach(function (r) { if (data.nodes[r] && !(r in keep)) {
|
|
1181
|
+
keep[r] = 1;
|
|
1182
|
+
layer[r] = 0;
|
|
1183
|
+
q.push([r, 0]);
|
|
1184
|
+
order.push(r);
|
|
1185
|
+
} });
|
|
1186
|
+
while (qi < q.length) {
|
|
1187
|
+
var cur = q[qi][0], d = q[qi][1];
|
|
1188
|
+
qi++;
|
|
1189
|
+
if (d >= data.depth)
|
|
1190
|
+
continue;
|
|
1191
|
+
(out[cur] || []).forEach(function (e) {
|
|
1192
|
+
var t = e[1];
|
|
1193
|
+
if (!data.nodes[t] || (t in keep) || accSeen[t])
|
|
1194
|
+
return;
|
|
1195
|
+
if (hideAcc && data.nodes[t].acc) {
|
|
1196
|
+
accSeen[t] = 1;
|
|
1197
|
+
accHidden++;
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
keep[t] = 1;
|
|
1201
|
+
layer[t] = d + 1;
|
|
1202
|
+
q.push([t, d + 1]);
|
|
1203
|
+
order.push(t);
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
// The VIEW paces itself: draw the first `cap` in BFS order, tell the
|
|
1207
|
+
// reader how much is beyond, let +400/all walk deeper. Data is complete.
|
|
1208
|
+
var total = order.length, capped = 0;
|
|
1209
|
+
if (data.mode !== "modules" && total > state.cap) {
|
|
1210
|
+
for (var oi = state.cap; oi < total; oi++)
|
|
1211
|
+
delete keep[order[oi]];
|
|
1212
|
+
capped = total - state.cap;
|
|
1213
|
+
}
|
|
1214
|
+
// Module overview: every module stays visible — the ones unreachable
|
|
1215
|
+
// from the roots (vendored deps etc.) park on one extra bottom layer.
|
|
1216
|
+
if (data.mode === "modules") {
|
|
1217
|
+
var park = 0;
|
|
1218
|
+
for (var kk in keep)
|
|
1219
|
+
if (layer[kk] > park)
|
|
1220
|
+
park = layer[kk];
|
|
1221
|
+
for (var nk in data.nodes)
|
|
1222
|
+
if (!(nk in keep)) {
|
|
1223
|
+
keep[nk] = 1;
|
|
1224
|
+
layer[nk] = park + 1;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
var color = {}, back = {};
|
|
1228
|
+
function dfs(u) {
|
|
1229
|
+
color[u] = 1;
|
|
1230
|
+
(out[u] || []).forEach(function (e) {
|
|
1231
|
+
var v = e[1];
|
|
1232
|
+
if (!keep[v])
|
|
1233
|
+
return;
|
|
1234
|
+
if (color[v] === 1)
|
|
1235
|
+
back[e[0] + ">" + e[1]] = 1;
|
|
1236
|
+
else if (!color[v])
|
|
1237
|
+
dfs(v);
|
|
1238
|
+
});
|
|
1239
|
+
color[u] = 2;
|
|
1240
|
+
}
|
|
1241
|
+
roots.forEach(function (r) { if (keep[r] && !color[r])
|
|
1242
|
+
dfs(r); });
|
|
1243
|
+
var changed = true, guard = 0;
|
|
1244
|
+
while (changed && guard++ < 80) {
|
|
1245
|
+
changed = false;
|
|
1246
|
+
data.edges.forEach(function (e) {
|
|
1247
|
+
if (!keep[e[0]] || !keep[e[1]] || back[e[0] + ">" + e[1]])
|
|
1248
|
+
return;
|
|
1249
|
+
if (layer[e[0]] + 1 > layer[e[1]]) {
|
|
1250
|
+
layer[e[1]] = layer[e[0]] + 1;
|
|
1251
|
+
changed = true;
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
return { keep: keep, layer: layer, back: back, accHidden: accHidden, total: total, capped: capped };
|
|
1256
|
+
}
|
|
1257
|
+
// Nested-browser view (static pages): the clicked document's pre-rendered
|
|
1258
|
+
// sibling .html shown INSIDE the graph area — an in-mount iframe, never a
|
|
1259
|
+
// whole-page navigation. "back" restores the graph exactly as it was.
|
|
1260
|
+
function drawFrame() {
|
|
1261
|
+
mount.replaceChildren();
|
|
1262
|
+
var bar = document.createElement("div");
|
|
1263
|
+
bar.className = "cg-bar";
|
|
1264
|
+
var crumb = document.createElement("span");
|
|
1265
|
+
crumb.className = "cg-crumb";
|
|
1266
|
+
var backBtn = document.createElement("button");
|
|
1267
|
+
backBtn.className = "cg-seg";
|
|
1268
|
+
backBtn.textContent = "◂ back";
|
|
1269
|
+
backBtn.onclick = function () { state.frame = null; draw(); };
|
|
1270
|
+
crumb.appendChild(backBtn);
|
|
1271
|
+
var sp = document.createElement("span");
|
|
1272
|
+
sp.textContent = " / " + String(state.frame.rel).replace(/\.geml$/, "");
|
|
1273
|
+
crumb.appendChild(sp);
|
|
1274
|
+
bar.appendChild(crumb);
|
|
1275
|
+
var open = document.createElement("a");
|
|
1276
|
+
open.href = state.frame.html;
|
|
1277
|
+
open.textContent = "open standalone ↗";
|
|
1278
|
+
bar.appendChild(open);
|
|
1279
|
+
mount.appendChild(bar);
|
|
1280
|
+
var fr = document.createElement("iframe");
|
|
1281
|
+
fr.className = "cg-frame";
|
|
1282
|
+
fr.setAttribute("src", state.frame.html);
|
|
1283
|
+
fr.setAttribute("title", state.frame.rel);
|
|
1284
|
+
mount.appendChild(fr);
|
|
1285
|
+
}
|
|
1286
|
+
function draw() {
|
|
1287
|
+
if (state.frame) {
|
|
1288
|
+
drawFrame();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
var s = slice(state.roots);
|
|
1292
|
+
// The callers view reads in TRUE call order — app entry first, the
|
|
1293
|
+
// focused method at the far end. Its slice is built from the focus
|
|
1294
|
+
// outward (edges callee -> caller), so flip the layers and swap edge
|
|
1295
|
+
// endpoints at draw time: call direction stays left->right (top->down)
|
|
1296
|
+
// in every view.
|
|
1297
|
+
var isUp = data.dir === "up";
|
|
1298
|
+
if (isUp) {
|
|
1299
|
+
var maxL = 0, fk;
|
|
1300
|
+
for (fk in s.layer)
|
|
1301
|
+
if (s.layer[fk] > maxL)
|
|
1302
|
+
maxL = s.layer[fk];
|
|
1303
|
+
for (fk in s.layer)
|
|
1304
|
+
s.layer[fk] = maxL - s.layer[fk];
|
|
1305
|
+
}
|
|
1306
|
+
// Group tint: front-end and back-end (and any other top-level module)
|
|
1307
|
+
// stopped being distinguishable once merged into one map — colour by
|
|
1308
|
+
// top path segment (module overview) / owning document (method view).
|
|
1309
|
+
var PALETTE = ["#e3f2fd", "#e8f5e9", "#fff3e0", "#f3e5f5", "#e0f7fa", "#fce4ec", "#f1f8e9", "#ede7f6", "#fff8e1", "#e0f2f1", "#efebe9", "#f9fbe7"];
|
|
1310
|
+
function groupOf(k) {
|
|
1311
|
+
return (data.mode === "modules"
|
|
1312
|
+
? (data.nodes[k].tg || String(data.nodes[k].n).split("/")[0])
|
|
1313
|
+
: String(k).split("#")[0]) || "";
|
|
1314
|
+
}
|
|
1315
|
+
var gnames = [];
|
|
1316
|
+
Object.keys(s.keep).forEach(function (k) { var gn = groupOf(k); if (gnames.indexOf(gn) < 0)
|
|
1317
|
+
gnames.push(gn); });
|
|
1318
|
+
gnames.sort();
|
|
1319
|
+
var rows = [];
|
|
1320
|
+
Object.keys(s.keep).forEach(function (k) {
|
|
1321
|
+
(rows[s.layer[k]] = rows[s.layer[k]] || []).push(k);
|
|
1322
|
+
});
|
|
1323
|
+
rows = rows.filter(function (r) { return r && r.length; });
|
|
1324
|
+
// In-layer order: group first (same-tint nodes sit together), name
|
|
1325
|
+
// second — and the layout leaves a small extra gap where the group
|
|
1326
|
+
// changes, so the colour runs read as blocks.
|
|
1327
|
+
rows.forEach(function (r) {
|
|
1328
|
+
r.sort(function (a, b) {
|
|
1329
|
+
var ga = groupOf(a), gb = groupOf(b);
|
|
1330
|
+
if (ga !== gb)
|
|
1331
|
+
return ga < gb ? -1 : 1;
|
|
1332
|
+
return data.nodes[a].n < data.nodes[b].n ? -1 : 1;
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
var NH = 26, GY = 44, GX = 14, GYL = 12, GXL = 70, GG = 22, pos = {}, W = 320, H = 0;
|
|
1336
|
+
var LR = state.dir === "LR";
|
|
1337
|
+
var isMethod = data.mode !== "modules";
|
|
1338
|
+
// Box width follows the DISPLAYED label, and the label is truncated to
|
|
1339
|
+
// fit the box — long dir-path module names used to overflow their 220px
|
|
1340
|
+
// cap and stack onto their neighbours. Modules keep the TAIL (the
|
|
1341
|
+
// informative end of a path), methods keep the head. The ⊕ direction
|
|
1342
|
+
// handle is now its OWN node beside the box (drawn below), so the box
|
|
1343
|
+
// width no longer reserves room for it.
|
|
1344
|
+
function label(k) {
|
|
1345
|
+
var n = data.nodes[k];
|
|
1346
|
+
// ▶ = this module (or group) holds an app entry — where the program
|
|
1347
|
+
// starts, from index meta entry= / app-entry-docs.
|
|
1348
|
+
var full = (n.appEntry || n.entry ? "▶ " : "") + n.n + (n.more ? " ›" : "");
|
|
1349
|
+
if (full.length <= 32)
|
|
1350
|
+
return full;
|
|
1351
|
+
return data.mode === "modules" ? "…" + full.slice(full.length - 31) : full.slice(0, 31) + "…";
|
|
1352
|
+
}
|
|
1353
|
+
// The ⊕ callers handle sits only on the current view's ROOTS: a
|
|
1354
|
+
// mid-graph node's callers are already drawn as its in-edges — the
|
|
1355
|
+
// entry is the one place the upstream is invisible. In the callers
|
|
1356
|
+
// view the focused method (far end) carries the mirrored handle that
|
|
1357
|
+
// flips back to its callee chain.
|
|
1358
|
+
function hasUp(k) { return isMethod && !isUp && state.roots.indexOf(k) >= 0; }
|
|
1359
|
+
function hasDown(k) { return isUp && k === data.focus; }
|
|
1360
|
+
function bw(k) { return Math.max(56, label(k).length * 7.2 + 18); }
|
|
1361
|
+
if (!LR) {
|
|
1362
|
+
rows.forEach(function (r, ri) {
|
|
1363
|
+
var x = 0;
|
|
1364
|
+
r.forEach(function (k, i) {
|
|
1365
|
+
if (i > 0 && groupOf(r[i - 1]) !== groupOf(k))
|
|
1366
|
+
x += GG;
|
|
1367
|
+
var w = bw(k);
|
|
1368
|
+
pos[k] = { x: x, y: ri * (NH + GY), w: w };
|
|
1369
|
+
x += w + GX;
|
|
1370
|
+
});
|
|
1371
|
+
W = Math.max(W, x - GX);
|
|
1372
|
+
});
|
|
1373
|
+
rows.forEach(function (r) {
|
|
1374
|
+
var rw = pos[r[r.length - 1]].x + pos[r[r.length - 1]].w;
|
|
1375
|
+
var off = (W - rw) / 2;
|
|
1376
|
+
r.forEach(function (k) { pos[k].x += off; });
|
|
1377
|
+
});
|
|
1378
|
+
H = rows.length * (NH + GY) - GY;
|
|
1379
|
+
}
|
|
1380
|
+
else {
|
|
1381
|
+
// Left-to-right: layers become columns, flow reads with the text.
|
|
1382
|
+
var cx = 0, colHs = [];
|
|
1383
|
+
rows.forEach(function (r, ci) {
|
|
1384
|
+
var cw = 0, y = 0;
|
|
1385
|
+
r.forEach(function (k, i) {
|
|
1386
|
+
if (i > 0 && groupOf(r[i - 1]) !== groupOf(k))
|
|
1387
|
+
y += GG;
|
|
1388
|
+
var w = bw(k);
|
|
1389
|
+
pos[k] = { x: cx, y: y, w: w };
|
|
1390
|
+
y += NH + GYL;
|
|
1391
|
+
if (w > cw)
|
|
1392
|
+
cw = w;
|
|
1393
|
+
});
|
|
1394
|
+
colHs[ci] = y - GYL;
|
|
1395
|
+
if (colHs[ci] > H)
|
|
1396
|
+
H = colHs[ci];
|
|
1397
|
+
cx += cw + GXL;
|
|
1398
|
+
});
|
|
1399
|
+
W = Math.max(320, cx - GXL);
|
|
1400
|
+
rows.forEach(function (r, ci) {
|
|
1401
|
+
var off = (H - colHs[ci]) / 2;
|
|
1402
|
+
r.forEach(function (k) { pos[k].y += off; });
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
// The standalone ⊕ node sits just OUTSIDE the box on the direction it
|
|
1406
|
+
// points — reserve a margin so it never clips the canvas edge or a
|
|
1407
|
+
// neighbour. It rides the caller side of a callee-view entry (left in
|
|
1408
|
+
// LR, top in TB) and the callee side of the callers-view focus (right /
|
|
1409
|
+
// bottom). Only one side is ever active in a given view.
|
|
1410
|
+
var UBOFF = 17, UBPAD = 24;
|
|
1411
|
+
var anyUp = false, anyDown = false;
|
|
1412
|
+
Object.keys(s.keep).forEach(function (k) { if (hasUp(k))
|
|
1413
|
+
anyUp = true;
|
|
1414
|
+
else if (hasDown(k))
|
|
1415
|
+
anyDown = true; });
|
|
1416
|
+
var padL = anyUp && LR ? UBPAD : 0, padT = anyUp && !LR ? UBPAD : 0;
|
|
1417
|
+
var padR = anyDown && LR ? UBPAD : 0, padB = anyDown && !LR ? UBPAD : 0;
|
|
1418
|
+
if (padL || padT)
|
|
1419
|
+
for (var pk in pos) {
|
|
1420
|
+
pos[pk].x += padL;
|
|
1421
|
+
pos[pk].y += padT;
|
|
1422
|
+
}
|
|
1423
|
+
W += padL + padR;
|
|
1424
|
+
H += padT + padB;
|
|
1425
|
+
var svg = h("svg", { viewBox: "0 0 " + W + " " + (H + 8), class: "cg-svg", role: "img" });
|
|
1426
|
+
// Small arrowheads, always pointing at the CALLEE — two fixed markers
|
|
1427
|
+
// (normal grey, back-edge red) rather than context-stroke, which not
|
|
1428
|
+
// every engine paints yet.
|
|
1429
|
+
var arrId = "cg-arr-" + arrowSeq++;
|
|
1430
|
+
var defs = h("defs", {});
|
|
1431
|
+
[["", "#94a3b8"], ["-b", "#dc2626"]].forEach(function (mdef) {
|
|
1432
|
+
var mk = h("marker", { id: arrId + mdef[0], viewBox: "0 0 10 10", refX: 8.5, refY: 5, markerWidth: 5.5, markerHeight: 5.5, orient: "auto" });
|
|
1433
|
+
mk.appendChild(h("path", { d: "M0 1.2 L8.5 5 L0 8.8 z", fill: mdef[1] }));
|
|
1434
|
+
defs.appendChild(mk);
|
|
1435
|
+
});
|
|
1436
|
+
svg.appendChild(defs);
|
|
1437
|
+
// Hover: light up the CALLER CONE of the node under the pointer —
|
|
1438
|
+
// every upstream node and edge in the current view — and dim the rest.
|
|
1439
|
+
// upAdj maps each node to its callers within the drawn slice (in the
|
|
1440
|
+
// callers view the data edges already point callee -> caller).
|
|
1441
|
+
var upAdj = {};
|
|
1442
|
+
var nodeEls = {}, nodeBase = {};
|
|
1443
|
+
var edgeEls = {}, edgeBase = {};
|
|
1444
|
+
data.edges.forEach(function (e) {
|
|
1445
|
+
var a = pos[isUp ? e[1] : e[0]], b = pos[isUp ? e[0] : e[1]];
|
|
1446
|
+
if (!a || !b)
|
|
1447
|
+
return;
|
|
1448
|
+
var isBack = s.back[e[0] + ">" + e[1]] || (e[0] === e[1]);
|
|
1449
|
+
var cls = "cg-e" + (e[2] === "candidate" ? " cand" : "") + (isBack ? " back" : "") + (e[3] === "medium" || e[3] === "low" ? " soft" : "");
|
|
1450
|
+
var p;
|
|
1451
|
+
if (e[0] === e[1]) {
|
|
1452
|
+
p = LR
|
|
1453
|
+
? "M" + (a.x + 8) + " " + (a.y + NH) + " c 0 16 16 16 16 0"
|
|
1454
|
+
: "M" + (a.x + a.w) + " " + (a.y + 8) + " c 18 0 18 " + (NH - 16) + " 0 " + (NH - 16);
|
|
1455
|
+
}
|
|
1456
|
+
else if (isBack) {
|
|
1457
|
+
if (LR) {
|
|
1458
|
+
var yb = Math.max(a.y, b.y) + NH + 24;
|
|
1459
|
+
p = "M" + (a.x + a.w / 2) + " " + (a.y + NH) + " C " + (a.x + a.w / 2) + " " + yb + " " + (b.x + b.w / 2) + " " + yb + " " + (b.x + b.w / 2) + " " + (b.y + NH);
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
var xr = Math.max(a.x + a.w, b.x + b.w) + 22;
|
|
1463
|
+
p = "M" + (a.x + a.w) + " " + (a.y + NH / 2) + " C " + xr + " " + (a.y + NH / 2) + " " + xr + " " + (b.y + NH / 2) + " " + (b.x + b.w) + " " + (b.y + NH / 2);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
else if (LR) {
|
|
1467
|
+
var lx1 = a.x + a.w, ly1 = a.y + NH / 2, lx2 = b.x, ly2 = b.y + NH / 2;
|
|
1468
|
+
p = "M" + lx1 + " " + ly1 + " C " + (lx1 + GXL / 2) + " " + ly1 + " " + (lx2 - GXL / 2) + " " + ly2 + " " + lx2 + " " + ly2;
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
var x1 = a.x + a.w / 2, y1 = a.y + NH, x2 = b.x + b.w / 2, y2 = b.y;
|
|
1472
|
+
p = "M" + x1 + " " + y1 + " C " + x1 + " " + (y1 + GY / 2) + " " + x2 + " " + (y2 - GY / 2) + " " + x2 + " " + y2;
|
|
1473
|
+
}
|
|
1474
|
+
var pathEl = h("path", { d: p, class: cls, "marker-end": "url(#" + arrId + (isBack ? "-b" : "") + ")" });
|
|
1475
|
+
var ek = e[0] + ">" + e[1];
|
|
1476
|
+
edgeEls[ek] = pathEl;
|
|
1477
|
+
edgeBase[ek] = cls;
|
|
1478
|
+
var callee = isUp ? e[0] : e[1], caller = isUp ? e[1] : e[0];
|
|
1479
|
+
(upAdj[callee] = upAdj[callee] || []).push({ n: caller, k: ek });
|
|
1480
|
+
if (data.mode === "modules" && e[3]) {
|
|
1481
|
+
var et = h("title", {});
|
|
1482
|
+
et.textContent = e[3] + " call(s)";
|
|
1483
|
+
pathEl.appendChild(et);
|
|
1484
|
+
}
|
|
1485
|
+
svg.appendChild(pathEl);
|
|
1486
|
+
});
|
|
1487
|
+
Object.keys(s.keep).forEach(function (k) {
|
|
1488
|
+
var n = data.nodes[k], a = pos[k];
|
|
1489
|
+
var ncls = "cg-n" + (n.leaf ? " leaf" : "") + (n.test ? " test" : "") + (n.grp ? " grp" : "") + (state.roots.indexOf(k) >= 0 ? " root" : "");
|
|
1490
|
+
var g = h("g", { class: ncls, "data-k": k, transform: "translate(" + a.x + "," + a.y + ")" });
|
|
1491
|
+
nodeEls[k] = g;
|
|
1492
|
+
nodeBase[k] = ncls;
|
|
1493
|
+
g.appendChild(h("rect", { width: a.w, height: NH, rx: 6, style: "fill:" + PALETTE[gnames.indexOf(groupOf(k)) % PALETTE.length] }));
|
|
1494
|
+
var t = h("text", { x: hasUp(k) ? a.w / 2 + 8 : hasDown(k) ? a.w / 2 - 8 : a.w / 2, y: NH / 2 + 4, "text-anchor": "middle" });
|
|
1495
|
+
t.textContent = label(k);
|
|
1496
|
+
g.appendChild(t);
|
|
1497
|
+
var tip = h("title", {});
|
|
1498
|
+
tip.textContent = data.mode === "modules"
|
|
1499
|
+
? (n.grp ? (n.grp.join("/") + "\nclick: open this group")
|
|
1500
|
+
: n.ext ? ("external dependency: " + n.n.replace(/^↗ /, ""))
|
|
1501
|
+
: n.n + "\nclick: open this module")
|
|
1502
|
+
: k + (n.src ? "\n" + n.src : "") + "\nclick = view source";
|
|
1503
|
+
g.appendChild(tip);
|
|
1504
|
+
svg.appendChild(g);
|
|
1505
|
+
if (hasUp(k) || hasDown(k)) {
|
|
1506
|
+
// The ⊕ handle (GEP-0003 caller direction) is now its OWN node
|
|
1507
|
+
// beside the box — no longer a child glued inside the box edge.
|
|
1508
|
+
// Same data-k / data-act / click: it focuses this node and TOGGLES
|
|
1509
|
+
// direction. It sits on the LEFT of a callee-view entry (expand
|
|
1510
|
+
// callers) and mirrors to the RIGHT of the callers-view focus (flip
|
|
1511
|
+
// back down); in top-down those become above / below. The reserved
|
|
1512
|
+
// margin above keeps it clear of the canvas edge and neighbours.
|
|
1513
|
+
var up = hasUp(k), ubx, uby;
|
|
1514
|
+
if (up) {
|
|
1515
|
+
if (LR) {
|
|
1516
|
+
ubx = a.x - UBOFF;
|
|
1517
|
+
uby = a.y + NH / 2;
|
|
1518
|
+
}
|
|
1519
|
+
else {
|
|
1520
|
+
ubx = a.x + a.w / 2;
|
|
1521
|
+
uby = a.y - UBOFF;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
if (LR) {
|
|
1526
|
+
ubx = a.x + a.w + UBOFF;
|
|
1527
|
+
uby = a.y + NH / 2;
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
ubx = a.x + a.w / 2;
|
|
1531
|
+
uby = a.y + NH + UBOFF;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
// Dashed connector ties the handle to its node and shows which way
|
|
1535
|
+
// the hidden chain flows: callers flow INTO the node (⊕ -> box),
|
|
1536
|
+
// callees flow OUT of it (box -> ⊕). Same grey + arrowhead as real
|
|
1537
|
+
// edges; dashed = "not expanded yet"; never a click target.
|
|
1538
|
+
var R = 6.5, TIP = 1.5, lx1, ly1, lx2, ly2;
|
|
1539
|
+
if (up) {
|
|
1540
|
+
if (LR) {
|
|
1541
|
+
lx1 = ubx + R;
|
|
1542
|
+
ly1 = uby;
|
|
1543
|
+
lx2 = a.x - TIP;
|
|
1544
|
+
ly2 = uby;
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
lx1 = ubx;
|
|
1548
|
+
ly1 = uby + R;
|
|
1549
|
+
lx2 = ubx;
|
|
1550
|
+
ly2 = a.y - TIP;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else if (LR) {
|
|
1554
|
+
lx1 = a.x + a.w + TIP;
|
|
1555
|
+
ly1 = uby;
|
|
1556
|
+
lx2 = ubx - R - TIP;
|
|
1557
|
+
ly2 = uby;
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
lx1 = ubx;
|
|
1561
|
+
ly1 = a.y + NH + TIP;
|
|
1562
|
+
lx2 = ubx;
|
|
1563
|
+
ly2 = uby - R - TIP;
|
|
1564
|
+
}
|
|
1565
|
+
svg.appendChild(h("path", { class: "cg-uplink", d: "M" + lx1 + " " + ly1 + " L" + lx2 + " " + ly2, "marker-end": "url(#" + arrId + ")" }));
|
|
1566
|
+
var ub = h("g", { class: "cg-upbtn", "data-k": k, "data-act": up ? "up" : "down", transform: "translate(" + ubx + "," + uby + ")" });
|
|
1567
|
+
ub.appendChild(h("circle", { r: 6.5 }));
|
|
1568
|
+
var ut = h("text", { x: 0, y: 3.5, "text-anchor": "middle" });
|
|
1569
|
+
ut.textContent = "+";
|
|
1570
|
+
ub.appendChild(ut);
|
|
1571
|
+
var utip = h("title", {});
|
|
1572
|
+
utip.textContent = up ? "⊕ expand the full caller chain" : "⊕ back to its callee chain";
|
|
1573
|
+
ub.appendChild(utip);
|
|
1574
|
+
svg.appendChild(ub);
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
// Natural pixel size; only the inner .cg-scroll pane scrolls, so the
|
|
1578
|
+
// toolbar (crumb/zoom/back) and the footer stay visible however big the
|
|
1579
|
+
// canvas gets. Squeezing a 16,000px canvas into the column made 1px
|
|
1580
|
+
// text — never again.
|
|
1581
|
+
svg.setAttribute("width", String(W));
|
|
1582
|
+
svg.setAttribute("height", String(H + 8));
|
|
1583
|
+
// Rendered pages sit next to their codemap documents: a live mount
|
|
1584
|
+
// (viewer/playground) carries data-src, a CLI embed carries the src
|
|
1585
|
+
// path in data.start — either directory anchors doc-relative links.
|
|
1586
|
+
var navBase = String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, "");
|
|
1587
|
+
// A live mount (viewer/playground/served page) navigates IN PLACE over
|
|
1588
|
+
// the geml documents through this loader; only truly static pages fall
|
|
1589
|
+
// back to their pre-rendered sibling .html pages. Read LAZILY on every
|
|
1590
|
+
// use: a served page attaches the hook from an async module script that
|
|
1591
|
+
// loads after the first draw, and late binding must still take effect
|
|
1592
|
+
// on the very next interaction — no redraw, no lost state.
|
|
1593
|
+
var live = function () { return mount._cgView; };
|
|
1594
|
+
mount.replaceChildren();
|
|
1595
|
+
var bar = document.createElement("div");
|
|
1596
|
+
bar.className = "cg-bar";
|
|
1597
|
+
// Breadcrumb: modules / <container> / <state> — the hierarchy is
|
|
1598
|
+
// entry -> module -> method view, and both upper levels are clickable.
|
|
1599
|
+
var crumb = document.createElement("span");
|
|
1600
|
+
crumb.className = "cg-crumb";
|
|
1601
|
+
function seg(txt, fn) {
|
|
1602
|
+
var el = document.createElement(fn ? "button" : "span");
|
|
1603
|
+
if (fn) {
|
|
1604
|
+
el.className = "cg-seg";
|
|
1605
|
+
el.onclick = fn;
|
|
1606
|
+
}
|
|
1607
|
+
el.textContent = txt;
|
|
1608
|
+
crumb.appendChild(el);
|
|
1609
|
+
}
|
|
1610
|
+
function sepEl() { var sp = document.createElement("span"); sp.textContent = " / "; crumb.appendChild(sp); }
|
|
1611
|
+
// A transient in-bar error — the "don't jump, say why" half of the
|
|
1612
|
+
// contract: an unloadable target reports here and the view stays put.
|
|
1613
|
+
function flash(msg) {
|
|
1614
|
+
var f = document.createElement("span");
|
|
1615
|
+
f.className = "cg-flash";
|
|
1616
|
+
f.textContent = msg;
|
|
1617
|
+
bar.appendChild(f);
|
|
1618
|
+
try {
|
|
1619
|
+
setTimeout(function () { if (f.parentNode)
|
|
1620
|
+
f.parentNode.removeChild(f); }, 5000);
|
|
1621
|
+
}
|
|
1622
|
+
catch (e) { /* stub */ }
|
|
1623
|
+
}
|
|
1624
|
+
function openDoc(rel, gpath) {
|
|
1625
|
+
var lv = live();
|
|
1626
|
+
if (lv) {
|
|
1627
|
+
Promise.resolve(lv({ doc: rel })).then(function (nd) {
|
|
1628
|
+
if (!nd) {
|
|
1629
|
+
flash("cannot load " + rel);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
// A module index ships RAW rows — its nodes come from deriveView,
|
|
1633
|
+
// which is bound to a document's own data0. Re-boot on the loaded
|
|
1634
|
+
// payload so its grouping tree derives; pushView alone would draw
|
|
1635
|
+
// the empty raw payload (nodes come out {}).
|
|
1636
|
+
if (nd.mode === "modules" && nd.mods)
|
|
1637
|
+
boot(mount, nd, gpath);
|
|
1638
|
+
else
|
|
1639
|
+
pushView(nd);
|
|
1640
|
+
}, function () { flash("cannot load " + rel); });
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
var html = rel.replace(/\.geml$/, ".html");
|
|
1644
|
+
// Inside the nested frame the frame IS the browser — navigate it
|
|
1645
|
+
// plainly instead of stacking frame-in-frame.
|
|
1646
|
+
var framed = false;
|
|
1647
|
+
try {
|
|
1648
|
+
framed = window.self !== window.top;
|
|
1649
|
+
}
|
|
1650
|
+
catch (e) { /* no window: top */ }
|
|
1651
|
+
if (framed) {
|
|
1652
|
+
window.location.href = html;
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
function embed() { state.frame = { rel: rel, html: html }; draw(); }
|
|
1656
|
+
// Served over http(s): probe first, so a missing page reports in
|
|
1657
|
+
// place and nothing navigates. file:// cannot probe (fetch is
|
|
1658
|
+
// blocked) — embed directly; the frame contains any error itself.
|
|
1659
|
+
try {
|
|
1660
|
+
if (/^https?:$/.test(window.location.protocol)) {
|
|
1661
|
+
fetch(html, { method: "HEAD" }).then(function (r) {
|
|
1662
|
+
if (r.ok)
|
|
1663
|
+
embed();
|
|
1664
|
+
else
|
|
1665
|
+
flash("page missing: " + html + " — re-run the codemap render");
|
|
1666
|
+
}).catch(function () { flash("cannot reach " + html); });
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
catch (e) { /* no fetch/location — treat like file:// */ }
|
|
1671
|
+
embed();
|
|
1672
|
+
}
|
|
1673
|
+
if (data.mode === "modules") {
|
|
1674
|
+
// Breadcrumb over the grouping tree. Tunnelled runs (levels with a
|
|
1675
|
+
// single child — Java package ceremony) merge into ONE hop, labelled
|
|
1676
|
+
// first/…/last, so the crumb shows only the steps a reader chose.
|
|
1677
|
+
var gp = data.gpath || [];
|
|
1678
|
+
seg("modules", gp.length ? function () { pushView(deriveView([])); } : null);
|
|
1679
|
+
var hops = [];
|
|
1680
|
+
var cur = [];
|
|
1681
|
+
for (var hi = 0; hi < gp.length; hi++) {
|
|
1682
|
+
var hpre = hi === 0 ? "" : gp.slice(0, hi).join("/") + "/";
|
|
1683
|
+
var seen = {}, branches = 0;
|
|
1684
|
+
data0.mods.forEach(function (m) {
|
|
1685
|
+
if (hpre && m.p.indexOf(hpre) !== 0)
|
|
1686
|
+
return;
|
|
1687
|
+
var rest = m.p.slice(hpre.length);
|
|
1688
|
+
var c = rest.indexOf("/");
|
|
1689
|
+
var s2 = c < 0 ? rest : rest.slice(0, c);
|
|
1690
|
+
if (!seen[s2]) {
|
|
1691
|
+
seen[s2] = 1;
|
|
1692
|
+
branches++;
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
if (branches > 1 || hi === 0) {
|
|
1696
|
+
if (cur.length)
|
|
1697
|
+
hops.push(cur);
|
|
1698
|
+
cur = [hi];
|
|
1699
|
+
}
|
|
1700
|
+
else
|
|
1701
|
+
cur.push(hi);
|
|
1702
|
+
}
|
|
1703
|
+
if (cur.length)
|
|
1704
|
+
hops.push(cur);
|
|
1705
|
+
hops.forEach(function (hop, oi) {
|
|
1706
|
+
sepEl();
|
|
1707
|
+
var lbl = hop.length === 1 ? gp[hop[0]]
|
|
1708
|
+
: hop.length === 2 ? gp[hop[0]] + "/" + gp[hop[hop.length - 1]]
|
|
1709
|
+
: gp[hop[0]] + "/…/" + gp[hop[hop.length - 1]];
|
|
1710
|
+
var endIdx = hop[hop.length - 1];
|
|
1711
|
+
seg(lbl, oi < hops.length - 1 ? function () { pushView(deriveView(gp.slice(0, endIdx + 1))); } : null);
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
else {
|
|
1715
|
+
seg("modules", function () { openDoc(navBase + "index.geml"); });
|
|
1716
|
+
sepEl();
|
|
1717
|
+
var modName = String(data.module || String(data.start || "").replace(/^.*\//, "").replace(/\.geml$/, "") || "container");
|
|
1718
|
+
// The middle crumb reads as the OVERVIEW level ("modules / <module>"),
|
|
1719
|
+
// so clicking it goes THERE — the module tier listing this container's
|
|
1720
|
+
// siblings — not a reload of the page you are already on.
|
|
1721
|
+
seg(modName, function () {
|
|
1722
|
+
if (live())
|
|
1723
|
+
openDoc(navBase + "index.geml", [modName.split("/")[0]]);
|
|
1724
|
+
else {
|
|
1725
|
+
state.trail = [];
|
|
1726
|
+
setData(homeData());
|
|
1727
|
+
state.roots = data.roots.slice();
|
|
1728
|
+
draw();
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
sepEl();
|
|
1732
|
+
seg(data.dir === "up"
|
|
1733
|
+
? "callers of " + (data.nodes[data.focus] ? data.nodes[data.focus].n : "") + (data.partial ? " (in-slice)" : "") + (Object.keys(data.nodes).length <= 1 ? " — none recorded" : "")
|
|
1734
|
+
: state.trail.length && state.roots.length === 1 ? "root: " + (data.nodes[state.roots[0]] || {}).n
|
|
1735
|
+
// Many roots = this IS the module's own view (its whole entry
|
|
1736
|
+
// list) — the methods are already on the graph; naming them all
|
|
1737
|
+
// here just makes a paragraph-long crumb.
|
|
1738
|
+
: state.trail.length ? "roots: entry"
|
|
1739
|
+
: "roots: entry", null);
|
|
1740
|
+
}
|
|
1741
|
+
bar.appendChild(crumb);
|
|
1742
|
+
var scroller = document.createElement("div");
|
|
1743
|
+
scroller.className = "cg-scroll";
|
|
1744
|
+
scroller.appendChild(svg);
|
|
1745
|
+
// The graph and the source panel sit side by side in a flex stage; the
|
|
1746
|
+
// panel is empty (hidden) until a method node is clicked, so the graph
|
|
1747
|
+
// uses the full width until then.
|
|
1748
|
+
var srcPanel = document.createElement("div");
|
|
1749
|
+
srcPanel.className = "cg-src";
|
|
1750
|
+
srcPanel.style.display = "none";
|
|
1751
|
+
var stage = document.createElement("div");
|
|
1752
|
+
stage.className = "cg-stage";
|
|
1753
|
+
stage.appendChild(scroller);
|
|
1754
|
+
stage.appendChild(srcPanel);
|
|
1755
|
+
// The scroll pane is capped at 72vh by CSS; before first layout its
|
|
1756
|
+
// clientHeight is the unconstrained content height, so derive the cap
|
|
1757
|
+
// from the viewport. Guards keep a collapsed pane (mid-layout measure)
|
|
1758
|
+
// from producing a negative or zero scale — invalid CSS would silently
|
|
1759
|
+
// keep the previous size.
|
|
1760
|
+
function paneSize() {
|
|
1761
|
+
var mw = scroller.clientWidth || mount.clientWidth || 0;
|
|
1762
|
+
var mh = 0;
|
|
1763
|
+
try {
|
|
1764
|
+
mh = Math.floor(window.innerHeight * 0.72);
|
|
1765
|
+
}
|
|
1766
|
+
catch (e) { /* no window (stub) */ }
|
|
1767
|
+
return { w: mw, h: mh };
|
|
1768
|
+
}
|
|
1769
|
+
// The fit BUTTON: whole-graph preview, both axes visible, no floor.
|
|
1770
|
+
function fitScale() {
|
|
1771
|
+
var p = paneSize(), s = 1;
|
|
1772
|
+
if (p.w > 60 && W)
|
|
1773
|
+
s = Math.min(s, (p.w - 26) / W);
|
|
1774
|
+
if (p.h > 60 && H)
|
|
1775
|
+
s = Math.min(s, (p.h - 10) / (H + 8));
|
|
1776
|
+
return Math.max(s, 0.05);
|
|
1777
|
+
}
|
|
1778
|
+
// The INITIAL view fits the CROSS axis only — height in left-right,
|
|
1779
|
+
// width in top-down; the reading axis is meant to scroll — clamped to
|
|
1780
|
+
// [2/3, 1] so text never drops below ~8px. Small and medium graphs
|
|
1781
|
+
// land on exactly 1:1; the overview stays one "fit" click away.
|
|
1782
|
+
function initialScale() {
|
|
1783
|
+
var p = paneSize(), s = 1;
|
|
1784
|
+
if (LR) {
|
|
1785
|
+
if (p.h > 60 && H)
|
|
1786
|
+
s = (p.h - 10) / (H + 8);
|
|
1787
|
+
}
|
|
1788
|
+
else if (p.w > 60 && W)
|
|
1789
|
+
s = (p.w - 26) / W;
|
|
1790
|
+
return Math.min(1, Math.max(2 / 3, s));
|
|
1791
|
+
}
|
|
1792
|
+
function applyScale() {
|
|
1793
|
+
svg.style.width = Math.round(W * state.scale) + "px";
|
|
1794
|
+
svg.style.height = Math.round((H + 8) * state.scale) + "px";
|
|
1795
|
+
svg.style.maxWidth = "none";
|
|
1796
|
+
}
|
|
1797
|
+
function zoomBtn(label, fn) {
|
|
1798
|
+
var b = document.createElement("button");
|
|
1799
|
+
b.textContent = label;
|
|
1800
|
+
b.onclick = function () { fn(); applyScale(); };
|
|
1801
|
+
bar.appendChild(b);
|
|
1802
|
+
}
|
|
1803
|
+
zoomBtn("−", function () { state.scale = Math.max(0.1, state.scale * 0.75); });
|
|
1804
|
+
zoomBtn("+", function () { state.scale = Math.min(4, state.scale / 0.75); });
|
|
1805
|
+
zoomBtn("fit", function () { state.scale = fitScale(); });
|
|
1806
|
+
zoomBtn("1:1", function () { state.scale = 1; });
|
|
1807
|
+
var dirBtn = document.createElement("button");
|
|
1808
|
+
dirBtn.textContent = LR ? "top-down" : "left-right";
|
|
1809
|
+
dirBtn.onclick = function () {
|
|
1810
|
+
state.dir = LR ? "TB" : "LR";
|
|
1811
|
+
try {
|
|
1812
|
+
window.localStorage.setItem("geml-cg-dir", state.dir);
|
|
1813
|
+
}
|
|
1814
|
+
catch (e) { /* no storage */ }
|
|
1815
|
+
draw();
|
|
1816
|
+
};
|
|
1817
|
+
bar.appendChild(dirBtn);
|
|
1818
|
+
// Accessor noise: hidden by default, one honest button to bring it back.
|
|
1819
|
+
if (s.accHidden > 0 || state.showAcc) {
|
|
1820
|
+
var accBtn = document.createElement("button");
|
|
1821
|
+
accBtn.textContent = state.showAcc ? "hide accessors" : s.accHidden + " accessors hidden";
|
|
1822
|
+
accBtn.onclick = function () { state.showAcc = !state.showAcc; draw(); };
|
|
1823
|
+
bar.appendChild(accBtn);
|
|
1824
|
+
}
|
|
1825
|
+
// View pacing: the slice beyond the cap is one click away, never lost.
|
|
1826
|
+
if (s.capped > 0) {
|
|
1827
|
+
var capInfo = document.createElement("span");
|
|
1828
|
+
capInfo.className = "cg-note";
|
|
1829
|
+
capInfo.textContent = "showing " + (s.total - s.capped) + " of " + s.total + " reachable";
|
|
1830
|
+
bar.appendChild(capInfo);
|
|
1831
|
+
var moreBtn = document.createElement("button");
|
|
1832
|
+
moreBtn.textContent = "+600";
|
|
1833
|
+
moreBtn.onclick = function () { state.cap += 600; draw(); };
|
|
1834
|
+
bar.appendChild(moreBtn);
|
|
1835
|
+
var allBtn = document.createElement("button");
|
|
1836
|
+
allBtn.textContent = "all";
|
|
1837
|
+
allBtn.onclick = function () { state.cap = 1e9; draw(); };
|
|
1838
|
+
bar.appendChild(allBtn);
|
|
1839
|
+
}
|
|
1840
|
+
if (state.trail.length) {
|
|
1841
|
+
var backBtn = document.createElement("button");
|
|
1842
|
+
backBtn.textContent = "back";
|
|
1843
|
+
backBtn.onclick = function () { var tr = state.trail.pop(); setData(tr.data); state.roots = tr.roots; draw(); };
|
|
1844
|
+
bar.appendChild(backBtn);
|
|
1845
|
+
var resetBtn = document.createElement("button");
|
|
1846
|
+
resetBtn.textContent = "reset";
|
|
1847
|
+
resetBtn.onclick = function () { state.trail = []; setData(homeData()); state.roots = data.roots.slice(); draw(); };
|
|
1848
|
+
bar.appendChild(resetBtn);
|
|
1849
|
+
}
|
|
1850
|
+
// Find a method by name -> jump to its node. Data source: a served page
|
|
1851
|
+
// (http) queries the /_search endpoint (top matches only — a huge index
|
|
1852
|
+
// never ships); a static page (file://) lazy-loads the compact
|
|
1853
|
+
// _index/search-index.js via <script> (fetch is CORS-blocked on file://,
|
|
1854
|
+
// a script tag is not). Picking a hit opens its FOCUSED call graph (B)
|
|
1855
|
+
// in place on a served page; on a static page (no live loader) it
|
|
1856
|
+
// navigates to the node's document (A). Alt-click always just locates.
|
|
1857
|
+
if (typeof location !== "undefined") { // browser only — skipped in the fake-DOM runtime test
|
|
1858
|
+
var searchWrap = document.createElement("span");
|
|
1859
|
+
searchWrap.className = "cg-search-wrap";
|
|
1860
|
+
var searchBox = document.createElement("input");
|
|
1861
|
+
searchBox.type = "search";
|
|
1862
|
+
searchBox.className = "cg-search";
|
|
1863
|
+
searchBox.placeholder = "find a method…";
|
|
1864
|
+
searchBox.setAttribute("aria-label", "Find a method by name");
|
|
1865
|
+
var searchMenu = document.createElement("div");
|
|
1866
|
+
searchMenu.className = "cg-search-menu";
|
|
1867
|
+
searchMenu.hidden = true;
|
|
1868
|
+
searchWrap.appendChild(searchBox);
|
|
1869
|
+
searchWrap.appendChild(searchMenu);
|
|
1870
|
+
bar.appendChild(searchWrap);
|
|
1871
|
+
var srvSearch = /^https?:$/.test(location.protocol);
|
|
1872
|
+
function withIndex(cb) {
|
|
1873
|
+
if (window.__gemlSearch)
|
|
1874
|
+
return cb(window.__gemlSearch);
|
|
1875
|
+
var s = document.createElement("script");
|
|
1876
|
+
s.src = navBase + "_index/search-index.js";
|
|
1877
|
+
s.onload = function () { cb(window.__gemlSearch || []); };
|
|
1878
|
+
s.onerror = function () { cb([]); };
|
|
1879
|
+
document.head.appendChild(s);
|
|
1880
|
+
}
|
|
1881
|
+
// Rank exactly like serve's /_search (exact > prefix > qualified-tail
|
|
1882
|
+
// prefix > substring), so both data paths order hits the same way.
|
|
1883
|
+
function hitScore(n, q) {
|
|
1884
|
+
if (n === q)
|
|
1885
|
+
return 0;
|
|
1886
|
+
if (n.indexOf(q) === 0)
|
|
1887
|
+
return 1;
|
|
1888
|
+
var c2 = n.lastIndexOf("::"), d = n.lastIndexOf(".");
|
|
1889
|
+
var cut = Math.max(c2 >= 0 ? c2 + 2 : 0, d >= 0 ? d + 1 : 0);
|
|
1890
|
+
if (cut > 0 && n.slice(cut).indexOf(q) === 0)
|
|
1891
|
+
return 2;
|
|
1892
|
+
return n.indexOf(q) >= 0 ? 3 : -1;
|
|
1893
|
+
}
|
|
1894
|
+
function candidates(q, cb) {
|
|
1895
|
+
q = q.trim().toLowerCase();
|
|
1896
|
+
if (q.length < 2)
|
|
1897
|
+
return cb({ total: 0, hits: [] });
|
|
1898
|
+
if (srvSearch) {
|
|
1899
|
+
fetch("/_search?q=" + encodeURIComponent(q))
|
|
1900
|
+
.then(function (r) { return r.ok ? r.json() : { total: 0, hits: [] }; })
|
|
1901
|
+
.then(function (a) { cb(a && a.hits ? a : { total: 0, hits: [] }); })
|
|
1902
|
+
.catch(function () { cb({ total: 0, hits: [] }); });
|
|
1903
|
+
}
|
|
1904
|
+
else {
|
|
1905
|
+
withIndex(function (rows) {
|
|
1906
|
+
var ranked = [];
|
|
1907
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1908
|
+
var s = hitScore(String(rows[i][0]).toLowerCase(), q);
|
|
1909
|
+
if (s >= 0)
|
|
1910
|
+
ranked.push({ s: s, name: rows[i][0], doc: rows[i][1], id: rows[i][2] });
|
|
1911
|
+
}
|
|
1912
|
+
ranked.sort(function (a, b) { return a.s - b.s || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); });
|
|
1913
|
+
// The lookup aliases bare member names to the same node — dedupe
|
|
1914
|
+
// on doc#id, keeping the best-ranked row.
|
|
1915
|
+
var seen = {}, hits = [];
|
|
1916
|
+
for (var j = 0; j < ranked.length; j++) {
|
|
1917
|
+
var rj = ranked[j];
|
|
1918
|
+
var k = rj.doc + "#" + rj.id;
|
|
1919
|
+
if (seen[k])
|
|
1920
|
+
continue;
|
|
1921
|
+
seen[k] = 1;
|
|
1922
|
+
hits.push(rj);
|
|
1923
|
+
}
|
|
1924
|
+
cb({ total: hits.length, hits: hits.slice(0, 100) });
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function gotoHit(doc, id, locate) {
|
|
1929
|
+
searchMenu.hidden = true;
|
|
1930
|
+
if (live() && !locate) {
|
|
1931
|
+
showCallees(doc + "#" + id);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
location.href = navBase + doc.replace(/\.geml$/, ".html") + "#" + encodeURIComponent(id);
|
|
1935
|
+
}
|
|
1936
|
+
var searchSeq = 0, searchTop = null; // best-ranked hit — Enter opens it
|
|
1937
|
+
searchBox.addEventListener("input", function () {
|
|
1938
|
+
var my = ++searchSeq, qv = searchBox.value;
|
|
1939
|
+
candidates(qv, function (res) {
|
|
1940
|
+
if (my !== searchSeq)
|
|
1941
|
+
return; // a newer keystroke already fired
|
|
1942
|
+
searchMenu.replaceChildren();
|
|
1943
|
+
var hits = res.hits || [];
|
|
1944
|
+
searchTop = hits.length ? hits[0] : null;
|
|
1945
|
+
if (!hits.length) {
|
|
1946
|
+
searchMenu.hidden = true;
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
// Honest count first — a capped list must say so.
|
|
1950
|
+
var count = document.createElement("div");
|
|
1951
|
+
count.className = "cg-search-count";
|
|
1952
|
+
count.textContent = (res.total > hits.length ? "showing " + hits.length + " of " + res.total + " matches" : res.total + (res.total === 1 ? " match" : " matches")) + " · Enter opens the first";
|
|
1953
|
+
searchMenu.appendChild(count);
|
|
1954
|
+
// Group by module (document), groups in best-hit order — hits arrive
|
|
1955
|
+
// globally ranked, so first appearance = the group's best rank.
|
|
1956
|
+
var order = [], byDoc = {};
|
|
1957
|
+
hits.forEach(function (c) {
|
|
1958
|
+
if (!byDoc[c.doc]) {
|
|
1959
|
+
byDoc[c.doc] = [];
|
|
1960
|
+
order.push(c.doc);
|
|
1961
|
+
}
|
|
1962
|
+
byDoc[c.doc].push(c);
|
|
1963
|
+
});
|
|
1964
|
+
order.forEach(function (doc) {
|
|
1965
|
+
var hd = document.createElement("div");
|
|
1966
|
+
hd.className = "cg-search-grp";
|
|
1967
|
+
hd.textContent = String(doc).replace(/\.geml$/, "").replace(/--/g, "/");
|
|
1968
|
+
searchMenu.appendChild(hd);
|
|
1969
|
+
byDoc[doc].forEach(function (c) {
|
|
1970
|
+
var row = document.createElement("button");
|
|
1971
|
+
row.className = "cg-search-row";
|
|
1972
|
+
row.type = "button";
|
|
1973
|
+
var nm = document.createElement("b");
|
|
1974
|
+
nm.textContent = c.name;
|
|
1975
|
+
row.appendChild(nm);
|
|
1976
|
+
row.onclick = function (ev) { gotoHit(c.doc, c.id, !!ev.altKey); };
|
|
1977
|
+
searchMenu.appendChild(row);
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
searchMenu.hidden = false;
|
|
1981
|
+
});
|
|
1982
|
+
});
|
|
1983
|
+
searchBox.addEventListener("keydown", function (ev) {
|
|
1984
|
+
if (ev.key === "Escape") {
|
|
1985
|
+
searchMenu.hidden = true;
|
|
1986
|
+
searchBox.blur();
|
|
1987
|
+
}
|
|
1988
|
+
else if (ev.key === "Enter" && searchTop) {
|
|
1989
|
+
ev.preventDefault();
|
|
1990
|
+
gotoHit(searchTop.doc, searchTop.id, !!ev.altKey);
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
document.addEventListener("click", function (ev) { if (!searchWrap.contains(ev.target))
|
|
1994
|
+
searchMenu.hidden = true; });
|
|
1995
|
+
} // end browser-only search box
|
|
1996
|
+
mount.appendChild(bar);
|
|
1997
|
+
mount.appendChild(stage);
|
|
1998
|
+
if (state.scale === null)
|
|
1999
|
+
state.scale = initialScale();
|
|
2000
|
+
applyScale();
|
|
2001
|
+
if (isUp) {
|
|
2002
|
+
// The focused method sits at the FAR end of the callers chain —
|
|
2003
|
+
// scroll it into view instead of opening on the app-entry end.
|
|
2004
|
+
if (LR)
|
|
2005
|
+
scroller.scrollLeft = 1e6;
|
|
2006
|
+
else
|
|
2007
|
+
scroller.scrollTop = 1e6;
|
|
2008
|
+
}
|
|
2009
|
+
// Centre the CROSS axis (the fit-to-pane one): the tree fans out around
|
|
2010
|
+
// its midline, so a big graph clamped to the 2/3 scale floor would
|
|
2011
|
+
// otherwise open on an empty top/left corner with every node off-screen.
|
|
2012
|
+
// Reading the scroll extent forces the post-applyScale reflow; when the
|
|
2013
|
+
// cross axis already fits, the delta is ≤0 and this is a no-op. The
|
|
2014
|
+
// reading axis is untouched (root start, or far-end for callers above).
|
|
2015
|
+
// If the view holds an app entry (▶), aim the midline at the FIRST one
|
|
2016
|
+
// (roots first, then any node) instead of the geometric centre — the
|
|
2017
|
+
// reader lands where the program starts.
|
|
2018
|
+
var entryK = null;
|
|
2019
|
+
state.roots.concat(Object.keys(data.nodes)).some(function (ek) {
|
|
2020
|
+
var en = data.nodes[ek];
|
|
2021
|
+
if (en && (en.appEntry || en.entry) && pos[ek]) {
|
|
2022
|
+
entryK = ek;
|
|
2023
|
+
return true;
|
|
2024
|
+
}
|
|
2025
|
+
return false;
|
|
2026
|
+
});
|
|
2027
|
+
var aim = function (full, pane, at) { return Math.max(0, Math.min(full - pane, at - pane / 2)); };
|
|
2028
|
+
if (LR)
|
|
2029
|
+
scroller.scrollTop = entryK
|
|
2030
|
+
? aim(scroller.scrollHeight, scroller.clientHeight, (pos[entryK].y + NH / 2) * state.scale)
|
|
2031
|
+
: Math.max(0, (scroller.scrollHeight - scroller.clientHeight) / 2);
|
|
2032
|
+
else
|
|
2033
|
+
scroller.scrollLeft = entryK
|
|
2034
|
+
? aim(scroller.scrollWidth, scroller.clientWidth, (pos[entryK].x + pos[entryK].w / 2) * state.scale)
|
|
2035
|
+
: Math.max(0, (scroller.scrollWidth - scroller.clientWidth) / 2);
|
|
2036
|
+
// Footer: live facts, not a static cheat-sheet (navigation lives in
|
|
2037
|
+
// the breadcrumb above).
|
|
2038
|
+
var footer = document.createElement("div");
|
|
2039
|
+
footer.className = "cg-legend";
|
|
2040
|
+
var info = document.createElement("span");
|
|
2041
|
+
info.textContent = data.mode === "modules"
|
|
2042
|
+
? Object.keys(s.keep).length + " modules · " + data.edges.length + " edges · click a module to open it"
|
|
2043
|
+
: isUp && Object.keys(data.nodes).length <= 1
|
|
2044
|
+
? "no recorded callers — framework/reflective entry points and dead code have none · ⊕ at the end = back to callees"
|
|
2045
|
+
: Object.keys(s.keep).length + "/" + Object.keys(data.nodes).length + " methods in view · click = view source · " + (isUp ? "⊕ at the end = back to callees" : "⊕ on an entry = full caller chain");
|
|
2046
|
+
footer.appendChild(info);
|
|
2047
|
+
mount.appendChild(footer);
|
|
2048
|
+
// Colour key — one chip per group (skip when it would be noise).
|
|
2049
|
+
if (gnames.length > 1 && gnames.length <= 14) {
|
|
2050
|
+
var chips = document.createElement("div");
|
|
2051
|
+
chips.className = "cg-groups";
|
|
2052
|
+
gnames.forEach(function (gn) {
|
|
2053
|
+
var chip = document.createElement("span");
|
|
2054
|
+
chip.className = "cg-chip";
|
|
2055
|
+
var sw = document.createElement("i");
|
|
2056
|
+
sw.style.background = PALETTE[gnames.indexOf(gn) % PALETTE.length] || "";
|
|
2057
|
+
chip.appendChild(sw);
|
|
2058
|
+
var lbl = document.createElement("span");
|
|
2059
|
+
lbl.textContent = gn || "(root)";
|
|
2060
|
+
chip.appendChild(lbl);
|
|
2061
|
+
chips.appendChild(chip);
|
|
2062
|
+
});
|
|
2063
|
+
mount.appendChild(chips);
|
|
2064
|
+
}
|
|
2065
|
+
function pushView(nd) {
|
|
2066
|
+
state.trail.push({ data: data, roots: state.roots });
|
|
2067
|
+
setData(nd);
|
|
2068
|
+
state.roots = nd.roots.slice();
|
|
2069
|
+
draw();
|
|
2070
|
+
}
|
|
2071
|
+
// Caller direction (GEP-0003): a live mount rebuilds through its
|
|
2072
|
+
// document loader (mount._cgView, attached by the upgrade step); a
|
|
2073
|
+
// static CLI page reverses its in-slice edges — partial but honest,
|
|
2074
|
+
// and labelled as such in the crumb.
|
|
2075
|
+
// No recorded callers (an app/framework entry, or dead code): "up" from
|
|
2076
|
+
// a METHOD is its CONTAINER — one level, never the whole-repo overview
|
|
2077
|
+
// two levels up. From a focused/derived view that means the method's own
|
|
2078
|
+
// container page (the view its module node opens); already sitting on
|
|
2079
|
+
// that default view, say why and stay put — the "don't jump, say why"
|
|
2080
|
+
// contract.
|
|
2081
|
+
function noCallers(k) {
|
|
2082
|
+
var docRel = k.slice(0, k.lastIndexOf("#"));
|
|
2083
|
+
if (docRel !== data0.start) {
|
|
2084
|
+
openDoc(navBase + docRel);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (state.trail.length) {
|
|
2088
|
+
state.trail = [];
|
|
2089
|
+
setData(homeData());
|
|
2090
|
+
state.roots = data.roots.slice();
|
|
2091
|
+
draw();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
flash("no recorded callers — an app/framework entry point");
|
|
2095
|
+
}
|
|
2096
|
+
function showCallers(k) {
|
|
2097
|
+
var lv = live();
|
|
2098
|
+
if (lv) {
|
|
2099
|
+
Promise.resolve(lv({ dir: "up", node: k })).then(function (nd) {
|
|
2100
|
+
if (nd && Object.keys(nd.nodes).length > 1)
|
|
2101
|
+
pushView(nd);
|
|
2102
|
+
else
|
|
2103
|
+
noCallers(k);
|
|
2104
|
+
});
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
var rin = {};
|
|
2108
|
+
data0.edges.forEach(function (e) { (rin[e[1]] = rin[e[1]] || []).push(e[0]); });
|
|
2109
|
+
var keep = {};
|
|
2110
|
+
keep[k] = 1;
|
|
2111
|
+
var q = [k], qi = 0;
|
|
2112
|
+
while (qi < q.length) {
|
|
2113
|
+
var c = q[qi++];
|
|
2114
|
+
(rin[c] || []).forEach(function (p) { if (!keep[p]) {
|
|
2115
|
+
keep[p] = 1;
|
|
2116
|
+
q.push(p);
|
|
2117
|
+
} });
|
|
2118
|
+
}
|
|
2119
|
+
if (Object.keys(keep).length <= 1) {
|
|
2120
|
+
noCallers(k);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
var nodes = {}, edges = [];
|
|
2124
|
+
for (var nk in keep)
|
|
2125
|
+
nodes[nk] = data0.nodes[nk];
|
|
2126
|
+
data0.edges.forEach(function (e) { if (keep[e[0]] && keep[e[1]])
|
|
2127
|
+
edges.push([e[1], e[0], e[2], e[3]]); });
|
|
2128
|
+
pushView({ start: data0.start, depth: 99, roots: [k], nodes: nodes, edges: edges, dir: "up", focus: k, partial: 1 });
|
|
2129
|
+
}
|
|
2130
|
+
function showCallees(k) {
|
|
2131
|
+
var lv = live();
|
|
2132
|
+
if (lv) {
|
|
2133
|
+
Promise.resolve(lv({ dir: "down", node: k })).then(function (nd) { if (nd)
|
|
2134
|
+
pushView(nd); });
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
pushView({ start: data0.start, depth: data0.depth, roots: [k], nodes: data0.nodes, edges: data0.edges });
|
|
2138
|
+
}
|
|
2139
|
+
// A method node's `src` is a route (like a table's `src` / a chart's
|
|
2140
|
+
// `data`): "<path>#L<start>-<end>". Resolve it relative to navBase
|
|
2141
|
+
// (overridable via the mount's data-src-base), fetch the file, slice the
|
|
2142
|
+
// line range, and show it in the side panel — the graph stays live, so
|
|
2143
|
+
// clicking another node updates the panel. Unreachable (offline, a
|
|
2144
|
+
// static embed, or a server scoped away from the sources) DEGRADES to
|
|
2145
|
+
// the path, never throws.
|
|
2146
|
+
function showSource(k) {
|
|
2147
|
+
var n = data.nodes[k] || {};
|
|
2148
|
+
var ref = n.src ? String(n.src) : "";
|
|
2149
|
+
srcPanel.replaceChildren();
|
|
2150
|
+
srcPanel.style.display = "";
|
|
2151
|
+
var hd = document.createElement("div");
|
|
2152
|
+
hd.className = "cg-src-hd";
|
|
2153
|
+
var ttl = document.createElement("span");
|
|
2154
|
+
ttl.textContent = ref || (n.n || k);
|
|
2155
|
+
hd.appendChild(ttl);
|
|
2156
|
+
var cls = document.createElement("button");
|
|
2157
|
+
cls.textContent = "✕";
|
|
2158
|
+
cls.onclick = function () { srcPanel.style.display = "none"; srcPanel.replaceChildren(); };
|
|
2159
|
+
hd.appendChild(cls);
|
|
2160
|
+
srcPanel.appendChild(hd);
|
|
2161
|
+
var body = document.createElement("pre");
|
|
2162
|
+
body.className = "cg-src-body";
|
|
2163
|
+
srcPanel.appendChild(body);
|
|
2164
|
+
if (!ref) {
|
|
2165
|
+
body.textContent = "no source location recorded for this node";
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
var hp = ref.indexOf("#");
|
|
2169
|
+
var path = hp < 0 ? ref : ref.slice(0, hp);
|
|
2170
|
+
var rng = /L(\d+)(?:-L?(\d+))?/.exec(hp < 0 ? "" : ref.slice(hp + 1));
|
|
2171
|
+
var a0 = rng ? parseInt(rng[1], 10) : 0;
|
|
2172
|
+
var b0 = rng && rng[2] ? parseInt(rng[2], 10) : a0;
|
|
2173
|
+
body.textContent = "loading " + path + " …";
|
|
2174
|
+
var base = mount.getAttribute("data-src-base");
|
|
2175
|
+
if (base === null || base === undefined)
|
|
2176
|
+
base = navBase;
|
|
2177
|
+
var degrade = function () {
|
|
2178
|
+
body.textContent = "";
|
|
2179
|
+
var note = document.createElement("div");
|
|
2180
|
+
note.className = "cg-src-note";
|
|
2181
|
+
note.textContent = ref + "\nsource not reachable here";
|
|
2182
|
+
body.appendChild(note);
|
|
2183
|
+
};
|
|
2184
|
+
var render = function (text) {
|
|
2185
|
+
var lines = String(text).split(/\r?\n/);
|
|
2186
|
+
var out = (a0 >= 1 && a0 <= lines.length) ? lines.slice(a0 - 1, b0 >= a0 ? b0 : a0) : lines;
|
|
2187
|
+
body.textContent = out.join("\n");
|
|
2188
|
+
};
|
|
2189
|
+
var fetchFn = (typeof fetch === "function") ? fetch : null;
|
|
2190
|
+
if (!fetchFn) {
|
|
2191
|
+
degrade();
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
try {
|
|
2195
|
+
Promise.resolve(fetchFn(base + path)).then(function (r) {
|
|
2196
|
+
if (!r || r.ok === false) {
|
|
2197
|
+
degrade();
|
|
2198
|
+
return null;
|
|
2199
|
+
}
|
|
2200
|
+
return Promise.resolve(r.text ? r.text() : r).then(render);
|
|
2201
|
+
}).catch(degrade);
|
|
2202
|
+
}
|
|
2203
|
+
catch (e) {
|
|
2204
|
+
degrade();
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
svg.addEventListener("click", function (ev) {
|
|
2208
|
+
var tgt = ev.target;
|
|
2209
|
+
var ub = tgt && tgt.closest ? tgt.closest(".cg-upbtn") : null;
|
|
2210
|
+
if (ub) {
|
|
2211
|
+
if (ub.getAttribute("data-act") === "down") {
|
|
2212
|
+
// "Back to its callee chain" must LAND on the callee chain: pop
|
|
2213
|
+
// the trail only when the view underneath IS this method's own
|
|
2214
|
+
// focused view (the search/chain path that opened these callers).
|
|
2215
|
+
// Arriving from anywhere wider — the module page — popping would
|
|
2216
|
+
// land there instead, so build the method's chain fresh.
|
|
2217
|
+
var k0 = ub.getAttribute("data-k");
|
|
2218
|
+
var top0 = state.trail[state.trail.length - 1];
|
|
2219
|
+
var ownChain = top0 && top0.data && top0.data.dir !== "up" && top0.roots && top0.roots.length === 1 && top0.roots[0] === k0;
|
|
2220
|
+
if (ownChain) {
|
|
2221
|
+
var tr0 = state.trail.pop();
|
|
2222
|
+
setData(tr0.data);
|
|
2223
|
+
state.roots = tr0.roots;
|
|
2224
|
+
draw();
|
|
2225
|
+
}
|
|
2226
|
+
else
|
|
2227
|
+
showCallees(k0);
|
|
2228
|
+
}
|
|
2229
|
+
else
|
|
2230
|
+
showCallers(ub.getAttribute("data-k"));
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
var g = tgt && tgt.closest ? tgt.closest(".cg-n") : null;
|
|
2234
|
+
if (!g)
|
|
2235
|
+
return;
|
|
2236
|
+
var k = g.getAttribute("data-k");
|
|
2237
|
+
if (data.mode === "modules") {
|
|
2238
|
+
var nd = data.nodes[k];
|
|
2239
|
+
if (nd && nd.grp) {
|
|
2240
|
+
pushView(deriveView(nd.grp));
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
if (nd && nd.ext)
|
|
2244
|
+
return; // external stub: informational
|
|
2245
|
+
if (nd && nd.doc)
|
|
2246
|
+
openDoc(navBase + String(nd.doc));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
// Method mode: the node body now VIEWS the method's source. All chain
|
|
2250
|
+
// navigation (callers / flip back) lives on the standalone ⊕ node.
|
|
2251
|
+
showSource(k);
|
|
2252
|
+
});
|
|
2253
|
+
// Hover highlight: BFS the caller cone over upAdj, mark nodes/edges
|
|
2254
|
+
// with .hl and flag the svg so everything else dims. Class strings are
|
|
2255
|
+
// rebuilt from the recorded bases — no classList dependency.
|
|
2256
|
+
function clearHl() {
|
|
2257
|
+
svg.setAttribute("class", "cg-svg");
|
|
2258
|
+
for (var nk in nodeEls)
|
|
2259
|
+
nodeEls[nk].setAttribute("class", nodeBase[nk]);
|
|
2260
|
+
for (var ekk in edgeEls)
|
|
2261
|
+
edgeEls[ekk].setAttribute("class", edgeBase[ekk]);
|
|
2262
|
+
}
|
|
2263
|
+
svg.addEventListener("mouseover", function (ev) {
|
|
2264
|
+
var tgt = ev.target;
|
|
2265
|
+
var g = tgt && tgt.closest ? tgt.closest(".cg-n") : null;
|
|
2266
|
+
if (!g)
|
|
2267
|
+
return;
|
|
2268
|
+
var k = g.getAttribute("data-k");
|
|
2269
|
+
var seen = {};
|
|
2270
|
+
seen[k] = 1;
|
|
2271
|
+
var hlE = {};
|
|
2272
|
+
var q = [k], qi = 0;
|
|
2273
|
+
while (qi < q.length) {
|
|
2274
|
+
var cur = q[qi++];
|
|
2275
|
+
(upAdj[cur] || []).forEach(function (p) {
|
|
2276
|
+
hlE[p.k] = 1;
|
|
2277
|
+
if (!seen[p.n]) {
|
|
2278
|
+
seen[p.n] = 1;
|
|
2279
|
+
q.push(p.n);
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
svg.setAttribute("class", "cg-svg hl");
|
|
2284
|
+
for (var nk in nodeEls)
|
|
2285
|
+
nodeEls[nk].setAttribute("class", nodeBase[nk] + (seen[nk] ? " hl" : ""));
|
|
2286
|
+
for (var ekk in edgeEls)
|
|
2287
|
+
edgeEls[ekk].setAttribute("class", edgeBase[ekk] + (hlE[ekk] ? " hl" : ""));
|
|
2288
|
+
});
|
|
2289
|
+
svg.addEventListener("mouseout", function (ev) {
|
|
2290
|
+
var tgt = ev.target;
|
|
2291
|
+
if (tgt && tgt.closest && !tgt.closest(".cg-n"))
|
|
2292
|
+
return;
|
|
2293
|
+
clearHl();
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
draw();
|
|
2297
|
+
}
|
|
2298
|
+
Array.prototype.forEach.call(root.querySelectorAll(".cg-mount"), function (mount) {
|
|
2299
|
+
var payload = mount.getAttribute("data-graph");
|
|
2300
|
+
if (payload) {
|
|
2301
|
+
boot(mount, JSON.parse(payload));
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
var side = mount.getAttribute("data-graph-src");
|
|
2305
|
+
if (!side)
|
|
2306
|
+
return; // not (yet) upgraded, or its build failed
|
|
2307
|
+
// Sidecar payload (served pages): the page shipped without the multi-MB
|
|
2308
|
+
// inline attribute — fetch it after first paint, then boot normally.
|
|
2309
|
+
fetch(side).then(function (r) { return r.json(); }).then(function (j) {
|
|
2310
|
+
if (!j || j.error !== undefined) {
|
|
2311
|
+
mount.textContent = "geml-code-graph: " + ((j && j.error) || "cannot load graph data");
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
if (j.truncated && mount.parentNode) {
|
|
2315
|
+
var note = document.createElement("p");
|
|
2316
|
+
note.className = "cg-note";
|
|
2317
|
+
note.textContent = "slice truncated — narrow the entry set or lower graph-depth";
|
|
2318
|
+
mount.parentNode.insertBefore(note, mount.nextSibling);
|
|
2319
|
+
}
|
|
2320
|
+
boot(mount, j.data);
|
|
2321
|
+
}).catch(function () { mount.textContent = "geml-code-graph: cannot load graph data"; });
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
// CLI inlining: the compiled runtime function, verbatim, run against document.
|
|
2325
|
+
export const CODE_GRAPH_JS = `(${codeGraphRuntime.toString()})(document);`;
|
|
2326
|
+
// Browser-side wave builder: the slice builder is synchronous with a
|
|
2327
|
+
// synchronous loader, but a browser fetches documents asynchronously — so
|
|
2328
|
+
// run the build in WAVES: every pass records the documents it needed but did
|
|
2329
|
+
// not have, those are fetched, and the build re-runs (builds are
|
|
2330
|
+
// milliseconds; the wave count is bounded by graph-depth). ONE
|
|
2331
|
+
// implementation, two consumers: the viewer's upgrade step and the live
|
|
2332
|
+
// module script injected into served pages.
|
|
2333
|
+
export function codeGraphWaves(fetchDoc, parseFn) {
|
|
2334
|
+
const cache = new Map();
|
|
2335
|
+
const failed = new Set();
|
|
2336
|
+
return {
|
|
2337
|
+
seed: (name, text) => { cache.set(name, text); },
|
|
2338
|
+
build: async (src, view) => {
|
|
2339
|
+
let result;
|
|
2340
|
+
for (;;) {
|
|
2341
|
+
const pending = [];
|
|
2342
|
+
result = buildCodeGraph(src, {
|
|
2343
|
+
loadDoc: (p) => {
|
|
2344
|
+
if (cache.has(p))
|
|
2345
|
+
return cache.get(p);
|
|
2346
|
+
if (!failed.has(p))
|
|
2347
|
+
pending.push(p);
|
|
2348
|
+
return null;
|
|
2349
|
+
},
|
|
2350
|
+
parseDoc: parseFn,
|
|
2351
|
+
}, view);
|
|
2352
|
+
if (!pending.length)
|
|
2353
|
+
break;
|
|
2354
|
+
await Promise.all(pending.map(async (p) => {
|
|
2355
|
+
try {
|
|
2356
|
+
const text = await fetchDoc(p);
|
|
2357
|
+
cache.set(p, text);
|
|
2358
|
+
if (text === null)
|
|
2359
|
+
failed.add(p);
|
|
2360
|
+
}
|
|
2361
|
+
catch {
|
|
2362
|
+
cache.set(p, null);
|
|
2363
|
+
failed.add(p);
|
|
2364
|
+
}
|
|
2365
|
+
}));
|
|
2366
|
+
}
|
|
2367
|
+
return result;
|
|
2368
|
+
},
|
|
2369
|
+
};
|
|
499
2370
|
}
|
|
500
2371
|
// ---------------------------------------------------------------------------
|
|
501
2372
|
// Public entry
|
|
502
2373
|
// ---------------------------------------------------------------------------
|
|
503
|
-
export
|
|
504
|
-
const ctx = new RenderCtx(doc);
|
|
505
|
-
const body = doc.children.map((b) => ctx.block(b)).filter((s) => s !== "").join("\n");
|
|
506
|
-
const title = opts.title ?? ctx.docTitle() ?? "GEML document";
|
|
507
|
-
return page(title, body, ctx, opts.source);
|
|
508
|
-
}
|
|
2374
|
+
export { buildCodeGraph };
|