@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/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
20
  }
21
- function escAttr(s) {
21
+ export function escAttr(s) {
22
22
  return esc(s).replace(/"/g, "&quot;");
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
- case "aside": {
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 = t.rows.map((r) => r.map(() => false));
198
- t.rows.forEach((row, r) => row.forEach((cell, c) => {
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
- for (let dr = 0; dr < cell.span.rows; dr++)
202
- for (let dc = 0; dc < cell.span.cols; dc++) {
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 = t.rows.map((row, r) => {
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; } li.task input { margin-right:.5em; }
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
- function page(title, body, ctx, source) {
471
- const mathHead = ctx.usedMath
472
- ? `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">\n` +
473
- `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>\n` +
474
- `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}]})"></script>\n`
475
- : "";
476
- const mermaidHead = ctx.usedMermaid
477
- ? `<script type="module">import m from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";m.initialize({startOnLoad:true});</script>\n`
478
- : "";
479
- const footer = source
480
- ? `<footer class="geml-footer">Rendered from <code>${esc(source)}</code> by the GEML runtime. Tables are sortable and filterable; the chart is inline SVG drawn from its bound table.</footer>`
481
- : "";
482
- return `<!doctype html>
483
- <html lang="en">
484
- <head>
485
- <meta charset="utf-8">
486
- <meta name="viewport" content="width=device-width, initial-scale=1">
487
- <title>${esc(title)}</title>
488
- <style>${CSS}</style>
489
- ${mathHead}${mermaidHead}</head>
490
- <body>
491
- <main>
492
- ${body}
493
- </main>
494
- ${footer}
495
- <script>${JS}</script>
496
- </body>
497
- </html>
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 function renderHtml(doc, opts = {}) {
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 };