@geml/geml 1.0.0 → 1.1.1

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
@@ -26,13 +26,28 @@ function escAttr(s) {
26
26
  // ---------------------------------------------------------------------------
27
27
  class RenderCtx {
28
28
  doc;
29
+ opts;
29
30
  usedMath = false;
30
31
  usedMermaid = false;
32
+ usedCodeGraph = false;
31
33
  labels = new Map(); // id -> link label for [[#id]] auto-refs
32
- constructor(doc) {
34
+ constructor(doc, opts = {}) {
33
35
  this.doc = doc;
36
+ this.opts = opts;
34
37
  this.indexLabels(doc.children);
35
38
  }
39
+ // Codemap documents (meta declares module= / container=) are machine data:
40
+ // their oversized tables fold shut by default. Everywhere else a big table
41
+ // is still the document's CONTENT — it truncates for the DOM's sake but
42
+ // stays visible.
43
+ get isCodemapDoc() {
44
+ for (const b of this.doc.children) {
45
+ if (b.kind === "block" && b.type === "meta" && b.data) {
46
+ return b.data["module"] !== undefined || b.data["container"] !== undefined;
47
+ }
48
+ }
49
+ return false;
50
+ }
36
51
  // Build the id -> label map: a heading's text, or a block's caption, or its id.
37
52
  indexLabels(blocks) {
38
53
  for (const b of blocks) {
@@ -156,8 +171,7 @@ class RenderCtx {
156
171
  case "math":
157
172
  this.usedMath = true;
158
173
  return `<div class="math-block"${idAttr}>\\[${esc(raw)}\\]</div>`;
159
- case "note":
160
- case "aside": {
174
+ case "note": {
161
175
  const classes = ["callout", b.type, ...b.classes].join(" ");
162
176
  const inner = (b.children ?? []).map((c) => this.block(c)).filter((s) => s).join("\n");
163
177
  return `<aside class="${classes}"${idAttr}>\n${inner}\n</aside>`;
@@ -182,6 +196,10 @@ class RenderCtx {
182
196
  return `<figure class="chart"${idAttr}>${chartSvg(b.chart, caption)}${cap}</figure>`;
183
197
  return `<figure${idAttr}><p class="render-error">chart could not be built (see diagnostics)</p>${cap}</figure>`;
184
198
  }
199
+ if (fmt === "geml-code-graph") {
200
+ const src = typeof b.attrs["src"] === "string" ? b.attrs["src"] : "";
201
+ return this.codeGraphFigure(src, idAttr, cap);
202
+ }
185
203
  if (fmt === "mermaid") {
186
204
  this.usedMermaid = true;
187
205
  return `<figure${idAttr}><pre class="mermaid">${esc(raw)}</pre>${cap}</figure>`;
@@ -190,12 +208,47 @@ class RenderCtx {
190
208
  return `<figure${idAttr}><pre class="diagram-src" data-format="${escAttr(fmt)}">${esc(raw)}</pre>` +
191
209
  `<figcaption>${caption ? esc(caption) + " — " : ""}<code>${esc(fmt || "diagram")}</code> (no bundled renderer in this build)</figcaption></figure>`;
192
210
  }
211
+ // geml-code-graph embed (GEP-0003): build the call-graph slice from the
212
+ // codemap document `src` points at (roots/depth from ITS meta), embed the
213
+ // data, and let the in-page runtime lay it out at draw time — that is what
214
+ // makes click-to-re-root possible.
215
+ codeGraphFigure(src, idAttr, cap) {
216
+ if (!src) {
217
+ return `<figure class="code-graph"${idAttr}><p class="render-error">geml-code-graph: missing <code>src=</code></p>${cap}</figure>`;
218
+ }
219
+ if (this.opts.graphSidecar) {
220
+ // Sidecar mode (served pages): don't build the slice here at all — the
221
+ // page ships without the payload and the runtime fetches it from the
222
+ // sidecar route after first paint. Errors surface in the mount then.
223
+ this.usedCodeGraph = true;
224
+ return `<figure class="code-graph"${idAttr}><div class="cg-mount" data-start="${escAttr(src)}"` +
225
+ ` data-graph-src="${escAttr(this.opts.graphSidecar + encodeURIComponent(src))}"></div>${cap}</figure>`;
226
+ }
227
+ const r = buildCodeGraph(src, this.opts);
228
+ if (r.error !== undefined) {
229
+ return `<figure class="code-graph"${idAttr}><p class="render-error">geml-code-graph: ${esc(r.error)}</p>${cap}</figure>`;
230
+ }
231
+ this.usedCodeGraph = true;
232
+ 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>` : "";
233
+ // data-start carries the slice's own document path so a live module
234
+ // script (opts.liveGraph) can hook the mount without re-parsing the
235
+ // multi-MB payload attribute.
236
+ 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>`;
237
+ }
193
238
  table(t, id, caption) {
194
239
  const idAttr = id ? ` id="${escAttr(id)}"` : "";
195
240
  const alignStyle = (a) => (a ? ` style="text-align:${a}"` : "");
241
+ // Parsing + laying out tens of thousands of <table> rows freezes the
242
+ // page for seconds, so the HTML view renders a bounded preview (the
243
+ // model keeps every row: charts, computed summaries and the code-graph
244
+ // never read the HTML). Codemap edge tables additionally fold shut —
245
+ // they are machine data; elsewhere the table is content and stays open.
246
+ const maxRows = this.opts.tableRows ?? 500;
247
+ const allRows = t.rows;
248
+ const rows = allRows.length > maxRows ? allRows.slice(0, maxRows) : allRows;
196
249
  // 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) => {
250
+ const covered = rows.map((r) => r.map(() => false));
251
+ rows.forEach((row, r) => row.forEach((cell, c) => {
199
252
  if (!cell.span)
200
253
  return;
201
254
  for (let dr = 0; dr < cell.span.rows; dr++)
@@ -210,7 +263,7 @@ class RenderCtx {
210
263
  const thead = t.header
211
264
  ? `<thead><tr>${t.columns.map((col, c) => `<th${alignStyle(t.align[c])}>${esc(col)}</th>`).join("")}</tr></thead>`
212
265
  : "";
213
- const bodyRows = t.rows.map((row, r) => {
266
+ const bodyRows = rows.map((row, r) => {
214
267
  const cells = row.map((cell, c) => {
215
268
  if (covered[r]?.[c])
216
269
  return "";
@@ -229,6 +282,16 @@ class RenderCtx {
229
282
  : "";
230
283
  const cap = caption ? `<figcaption>${esc(caption)}</figcaption>` : "";
231
284
  const tools = `<div class="table-tools"><input class="table-filter" type="search" placeholder="Filter rows…" aria-label="Filter table rows"></div>`;
285
+ if (allRows.length > maxRows) {
286
+ const note = `<p class="table-note">showing the first ${maxRows} of ${allRows.length} rows — the complete table is in the document source</p>`;
287
+ if (this.isCodemapDoc) {
288
+ const summary = `${esc(id ? "#" + id : "table")} · ${allRows.length} rows (preview: first ${maxRows})`;
289
+ return `<figure class="table-figure"${idAttr}><details><summary>${summary}</summary>${tools}` +
290
+ `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}</details>${cap}</figure>`;
291
+ }
292
+ return `<figure class="table-figure"${idAttr}>${tools}` +
293
+ `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}${cap}</figure>`;
294
+ }
232
295
  return `<figure class="table-figure"${idAttr}>${tools}` +
233
296
  `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${cap}</figure>`;
234
297
  }
@@ -244,6 +307,392 @@ function niceMax(v) {
244
307
  const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
245
308
  return nice * pow;
246
309
  }
310
+ // ---------------------------------------------------------------------------
311
+ // geml-code-graph (GEP-0003) — slice builder. Traverses the codemap profile's
312
+ // #calls tables from the target document's meta `entry`, across documents,
313
+ // depth-limited; the layered LAYOUT happens in the page runtime (draw time).
314
+ // ---------------------------------------------------------------------------
315
+ // Hard payload ceiling only — the VIEW paces itself: the runtime draws the
316
+ // first 400 by BFS order and offers "+400"/"all" to walk deeper. The data in
317
+ // the codemap documents is always complete regardless.
318
+ const CG_MAX_NODES = 4000;
319
+ // Tiny posix-path helpers (no node:path dependency in the renderer).
320
+ function cgDir(p) { const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); }
321
+ function cgJoin(dir, rel) {
322
+ const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
323
+ const out = [];
324
+ for (const seg of parts) {
325
+ if (seg === "" || seg === ".")
326
+ continue;
327
+ if (seg === "..")
328
+ out.pop();
329
+ else
330
+ out.push(seg);
331
+ }
332
+ return out.join("/");
333
+ }
334
+ function buildCodeGraph(startRel, opts, view) {
335
+ if (!opts.loadDoc || !opts.parseDoc)
336
+ return { error: "no document loader in this build (render via the geml CLI)" };
337
+ const cache = new Map();
338
+ const loadParsed = (rel) => {
339
+ if (!cache.has(rel)) {
340
+ const s = opts.loadDoc(rel);
341
+ cache.set(rel, s === null ? null : opts.parseDoc(s));
342
+ }
343
+ return cache.get(rel);
344
+ };
345
+ const metaOf = (d) => {
346
+ for (const b of d.children)
347
+ if (b.kind === "block" && b.type === "meta" && b.data)
348
+ return b.data;
349
+ return {};
350
+ };
351
+ const start = cgJoin("", startRel);
352
+ const doc0 = loadParsed(start);
353
+ if (!doc0)
354
+ return { error: `cannot load \`${startRel}\`` };
355
+ const meta0 = metaOf(doc0);
356
+ const entries = String(meta0["entry"] ?? "").split(/\s+/).filter(Boolean);
357
+ // A codemap INDEX (meta declares container=) renders the MODULE-level
358
+ // aggregation. The payload carries the RAW module rows and module edges;
359
+ // the runtime derives every view of the grouping tree from them (one tree
360
+ // node's children per view, single-child chains tunnelled) — so drilling
361
+ // through packages costs no refetch and old data needs no rebuild.
362
+ if (meta0["container"] !== undefined && !(view && view.node)) {
363
+ const findTable = (d, id) => {
364
+ for (const b of d.children)
365
+ if (b.kind === "block" && b.type === "table" && b.id === id && b.table)
366
+ return b.table;
367
+ return undefined;
368
+ };
369
+ const mods = findTable(doc0, "modules");
370
+ if (mods) {
371
+ const mi = mods.columns.indexOf("module"), di = mods.columns.indexOf("doc");
372
+ const mc = mods.columns.indexOf("methods");
373
+ if (mi < 0 || di < 0)
374
+ return { error: "#modules table lacks module/doc columns" };
375
+ const list = [];
376
+ for (const r of mods.rows) {
377
+ const name = r[mi]?.text ?? "", doc = r[di]?.text ?? "";
378
+ if (name && doc)
379
+ list.push({ p: name, doc, m: mc >= 0 ? Number(r[mc]?.text ?? "") || 0 : 0 });
380
+ }
381
+ const em = [];
382
+ const medges = findTable(doc0, "module-edges");
383
+ if (medges) {
384
+ const fi = medges.columns.indexOf("from"), ti = medges.columns.indexOf("to"), ci = medges.columns.indexOf("calls");
385
+ for (const r of medges.rows) {
386
+ const f = r[fi]?.text ?? "", t = r[ti]?.text ?? "";
387
+ if (f && t)
388
+ em.push([f, t, ci >= 0 ? Number(r[ci]?.text ?? "") || 1 : 1]);
389
+ }
390
+ }
391
+ // Containers holding app entries — every derived view marks the child
392
+ // that contains one of these as a root.
393
+ const entryDocs = [];
394
+ for (const e of entries) {
395
+ const h = e.indexOf("#");
396
+ if (h > 0) {
397
+ const d = cgJoin(cgDir(start), e.slice(0, h));
398
+ if (!entryDocs.includes(d))
399
+ entryDocs.push(d);
400
+ }
401
+ }
402
+ return { data: { start, depth: 99, roots: [], nodes: {}, edges: [], mode: "modules", mods: list, medges: em, entryDocs } };
403
+ }
404
+ }
405
+ if (!(view && view.node)) {
406
+ // A container view roots at its meta `entry` PLUS its in-degree-zero
407
+ // methods. `entry` = called from OUTSIDE the container; in-degree-zero =
408
+ // NO static caller at all — a JVM/agent entry point (`premain`), an AOP
409
+ // advice the instrumentation invokes, a reflective handler, or dead code.
410
+ // Such framework hooks have no in-repo caller, so seeding only from
411
+ // `entry` drops them (and everything they reach) from their OWN
412
+ // container's view. Union keeps a container's methods visible in it —
413
+ // symmetric with the module overview's entry ∪ in-degree-zero roots.
414
+ const ids = [];
415
+ const anchorOf = {};
416
+ const leaf = new Set();
417
+ const called = new Set();
418
+ for (const b of doc0.children) {
419
+ if (b.kind !== "block")
420
+ continue;
421
+ if (b.type === "code" && b.id) {
422
+ ids.push(b.id);
423
+ if (typeof b.attrs["anchor"] === "string")
424
+ anchorOf[b.id] = b.attrs["anchor"];
425
+ if (b.classes.includes("leaf"))
426
+ leaf.add(b.id);
427
+ }
428
+ if (b.type === "table" && b.table && (b.id === "calls" || b.id === "called-by")) {
429
+ const ti = b.table.columns.indexOf("to");
430
+ if (ti >= 0)
431
+ for (const r of b.table.rows) {
432
+ const t = r[ti]?.text ?? "";
433
+ if (t.startsWith("#"))
434
+ called.add(t.slice(1));
435
+ }
436
+ }
437
+ }
438
+ const have = new Set(entries.map((e) => e.replace(/^#/, "")));
439
+ // Synthetic methods — constructors (`<init>`/`<clinit>`), lambdas
440
+ // (`<lambda>`), and anonymous-class / unresolved-signature methods — are
441
+ // implementation artifacts, never entry points. Their in-degree is zero
442
+ // only because no static edge names them (fluent-API / reflective / lambda
443
+ // callers go unresolved), so seeding roots from them floods the view. Keep
444
+ // them out of the in-degree-zero roots; they still appear when a real root
445
+ // reaches them.
446
+ const synthetic = (id) => /<(?:init|clinit|lambda)>|<unresolvedSignature>/.test(anchorOf[id] || "");
447
+ // `.leaf` = zero out-edges: an in-degree-zero leaf is an ISOLATED node (no
448
+ // caller, nothing to expand) — a bean getter/setter, a constant, dead code.
449
+ // As a root it is pure clutter, so it never seeds one; it still appears if a
450
+ // real root reaches it. (An in-degree-zero method WITH out-edges — premain,
451
+ // an AOP advice — is a genuine entry and does seed a root.)
452
+ for (const id of ids)
453
+ if (!called.has(id) && !have.has(id) && !synthetic(id) && !leaf.has(id))
454
+ entries.push(`#${id}`);
455
+ }
456
+ if (!(view && view.node) && !entries.length)
457
+ return { error: `\`${startRel}\` declares no \`entry\` in its meta` };
458
+ const depth = Number(meta0["graph-depth"]) > 0 ? Number(meta0["graph-depth"]) : 6;
459
+ const resolveRef = (fromDoc, ref) => {
460
+ const h = ref.indexOf("#");
461
+ if (h < 0)
462
+ return null;
463
+ const id = ref.slice(h + 1);
464
+ return { doc: h === 0 ? fromDoc : cgJoin(cgDir(fromDoc), ref.slice(0, h)), id };
465
+ };
466
+ const nodes = {};
467
+ const edges = [];
468
+ const roots = [];
469
+ let truncated = false;
470
+ // Per-document indexes, built once on first touch. The BFS re-enters the
471
+ // same documents for every node it expands — a linear scan of a 30k-row
472
+ // #calls table per node turns the whole walk quadratic (seconds per page
473
+ // on a large codemap).
474
+ const blockIdxOf = (() => {
475
+ const cache = new Map();
476
+ return (docRel) => {
477
+ let idx = cache.get(docRel);
478
+ if (idx)
479
+ return idx;
480
+ idx = new Map();
481
+ const d = loadParsed(docRel);
482
+ if (d)
483
+ for (const b of d.children) {
484
+ if (b.kind !== "block" || !b.id || idx.has(b.id))
485
+ continue;
486
+ // Label with the real display name when the block carries one — the
487
+ // id is the sanitised form ("RenderCtx-block" for "RenderCtx.block").
488
+ const node = { n: typeof b.attrs["name"] === "string" ? b.attrs["name"] : b.id, doc: docRel };
489
+ if (typeof b.attrs["src"] === "string")
490
+ node.src = b.attrs["src"];
491
+ if (b.classes.includes("leaf"))
492
+ node.leaf = true;
493
+ if (b.classes.includes("test"))
494
+ node.test = true;
495
+ if (b.classes.includes("accessor"))
496
+ node.acc = true;
497
+ idx.set(b.id, node);
498
+ }
499
+ cache.set(docRel, idx);
500
+ return idx;
501
+ };
502
+ })();
503
+ const blockInfo = (docRel, id) => blockIdxOf(docRel).get(id) ?? { n: id, doc: docRel };
504
+ const callIdxOf = (() => {
505
+ const cache = new Map();
506
+ return (docRel) => {
507
+ let idx = cache.get(docRel);
508
+ if (idx)
509
+ return idx;
510
+ idx = new Map();
511
+ const d = loadParsed(docRel);
512
+ if (d)
513
+ for (const b of d.children) {
514
+ if (b.kind === "block" && b.type === "table" && b.id === "calls" && b.table) {
515
+ const cols = b.table.columns;
516
+ const fi = cols.indexOf("from"), ti = cols.indexOf("to"), ki = cols.indexOf("kind"), ci = cols.indexOf("confidence");
517
+ if (fi < 0 || ti < 0)
518
+ break;
519
+ for (const r of b.table.rows) {
520
+ const from = r[fi]?.text ?? "";
521
+ if (!from.startsWith("#"))
522
+ continue;
523
+ let list = idx.get(from.slice(1));
524
+ if (!list) {
525
+ list = [];
526
+ idx.set(from.slice(1), list);
527
+ }
528
+ list.push({ to: r[ti]?.text ?? "", kind: r[ki]?.text || "call", conf: ci >= 0 ? (r[ci]?.text ?? "") : "" });
529
+ }
530
+ break;
531
+ }
532
+ }
533
+ cache.set(docRel, idx);
534
+ return idx;
535
+ };
536
+ })();
537
+ const callRows = (docRel, id) => callIdxOf(docRel).get(id) ?? [];
538
+ // A caller-direction view (the runtime's ⊕ handle through a live loader):
539
+ // BFS over #called-by tables from one node. Edges are emitted REVERSED
540
+ // (callee -> caller), so roots=[focus] lets the standard layering flow from
541
+ // the method out to its ultimate callers — cycles fall out as back edges.
542
+ if (view && view.node && view.dir === "up") {
543
+ const hi = view.node.lastIndexOf("#");
544
+ if (hi <= 0)
545
+ return { error: `bad view node \`${view.node}\`` };
546
+ // Same once-per-document indexing as callRows — the upward BFS crosses
547
+ // documents through their #called-by tables just as hot.
548
+ const calledByIdxOf = (() => {
549
+ const cache = new Map();
550
+ return (docRel) => {
551
+ let idx = cache.get(docRel);
552
+ if (idx)
553
+ return idx;
554
+ idx = new Map();
555
+ const d = loadParsed(docRel);
556
+ if (d)
557
+ for (const b of d.children) {
558
+ if (b.kind === "block" && b.type === "table" && b.id === "called-by" && b.table) {
559
+ const cols = b.table.columns;
560
+ const fi = cols.indexOf("from"), ti = cols.indexOf("to"), ki = cols.indexOf("kind");
561
+ if (fi < 0 || ti < 0)
562
+ break;
563
+ for (const r of b.table.rows) {
564
+ const to = r[ti]?.text ?? "";
565
+ if (!to.startsWith("#"))
566
+ continue;
567
+ let list = idx.get(to.slice(1));
568
+ if (!list) {
569
+ list = [];
570
+ idx.set(to.slice(1), list);
571
+ }
572
+ list.push({ from: r[fi]?.text ?? "", kind: r[ki]?.text || "call" });
573
+ }
574
+ break;
575
+ }
576
+ }
577
+ cache.set(docRel, idx);
578
+ return idx;
579
+ };
580
+ })();
581
+ const calledByRows = (docRel, id) => calledByIdxOf(docRel).get(id) ?? [];
582
+ const focus = view.node;
583
+ nodes[focus] = blockInfo(focus.slice(0, hi), focus.slice(hi + 1));
584
+ roots.push(focus);
585
+ let fr = [{ doc: focus.slice(0, hi), id: focus.slice(hi + 1) }];
586
+ const seenUp = new Set([focus]);
587
+ // The caller chain is not depth-limited: its whole point is reaching the
588
+ // app entry. The node cap (with its visible note) is the only guard.
589
+ const upDepth = 99;
590
+ for (let d = 0; d < upDepth && fr.length; d++) {
591
+ const next = [];
592
+ for (const cur of fr) {
593
+ const toKey = `${cur.doc}#${cur.id}`;
594
+ for (const row of calledByRows(cur.doc, cur.id)) {
595
+ const c = resolveRef(cur.doc, row.from);
596
+ if (!c)
597
+ continue;
598
+ const callerKey = `${c.doc}#${c.id}`;
599
+ if (!nodes[callerKey]) {
600
+ if (Object.keys(nodes).length >= CG_MAX_NODES) {
601
+ truncated = true;
602
+ continue;
603
+ }
604
+ nodes[callerKey] = blockInfo(c.doc, c.id);
605
+ }
606
+ edges.push([toKey, callerKey, row.kind, ""]);
607
+ if (!seenUp.has(callerKey)) {
608
+ seenUp.add(callerKey);
609
+ next.push(c);
610
+ }
611
+ }
612
+ }
613
+ fr = next;
614
+ }
615
+ return { data: { start, depth: upDepth, roots, nodes, edges, module: String(meta0["module"] ?? "") || undefined, dir: "up", focus }, truncated };
616
+ }
617
+ // BFS from the target document's entries, depth-limited (+1 ring of stubs so
618
+ // the horizon is visible as "more" markers rather than silently missing).
619
+ // A directed callee view (node-body click through a live loader) seeds from
620
+ // that one node key instead of the meta entries.
621
+ let frontier = [];
622
+ if (view && view.node) {
623
+ const hi = view.node.lastIndexOf("#");
624
+ if (hi <= 0)
625
+ return { error: `bad view node \`${view.node}\`` };
626
+ roots.push(view.node);
627
+ frontier.push({ doc: view.node.slice(0, hi), id: view.node.slice(hi + 1) });
628
+ }
629
+ else {
630
+ const seeds = entries.map((e) => resolveRef(start, e)).filter(Boolean);
631
+ // A `.leaf` root has no callees to expand, so it renders as an ISOLATED dot
632
+ // — a getter/setter/constant called from another container, or dead code.
633
+ // Seed roots only from non-leaf entries so the view is call chains, not a
634
+ // field of dots; a leaf still appears when a real chain reaches it. Fall
635
+ // back to all seeds if EVERY entry is a leaf (a pure data container — a DTO
636
+ // of getters — must not come out blank).
637
+ const nonLeaf = seeds.filter((r) => !blockInfo(r.doc, r.id).leaf);
638
+ for (const r of (nonLeaf.length ? nonLeaf : seeds)) {
639
+ roots.push(`${r.doc}#${r.id}`);
640
+ frontier.push(r);
641
+ }
642
+ }
643
+ const seen = new Set(roots);
644
+ for (const r of frontier)
645
+ nodes[`${r.doc}#${r.id}`] = blockInfo(r.doc, r.id);
646
+ for (let d = 0; d < depth && frontier.length; d++) {
647
+ const next = [];
648
+ for (const cur of frontier) {
649
+ const fromKey = `${cur.doc}#${cur.id}`;
650
+ for (const row of callRows(cur.doc, cur.id)) {
651
+ const t = resolveRef(cur.doc, row.to);
652
+ if (!t)
653
+ continue;
654
+ const toKey = `${t.doc}#${t.id}`;
655
+ if (!nodes[toKey]) {
656
+ if (Object.keys(nodes).length >= CG_MAX_NODES) {
657
+ truncated = true;
658
+ continue;
659
+ }
660
+ nodes[toKey] = blockInfo(t.doc, t.id);
661
+ }
662
+ edges.push([fromKey, toKey, row.kind, row.conf]);
663
+ if (!seen.has(toKey)) {
664
+ seen.add(toKey);
665
+ next.push(t);
666
+ }
667
+ }
668
+ }
669
+ frontier = next;
670
+ }
671
+ // Horizon markers: anything still in the frontier that has further callees.
672
+ for (const cur of frontier) {
673
+ if (callRows(cur.doc, cur.id).length > 0)
674
+ nodes[`${cur.doc}#${cur.id}`].more = true;
675
+ }
676
+ // Drop ISOLATED nodes: a seeded root that ends up with no edge at all (no
677
+ // resolved callee to expand, no in-view caller) is a lone dot — a getter/
678
+ // setter/constant called only from elsewhere, or a method whose only calls
679
+ // were unresolved. They are clutter in a flow view. Keep them only if the
680
+ // WHOLE view is isolated dots (a pure data container mustn't come out blank).
681
+ const touched = new Set();
682
+ for (const e of edges) {
683
+ touched.add(e[0]);
684
+ touched.add(e[1]);
685
+ }
686
+ const connected = roots.filter((r) => touched.has(r));
687
+ let finalRoots = roots;
688
+ if (connected.length) {
689
+ for (const r of roots)
690
+ if (!touched.has(r))
691
+ delete nodes[r];
692
+ finalRoots = connected;
693
+ }
694
+ return { data: { start, depth, roots: finalRoots, nodes, edges, module: String(meta0["module"] ?? "") || undefined }, truncated };
695
+ }
247
696
  function chartSvg(m, title) {
248
697
  if (m.type === "pie")
249
698
  return pieSvg(m, title);
@@ -403,7 +852,10 @@ pre code { background:none; padding:0; font-size:.85em; }
403
852
  pre.output { background:#0d1117; color:#e6edf3; }
404
853
  pre.output code { color:inherit; }
405
854
  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; }
855
+ ul.task-list { list-style:none; padding-left:.2em; }
856
+ 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; }
857
+ li.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }
858
+ 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
859
  aside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }
408
860
  aside.aside { border-left-color:#8b949e; background:#f6f8fa; }
409
861
  aside.warning { border-left-color:#d97706; background:#fff8f0; }
@@ -420,6 +872,8 @@ table.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }
420
872
  table.geml-table td.computed { color:#0a7c52; }
421
873
  table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }
422
874
  .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; }
875
+ .table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }
876
+ .table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }
423
877
  .geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }
424
878
  .c-title { font-size:15px; font-weight:600; fill:var(--fg); }
425
879
  .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,6 +883,49 @@ table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-t
429
883
  sup.fn a { font-size:.75em; }
430
884
  .geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }
431
885
  .geml-footer code { font-size:.95em; }
886
+ .code-graph { margin:1.4em 0; }
887
+ .cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
888
+ .cg-scroll { overflow:auto; max-height:72vh; }
889
+ .cg-svg { display:block; }
890
+ .cg-stage { display:flex; gap:10px; align-items:flex-start; }
891
+ .cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }
892
+ .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); }
893
+ .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; }
894
+ .cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }
895
+ .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; }
896
+ .cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }
897
+ .cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }
898
+ .cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }
899
+ .cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }
900
+ .cg-crumb .cg-seg:hover { text-decoration:underline; }
901
+ .cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }
902
+ .cg-flash { color:#b42318; }
903
+ .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; }
904
+ .cg-upbtn { cursor:pointer; }
905
+ .cg-upbtn circle { fill:#fff; stroke:#94a3b8; }
906
+ .cg-upbtn text { font-size:11px; fill:#57606a; }
907
+ .cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }
908
+ .cg-upbtn:hover text { fill:var(--accent); }
909
+ .cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }
910
+ .cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }
911
+ .cg-chip { display:inline-flex; align-items:center; gap:4px; }
912
+ .cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }
913
+ .cg-note { font-size:.8em; color:#9a6700; }
914
+ .cg-n rect { fill:#eef2f7; stroke:#94a3b8; }
915
+ .cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }
916
+ .cg-n { cursor:pointer; }
917
+ .cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }
918
+ .cg-n.leaf { opacity:.45; }
919
+ .cg-n.test rect { stroke-dasharray:3 2; }
920
+ .cg-n.grp rect { stroke-width:1.8; }
921
+ .cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }
922
+ .cg-e.cand { stroke-dasharray:2 3; }
923
+ .cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }
924
+ .cg-e.soft { opacity:.55; }
925
+ .cg-svg.hl .cg-n { opacity:.22; }
926
+ .cg-svg.hl .cg-e { opacity:.1; }
927
+ .cg-svg.hl .cg-n.hl { opacity:1; }
928
+ .cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }
432
929
  `;
433
930
  const JS = `
434
931
  (function () {
@@ -467,6 +964,1136 @@ const JS = `
467
964
  });
468
965
  })();
469
966
  `;
967
+ // geml-code-graph runtime: layered layout AT DRAW TIME (GEP-0003 / v2-D8) so
968
+ // clicking a node re-roots the view inside the embedded slice. Algorithm as
969
+ // specified: BFS slice from roots -> DFS back-edge marking -> longest-path
970
+ // layering over forward edges -> stable in-layer order. O(V+E) per redraw.
971
+ //
972
+ // ONE implementation, two consumers: the CLI inlines `codeGraphRuntime`
973
+ // verbatim (Function.prototype.toString) into the self-contained HTML; the
974
+ // browser extension / playground import it and call it after their async
975
+ // upgrade step has attached data-graph payloads. Browser-only code — it must
976
+ // stay self-contained (no captured module-scope identifiers).
977
+ export function codeGraphRuntime(root) {
978
+ function h(tag, attrs) {
979
+ var el = document.createElementNS("http://www.w3.org/2000/svg", tag);
980
+ for (var k in attrs)
981
+ el.setAttribute(k, String(attrs[k]));
982
+ return el;
983
+ }
984
+ // Arrow-marker ids must be unique per drawn svg — several mounts share one
985
+ // document, and duplicate ids would make every graph point at the first.
986
+ var arrowSeq = 0;
987
+ function boot(mount, data0) {
988
+ var data, out;
989
+ function setData(d) {
990
+ data = d;
991
+ out = {};
992
+ data.edges.forEach(function (e) { (out[e[0]] = out[e[0]] || []).push(e); });
993
+ }
994
+ // Grouped module navigation (GEP-0003 §4): a SHALLOW two-tier model.
995
+ // Tier 1 (gpath = []) is one node per top path segment — the module-ish
996
+ // roots. Tier 2 (gpath = [seg]) is that segment's containers FLAT, labelled
997
+ // by their intra-module path; clicking a container opens its methods. At
998
+ // most ONE grouping level, so a method is always two clicks from the top —
999
+ // a deep package chain (core/service/impl) reads as a flat label, never a
1000
+ // click-through. Calls leaving the subtree aggregate into dimmed external
1001
+ // stubs so no dependency is hidden.
1002
+ function deriveView(gpath) {
1003
+ function first(p) { var c = p.indexOf("/"); return c < 0 ? p : p.slice(0, c); }
1004
+ var pByDoc = {}, docByP = {};
1005
+ data0.mods.forEach(function (m) { pByDoc[m.doc] = m.p; docByP[m.p] = m.doc; });
1006
+ // A single top segment (one-module repo) is ceremony: land straight on
1007
+ // its containers, with the breadcrumb still at root — a lone top node is
1008
+ // never worth a click. `reported` keeps the crumb showing `modules`.
1009
+ var reported = gpath;
1010
+ if (!gpath.length) {
1011
+ var tops = {};
1012
+ data0.mods.forEach(function (m) { var s = first(m.p); tops[s] = (tops[s] || 0) + 1; });
1013
+ var tk = Object.keys(tops);
1014
+ if (tk.length === 1) {
1015
+ var whole = false;
1016
+ data0.mods.forEach(function (m) { if (m.p === tk[0])
1017
+ whole = true; });
1018
+ if (!(tops[tk[0]] === 1 && whole))
1019
+ gpath = [tk[0]]; // descend past the sole group
1020
+ }
1021
+ }
1022
+ var nodes = {}, keyOf;
1023
+ if (!gpath.length) {
1024
+ // Tier 1: one node per top segment. A segment that is a single whole
1025
+ // container (its path IS the segment) is a leaf — one click to methods.
1026
+ var segCount = {}, segWhole = {};
1027
+ data0.mods.forEach(function (m) {
1028
+ var s = first(m.p);
1029
+ segCount[s] = (segCount[s] || 0) + 1;
1030
+ if (m.p === s)
1031
+ segWhole[s] = m.doc;
1032
+ });
1033
+ Object.keys(segCount).sort().forEach(function (s) {
1034
+ if (segCount[s] === 1 && segWhole[s])
1035
+ nodes[segWhole[s]] = { n: s, doc: segWhole[s] };
1036
+ else
1037
+ nodes["g:" + s] = { n: s, grp: [s] };
1038
+ });
1039
+ keyOf = function (p) { var s = first(p); return (segCount[s] === 1 && segWhole[s]) ? segWhole[s] : "g:" + s; };
1040
+ }
1041
+ else {
1042
+ // Tier 2: every container under this segment, FLAT.
1043
+ var mod = gpath.join("/"), pre = mod + "/";
1044
+ data0.mods.forEach(function (m) {
1045
+ if (m.p !== mod && m.p.indexOf(pre) !== 0)
1046
+ return;
1047
+ var label = m.p === mod ? (mod.indexOf("/") < 0 ? mod : mod.slice(mod.lastIndexOf("/") + 1)) : m.p.slice(pre.length);
1048
+ nodes[m.doc] = { n: label, doc: m.doc };
1049
+ });
1050
+ keyOf = function (p) {
1051
+ if (p === mod || p.indexOf(pre) === 0)
1052
+ return docByP[p] || null;
1053
+ return "x:" + first(p);
1054
+ };
1055
+ }
1056
+ var agg = {};
1057
+ data0.medges.forEach(function (e) {
1058
+ var a = keyOf(e[0]), b = keyOf(e[1]);
1059
+ if (!a || !b || a === b)
1060
+ return;
1061
+ if (a.indexOf("x:") === 0 && b.indexOf("x:") === 0)
1062
+ return;
1063
+ [a, b].forEach(function (kk) { if (kk.indexOf("x:") === 0 && !nodes[kk])
1064
+ nodes[kk] = { n: "↗ " + kk.slice(2), ext: 1, leaf: 1 }; });
1065
+ agg[a + ">" + b] = (agg[a + ">" + b] || 0) + (Number(e[2]) || 1);
1066
+ });
1067
+ var edges = [];
1068
+ for (var ek in agg) {
1069
+ var i2 = ek.indexOf(">");
1070
+ edges.push([ek.slice(0, i2), ek.slice(i2 + 1), "call", String(agg[ek])]);
1071
+ }
1072
+ // roots: nodes holding app entries, plus in-degree-zero nodes
1073
+ var roots = [];
1074
+ (data0.entryDocs || []).forEach(function (d) {
1075
+ var p = pByDoc[d];
1076
+ if (!p)
1077
+ return;
1078
+ var kk = keyOf(p);
1079
+ if (kk && kk.indexOf("x:") !== 0 && roots.indexOf(kk) < 0)
1080
+ roots.push(kk);
1081
+ });
1082
+ var hasIn = {};
1083
+ edges.forEach(function (e) { hasIn[e[1]] = 1; });
1084
+ for (var nk in nodes)
1085
+ if (!hasIn[nk] && !nodes[nk].ext && roots.indexOf(nk) < 0)
1086
+ roots.push(nk);
1087
+ if (!roots.length)
1088
+ for (var nk2 in nodes)
1089
+ roots.push(nk2);
1090
+ return { start: data0.start, depth: 99, mode: "modules", gpath: reported, roots: roots, nodes: nodes, edges: edges };
1091
+ }
1092
+ function homeData() {
1093
+ return data0.mode === "modules" && data0.mods ? deriveView([]) : data0;
1094
+ }
1095
+ setData(homeData());
1096
+ // scale null = fit-to-width on first draw. Left-right is the default —
1097
+ // call flow reads with the text; the toggle persists per reader.
1098
+ var state = { roots: data.roots.slice(), trail: [], scale: null, dir: "LR", frame: null, cap: 400, showAcc: false };
1099
+ // Direction survives module -> container navigation (each page is a fresh
1100
+ // document); best-effort only — file:// or the DOM stub may lack storage.
1101
+ try {
1102
+ var sd = window.localStorage.getItem("geml-cg-dir");
1103
+ if (sd === "TB" || sd === "LR")
1104
+ state.dir = sd;
1105
+ }
1106
+ catch (e) { /* no storage */ }
1107
+ function slice(roots) {
1108
+ var keep = {}, layer = {}, q = [], qi = 0, order = [];
1109
+ // Accessor noise (bean get/set/is leaves, .accessor) is hidden unless
1110
+ // toggled on; the walk COUNTS what it hides so the toolbar can say so.
1111
+ var hideAcc = data.mode !== "modules" && !state.showAcc;
1112
+ var accSeen = {}, accHidden = 0;
1113
+ roots.forEach(function (r) { if (data.nodes[r] && !(r in keep)) {
1114
+ keep[r] = 1;
1115
+ layer[r] = 0;
1116
+ q.push([r, 0]);
1117
+ order.push(r);
1118
+ } });
1119
+ while (qi < q.length) {
1120
+ var cur = q[qi][0], d = q[qi][1];
1121
+ qi++;
1122
+ if (d >= data.depth)
1123
+ continue;
1124
+ (out[cur] || []).forEach(function (e) {
1125
+ var t = e[1];
1126
+ if (!data.nodes[t] || (t in keep) || accSeen[t])
1127
+ return;
1128
+ if (hideAcc && data.nodes[t].acc) {
1129
+ accSeen[t] = 1;
1130
+ accHidden++;
1131
+ return;
1132
+ }
1133
+ keep[t] = 1;
1134
+ layer[t] = d + 1;
1135
+ q.push([t, d + 1]);
1136
+ order.push(t);
1137
+ });
1138
+ }
1139
+ // The VIEW paces itself: draw the first `cap` in BFS order, tell the
1140
+ // reader how much is beyond, let +400/all walk deeper. Data is complete.
1141
+ var total = order.length, capped = 0;
1142
+ if (data.mode !== "modules" && total > state.cap) {
1143
+ for (var oi = state.cap; oi < total; oi++)
1144
+ delete keep[order[oi]];
1145
+ capped = total - state.cap;
1146
+ }
1147
+ // Module overview: every module stays visible — the ones unreachable
1148
+ // from the roots (vendored deps etc.) park on one extra bottom layer.
1149
+ if (data.mode === "modules") {
1150
+ var park = 0;
1151
+ for (var kk in keep)
1152
+ if (layer[kk] > park)
1153
+ park = layer[kk];
1154
+ for (var nk in data.nodes)
1155
+ if (!(nk in keep)) {
1156
+ keep[nk] = 1;
1157
+ layer[nk] = park + 1;
1158
+ }
1159
+ }
1160
+ var color = {}, back = {};
1161
+ function dfs(u) {
1162
+ color[u] = 1;
1163
+ (out[u] || []).forEach(function (e) {
1164
+ var v = e[1];
1165
+ if (!keep[v])
1166
+ return;
1167
+ if (color[v] === 1)
1168
+ back[e[0] + ">" + e[1]] = 1;
1169
+ else if (!color[v])
1170
+ dfs(v);
1171
+ });
1172
+ color[u] = 2;
1173
+ }
1174
+ roots.forEach(function (r) { if (keep[r] && !color[r])
1175
+ dfs(r); });
1176
+ var changed = true, guard = 0;
1177
+ while (changed && guard++ < 80) {
1178
+ changed = false;
1179
+ data.edges.forEach(function (e) {
1180
+ if (!keep[e[0]] || !keep[e[1]] || back[e[0] + ">" + e[1]])
1181
+ return;
1182
+ if (layer[e[0]] + 1 > layer[e[1]]) {
1183
+ layer[e[1]] = layer[e[0]] + 1;
1184
+ changed = true;
1185
+ }
1186
+ });
1187
+ }
1188
+ return { keep: keep, layer: layer, back: back, accHidden: accHidden, total: total, capped: capped };
1189
+ }
1190
+ // Nested-browser view (static pages): the clicked document's pre-rendered
1191
+ // sibling .html shown INSIDE the graph area — an in-mount iframe, never a
1192
+ // whole-page navigation. "back" restores the graph exactly as it was.
1193
+ function drawFrame() {
1194
+ mount.replaceChildren();
1195
+ var bar = document.createElement("div");
1196
+ bar.className = "cg-bar";
1197
+ var crumb = document.createElement("span");
1198
+ crumb.className = "cg-crumb";
1199
+ var backBtn = document.createElement("button");
1200
+ backBtn.className = "cg-seg";
1201
+ backBtn.textContent = "◂ back";
1202
+ backBtn.onclick = function () { state.frame = null; draw(); };
1203
+ crumb.appendChild(backBtn);
1204
+ var sp = document.createElement("span");
1205
+ sp.textContent = " / " + String(state.frame.rel).replace(/\.geml$/, "");
1206
+ crumb.appendChild(sp);
1207
+ bar.appendChild(crumb);
1208
+ var open = document.createElement("a");
1209
+ open.href = state.frame.html;
1210
+ open.textContent = "open standalone ↗";
1211
+ bar.appendChild(open);
1212
+ mount.appendChild(bar);
1213
+ var fr = document.createElement("iframe");
1214
+ fr.className = "cg-frame";
1215
+ fr.setAttribute("src", state.frame.html);
1216
+ fr.setAttribute("title", state.frame.rel);
1217
+ mount.appendChild(fr);
1218
+ }
1219
+ function draw() {
1220
+ if (state.frame) {
1221
+ drawFrame();
1222
+ return;
1223
+ }
1224
+ var s = slice(state.roots);
1225
+ // The callers view reads in TRUE call order — app entry first, the
1226
+ // focused method at the far end. Its slice is built from the focus
1227
+ // outward (edges callee -> caller), so flip the layers and swap edge
1228
+ // endpoints at draw time: call direction stays left->right (top->down)
1229
+ // in every view.
1230
+ var isUp = data.dir === "up";
1231
+ if (isUp) {
1232
+ var maxL = 0, fk;
1233
+ for (fk in s.layer)
1234
+ if (s.layer[fk] > maxL)
1235
+ maxL = s.layer[fk];
1236
+ for (fk in s.layer)
1237
+ s.layer[fk] = maxL - s.layer[fk];
1238
+ }
1239
+ // Group tint: front-end and back-end (and any other top-level module)
1240
+ // stopped being distinguishable once merged into one map — colour by
1241
+ // top path segment (module overview) / owning document (method view).
1242
+ var PALETTE = ["#e3f2fd", "#e8f5e9", "#fff3e0", "#f3e5f5", "#e0f7fa", "#fce4ec", "#f1f8e9", "#ede7f6", "#fff8e1", "#e0f2f1", "#efebe9", "#f9fbe7"];
1243
+ function groupOf(k) {
1244
+ return (data.mode === "modules"
1245
+ ? (data.nodes[k].tg || String(data.nodes[k].n).split("/")[0])
1246
+ : String(k).split("#")[0]) || "";
1247
+ }
1248
+ var gnames = [];
1249
+ Object.keys(s.keep).forEach(function (k) { var gn = groupOf(k); if (gnames.indexOf(gn) < 0)
1250
+ gnames.push(gn); });
1251
+ gnames.sort();
1252
+ var rows = [];
1253
+ Object.keys(s.keep).forEach(function (k) {
1254
+ (rows[s.layer[k]] = rows[s.layer[k]] || []).push(k);
1255
+ });
1256
+ rows = rows.filter(function (r) { return r && r.length; });
1257
+ // In-layer order: group first (same-tint nodes sit together), name
1258
+ // second — and the layout leaves a small extra gap where the group
1259
+ // changes, so the colour runs read as blocks.
1260
+ rows.forEach(function (r) {
1261
+ r.sort(function (a, b) {
1262
+ var ga = groupOf(a), gb = groupOf(b);
1263
+ if (ga !== gb)
1264
+ return ga < gb ? -1 : 1;
1265
+ return data.nodes[a].n < data.nodes[b].n ? -1 : 1;
1266
+ });
1267
+ });
1268
+ var NH = 26, GY = 44, GX = 14, GYL = 12, GXL = 70, GG = 22, pos = {}, W = 320, H = 0;
1269
+ var LR = state.dir === "LR";
1270
+ var isMethod = data.mode !== "modules";
1271
+ // Box width follows the DISPLAYED label, and the label is truncated to
1272
+ // fit the box — long dir-path module names used to overflow their 220px
1273
+ // cap and stack onto their neighbours. Modules keep the TAIL (the
1274
+ // informative end of a path), methods keep the head. The ⊕ direction
1275
+ // handle is now its OWN node beside the box (drawn below), so the box
1276
+ // width no longer reserves room for it.
1277
+ function label(k) {
1278
+ var n = data.nodes[k];
1279
+ var full = n.n + (n.more ? " ›" : "");
1280
+ if (full.length <= 32)
1281
+ return full;
1282
+ return data.mode === "modules" ? "…" + full.slice(full.length - 31) : full.slice(0, 31) + "…";
1283
+ }
1284
+ // The ⊕ callers handle sits only on the current view's ROOTS: a
1285
+ // mid-graph node's callers are already drawn as its in-edges — the
1286
+ // entry is the one place the upstream is invisible. In the callers
1287
+ // view the focused method (far end) carries the mirrored handle that
1288
+ // flips back to its callee chain.
1289
+ function hasUp(k) { return isMethod && !isUp && state.roots.indexOf(k) >= 0; }
1290
+ function hasDown(k) { return isUp && k === data.focus; }
1291
+ function bw(k) { return Math.max(56, label(k).length * 7.2 + 18); }
1292
+ if (!LR) {
1293
+ rows.forEach(function (r, ri) {
1294
+ var x = 0;
1295
+ r.forEach(function (k, i) {
1296
+ if (i > 0 && groupOf(r[i - 1]) !== groupOf(k))
1297
+ x += GG;
1298
+ var w = bw(k);
1299
+ pos[k] = { x: x, y: ri * (NH + GY), w: w };
1300
+ x += w + GX;
1301
+ });
1302
+ W = Math.max(W, x - GX);
1303
+ });
1304
+ rows.forEach(function (r) {
1305
+ var rw = pos[r[r.length - 1]].x + pos[r[r.length - 1]].w;
1306
+ var off = (W - rw) / 2;
1307
+ r.forEach(function (k) { pos[k].x += off; });
1308
+ });
1309
+ H = rows.length * (NH + GY) - GY;
1310
+ }
1311
+ else {
1312
+ // Left-to-right: layers become columns, flow reads with the text.
1313
+ var cx = 0, colHs = [];
1314
+ rows.forEach(function (r, ci) {
1315
+ var cw = 0, y = 0;
1316
+ r.forEach(function (k, i) {
1317
+ if (i > 0 && groupOf(r[i - 1]) !== groupOf(k))
1318
+ y += GG;
1319
+ var w = bw(k);
1320
+ pos[k] = { x: cx, y: y, w: w };
1321
+ y += NH + GYL;
1322
+ if (w > cw)
1323
+ cw = w;
1324
+ });
1325
+ colHs[ci] = y - GYL;
1326
+ if (colHs[ci] > H)
1327
+ H = colHs[ci];
1328
+ cx += cw + GXL;
1329
+ });
1330
+ W = Math.max(320, cx - GXL);
1331
+ rows.forEach(function (r, ci) {
1332
+ var off = (H - colHs[ci]) / 2;
1333
+ r.forEach(function (k) { pos[k].y += off; });
1334
+ });
1335
+ }
1336
+ // The standalone ⊕ node sits just OUTSIDE the box on the direction it
1337
+ // points — reserve a margin so it never clips the canvas edge or a
1338
+ // neighbour. It rides the caller side of a callee-view entry (left in
1339
+ // LR, top in TB) and the callee side of the callers-view focus (right /
1340
+ // bottom). Only one side is ever active in a given view.
1341
+ var UBOFF = 17, UBPAD = 24;
1342
+ var anyUp = false, anyDown = false;
1343
+ Object.keys(s.keep).forEach(function (k) { if (hasUp(k))
1344
+ anyUp = true;
1345
+ else if (hasDown(k))
1346
+ anyDown = true; });
1347
+ var padL = anyUp && LR ? UBPAD : 0, padT = anyUp && !LR ? UBPAD : 0;
1348
+ var padR = anyDown && LR ? UBPAD : 0, padB = anyDown && !LR ? UBPAD : 0;
1349
+ if (padL || padT)
1350
+ for (var pk in pos) {
1351
+ pos[pk].x += padL;
1352
+ pos[pk].y += padT;
1353
+ }
1354
+ W += padL + padR;
1355
+ H += padT + padB;
1356
+ var svg = h("svg", { viewBox: "0 0 " + W + " " + (H + 8), class: "cg-svg", role: "img" });
1357
+ // Small arrowheads, always pointing at the CALLEE — two fixed markers
1358
+ // (normal grey, back-edge red) rather than context-stroke, which not
1359
+ // every engine paints yet.
1360
+ var arrId = "cg-arr-" + arrowSeq++;
1361
+ var defs = h("defs", {});
1362
+ [["", "#94a3b8"], ["-b", "#dc2626"]].forEach(function (mdef) {
1363
+ 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" });
1364
+ mk.appendChild(h("path", { d: "M0 1.2 L8.5 5 L0 8.8 z", fill: mdef[1] }));
1365
+ defs.appendChild(mk);
1366
+ });
1367
+ svg.appendChild(defs);
1368
+ // Hover: light up the CALLER CONE of the node under the pointer —
1369
+ // every upstream node and edge in the current view — and dim the rest.
1370
+ // upAdj maps each node to its callers within the drawn slice (in the
1371
+ // callers view the data edges already point callee -> caller).
1372
+ var upAdj = {};
1373
+ var nodeEls = {}, nodeBase = {};
1374
+ var edgeEls = {}, edgeBase = {};
1375
+ data.edges.forEach(function (e) {
1376
+ var a = pos[isUp ? e[1] : e[0]], b = pos[isUp ? e[0] : e[1]];
1377
+ if (!a || !b)
1378
+ return;
1379
+ var isBack = s.back[e[0] + ">" + e[1]] || (e[0] === e[1]);
1380
+ var cls = "cg-e" + (e[2] === "candidate" ? " cand" : "") + (isBack ? " back" : "") + (e[3] === "medium" || e[3] === "low" ? " soft" : "");
1381
+ var p;
1382
+ if (e[0] === e[1]) {
1383
+ p = LR
1384
+ ? "M" + (a.x + 8) + " " + (a.y + NH) + " c 0 16 16 16 16 0"
1385
+ : "M" + (a.x + a.w) + " " + (a.y + 8) + " c 18 0 18 " + (NH - 16) + " 0 " + (NH - 16);
1386
+ }
1387
+ else if (isBack) {
1388
+ if (LR) {
1389
+ var yb = Math.max(a.y, b.y) + NH + 24;
1390
+ 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);
1391
+ }
1392
+ else {
1393
+ var xr = Math.max(a.x + a.w, b.x + b.w) + 22;
1394
+ 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);
1395
+ }
1396
+ }
1397
+ else if (LR) {
1398
+ var lx1 = a.x + a.w, ly1 = a.y + NH / 2, lx2 = b.x, ly2 = b.y + NH / 2;
1399
+ p = "M" + lx1 + " " + ly1 + " C " + (lx1 + GXL / 2) + " " + ly1 + " " + (lx2 - GXL / 2) + " " + ly2 + " " + lx2 + " " + ly2;
1400
+ }
1401
+ else {
1402
+ var x1 = a.x + a.w / 2, y1 = a.y + NH, x2 = b.x + b.w / 2, y2 = b.y;
1403
+ p = "M" + x1 + " " + y1 + " C " + x1 + " " + (y1 + GY / 2) + " " + x2 + " " + (y2 - GY / 2) + " " + x2 + " " + y2;
1404
+ }
1405
+ var pathEl = h("path", { d: p, class: cls, "marker-end": "url(#" + arrId + (isBack ? "-b" : "") + ")" });
1406
+ var ek = e[0] + ">" + e[1];
1407
+ edgeEls[ek] = pathEl;
1408
+ edgeBase[ek] = cls;
1409
+ var callee = isUp ? e[0] : e[1], caller = isUp ? e[1] : e[0];
1410
+ (upAdj[callee] = upAdj[callee] || []).push({ n: caller, k: ek });
1411
+ if (data.mode === "modules" && e[3]) {
1412
+ var et = h("title", {});
1413
+ et.textContent = e[3] + " call(s)";
1414
+ pathEl.appendChild(et);
1415
+ }
1416
+ svg.appendChild(pathEl);
1417
+ });
1418
+ Object.keys(s.keep).forEach(function (k) {
1419
+ var n = data.nodes[k], a = pos[k];
1420
+ var ncls = "cg-n" + (n.leaf ? " leaf" : "") + (n.test ? " test" : "") + (n.grp ? " grp" : "") + (state.roots.indexOf(k) >= 0 ? " root" : "");
1421
+ var g = h("g", { class: ncls, "data-k": k, transform: "translate(" + a.x + "," + a.y + ")" });
1422
+ nodeEls[k] = g;
1423
+ nodeBase[k] = ncls;
1424
+ g.appendChild(h("rect", { width: a.w, height: NH, rx: 6, style: "fill:" + PALETTE[gnames.indexOf(groupOf(k)) % PALETTE.length] }));
1425
+ 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" });
1426
+ t.textContent = label(k);
1427
+ g.appendChild(t);
1428
+ var tip = h("title", {});
1429
+ tip.textContent = data.mode === "modules"
1430
+ ? (n.grp ? (n.grp.join("/") + "\nclick: open this group")
1431
+ : n.ext ? ("external dependency: " + n.n.replace(/^↗ /, ""))
1432
+ : n.n + "\nclick: open this module")
1433
+ : k + (n.src ? "\n" + n.src : "") + "\nclick = view source";
1434
+ g.appendChild(tip);
1435
+ svg.appendChild(g);
1436
+ if (hasUp(k) || hasDown(k)) {
1437
+ // The ⊕ handle (GEP-0003 caller direction) is now its OWN node
1438
+ // beside the box — no longer a child glued inside the box edge.
1439
+ // Same data-k / data-act / click: it focuses this node and TOGGLES
1440
+ // direction. It sits on the LEFT of a callee-view entry (expand
1441
+ // callers) and mirrors to the RIGHT of the callers-view focus (flip
1442
+ // back down); in top-down those become above / below. The reserved
1443
+ // margin above keeps it clear of the canvas edge and neighbours.
1444
+ var up = hasUp(k), ubx, uby;
1445
+ if (up) {
1446
+ if (LR) {
1447
+ ubx = a.x - UBOFF;
1448
+ uby = a.y + NH / 2;
1449
+ }
1450
+ else {
1451
+ ubx = a.x + a.w / 2;
1452
+ uby = a.y - UBOFF;
1453
+ }
1454
+ }
1455
+ else {
1456
+ if (LR) {
1457
+ ubx = a.x + a.w + UBOFF;
1458
+ uby = a.y + NH / 2;
1459
+ }
1460
+ else {
1461
+ ubx = a.x + a.w / 2;
1462
+ uby = a.y + NH + UBOFF;
1463
+ }
1464
+ }
1465
+ // Dashed connector ties the handle to its node and shows which way
1466
+ // the hidden chain flows: callers flow INTO the node (⊕ -> box),
1467
+ // callees flow OUT of it (box -> ⊕). Same grey + arrowhead as real
1468
+ // edges; dashed = "not expanded yet"; never a click target.
1469
+ var R = 6.5, TIP = 1.5, lx1, ly1, lx2, ly2;
1470
+ if (up) {
1471
+ if (LR) {
1472
+ lx1 = ubx + R;
1473
+ ly1 = uby;
1474
+ lx2 = a.x - TIP;
1475
+ ly2 = uby;
1476
+ }
1477
+ else {
1478
+ lx1 = ubx;
1479
+ ly1 = uby + R;
1480
+ lx2 = ubx;
1481
+ ly2 = a.y - TIP;
1482
+ }
1483
+ }
1484
+ else if (LR) {
1485
+ lx1 = a.x + a.w + TIP;
1486
+ ly1 = uby;
1487
+ lx2 = ubx - R - TIP;
1488
+ ly2 = uby;
1489
+ }
1490
+ else {
1491
+ lx1 = ubx;
1492
+ ly1 = a.y + NH + TIP;
1493
+ lx2 = ubx;
1494
+ ly2 = uby - R - TIP;
1495
+ }
1496
+ svg.appendChild(h("path", { class: "cg-uplink", d: "M" + lx1 + " " + ly1 + " L" + lx2 + " " + ly2, "marker-end": "url(#" + arrId + ")" }));
1497
+ var ub = h("g", { class: "cg-upbtn", "data-k": k, "data-act": up ? "up" : "down", transform: "translate(" + ubx + "," + uby + ")" });
1498
+ ub.appendChild(h("circle", { r: 6.5 }));
1499
+ var ut = h("text", { x: 0, y: 3.5, "text-anchor": "middle" });
1500
+ ut.textContent = "+";
1501
+ ub.appendChild(ut);
1502
+ var utip = h("title", {});
1503
+ utip.textContent = up ? "⊕ expand the full caller chain" : "⊕ back to its callee chain";
1504
+ ub.appendChild(utip);
1505
+ svg.appendChild(ub);
1506
+ }
1507
+ });
1508
+ // Natural pixel size; only the inner .cg-scroll pane scrolls, so the
1509
+ // toolbar (crumb/zoom/back) and the footer stay visible however big the
1510
+ // canvas gets. Squeezing a 16,000px canvas into the column made 1px
1511
+ // text — never again.
1512
+ svg.setAttribute("width", String(W));
1513
+ svg.setAttribute("height", String(H + 8));
1514
+ // Rendered pages sit next to their codemap documents: a live mount
1515
+ // (viewer/playground) carries data-src, a CLI embed carries the src
1516
+ // path in data.start — either directory anchors doc-relative links.
1517
+ var navBase = String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, "");
1518
+ // A live mount (viewer/playground/served page) navigates IN PLACE over
1519
+ // the geml documents through this loader; only truly static pages fall
1520
+ // back to their pre-rendered sibling .html pages. Read LAZILY on every
1521
+ // use: a served page attaches the hook from an async module script that
1522
+ // loads after the first draw, and late binding must still take effect
1523
+ // on the very next interaction — no redraw, no lost state.
1524
+ var live = function () { return mount._cgView; };
1525
+ mount.replaceChildren();
1526
+ var bar = document.createElement("div");
1527
+ bar.className = "cg-bar";
1528
+ // Breadcrumb: modules / <container> / <state> — the hierarchy is
1529
+ // entry -> module -> method view, and both upper levels are clickable.
1530
+ var crumb = document.createElement("span");
1531
+ crumb.className = "cg-crumb";
1532
+ function seg(txt, fn) {
1533
+ var el = document.createElement(fn ? "button" : "span");
1534
+ if (fn) {
1535
+ el.className = "cg-seg";
1536
+ el.onclick = fn;
1537
+ }
1538
+ el.textContent = txt;
1539
+ crumb.appendChild(el);
1540
+ }
1541
+ function sepEl() { var sp = document.createElement("span"); sp.textContent = " / "; crumb.appendChild(sp); }
1542
+ // A transient in-bar error — the "don't jump, say why" half of the
1543
+ // contract: an unloadable target reports here and the view stays put.
1544
+ function flash(msg) {
1545
+ var f = document.createElement("span");
1546
+ f.className = "cg-flash";
1547
+ f.textContent = msg;
1548
+ bar.appendChild(f);
1549
+ try {
1550
+ setTimeout(function () { if (f.parentNode)
1551
+ f.parentNode.removeChild(f); }, 5000);
1552
+ }
1553
+ catch (e) { /* stub */ }
1554
+ }
1555
+ function openDoc(rel) {
1556
+ var lv = live();
1557
+ if (lv) {
1558
+ Promise.resolve(lv({ doc: rel })).then(function (nd) {
1559
+ if (!nd) {
1560
+ flash("cannot load " + rel);
1561
+ return;
1562
+ }
1563
+ // A module index ships RAW rows — its nodes come from deriveView,
1564
+ // which is bound to a document's own data0. Re-boot on the loaded
1565
+ // payload so its grouping tree derives; pushView alone would draw
1566
+ // the empty raw payload (nodes come out {}).
1567
+ if (nd.mode === "modules" && nd.mods)
1568
+ boot(mount, nd);
1569
+ else
1570
+ pushView(nd);
1571
+ }, function () { flash("cannot load " + rel); });
1572
+ return;
1573
+ }
1574
+ var html = rel.replace(/\.geml$/, ".html");
1575
+ // Inside the nested frame the frame IS the browser — navigate it
1576
+ // plainly instead of stacking frame-in-frame.
1577
+ var framed = false;
1578
+ try {
1579
+ framed = window.self !== window.top;
1580
+ }
1581
+ catch (e) { /* no window: top */ }
1582
+ if (framed) {
1583
+ window.location.href = html;
1584
+ return;
1585
+ }
1586
+ function embed() { state.frame = { rel: rel, html: html }; draw(); }
1587
+ // Served over http(s): probe first, so a missing page reports in
1588
+ // place and nothing navigates. file:// cannot probe (fetch is
1589
+ // blocked) — embed directly; the frame contains any error itself.
1590
+ try {
1591
+ if (/^https?:$/.test(window.location.protocol)) {
1592
+ fetch(html, { method: "HEAD" }).then(function (r) {
1593
+ if (r.ok)
1594
+ embed();
1595
+ else
1596
+ flash("page missing: " + html + " — re-run the codemap render");
1597
+ }).catch(function () { flash("cannot reach " + html); });
1598
+ return;
1599
+ }
1600
+ }
1601
+ catch (e) { /* no fetch/location — treat like file:// */ }
1602
+ embed();
1603
+ }
1604
+ if (data.mode === "modules") {
1605
+ // Breadcrumb over the grouping tree. Tunnelled runs (levels with a
1606
+ // single child — Java package ceremony) merge into ONE hop, labelled
1607
+ // first/…/last, so the crumb shows only the steps a reader chose.
1608
+ var gp = data.gpath || [];
1609
+ seg("modules", gp.length ? function () { pushView(deriveView([])); } : null);
1610
+ var hops = [];
1611
+ var cur = [];
1612
+ for (var hi = 0; hi < gp.length; hi++) {
1613
+ var hpre = hi === 0 ? "" : gp.slice(0, hi).join("/") + "/";
1614
+ var seen = {}, branches = 0;
1615
+ data0.mods.forEach(function (m) {
1616
+ if (hpre && m.p.indexOf(hpre) !== 0)
1617
+ return;
1618
+ var rest = m.p.slice(hpre.length);
1619
+ var c = rest.indexOf("/");
1620
+ var s2 = c < 0 ? rest : rest.slice(0, c);
1621
+ if (!seen[s2]) {
1622
+ seen[s2] = 1;
1623
+ branches++;
1624
+ }
1625
+ });
1626
+ if (branches > 1 || hi === 0) {
1627
+ if (cur.length)
1628
+ hops.push(cur);
1629
+ cur = [hi];
1630
+ }
1631
+ else
1632
+ cur.push(hi);
1633
+ }
1634
+ if (cur.length)
1635
+ hops.push(cur);
1636
+ hops.forEach(function (hop, oi) {
1637
+ sepEl();
1638
+ var lbl = hop.length === 1 ? gp[hop[0]]
1639
+ : hop.length === 2 ? gp[hop[0]] + "/" + gp[hop[hop.length - 1]]
1640
+ : gp[hop[0]] + "/…/" + gp[hop[hop.length - 1]];
1641
+ var endIdx = hop[hop.length - 1];
1642
+ seg(lbl, oi < hops.length - 1 ? function () { pushView(deriveView(gp.slice(0, endIdx + 1))); } : null);
1643
+ });
1644
+ }
1645
+ else {
1646
+ seg("modules", function () { openDoc(navBase + "index.geml"); });
1647
+ sepEl();
1648
+ var modName = String(data.module || String(data.start || "").replace(/^.*\//, "").replace(/\.geml$/, "") || "container");
1649
+ seg(modName, function () {
1650
+ if (live())
1651
+ openDoc(String(data.start));
1652
+ else {
1653
+ state.trail = [];
1654
+ setData(homeData());
1655
+ state.roots = data.roots.slice();
1656
+ draw();
1657
+ }
1658
+ });
1659
+ sepEl();
1660
+ seg(data.dir === "up"
1661
+ ? "callers of " + (data.nodes[data.focus] ? data.nodes[data.focus].n : "") + (data.partial ? " (in-slice)" : "") + (Object.keys(data.nodes).length <= 1 ? " — none recorded" : "")
1662
+ : state.trail.length ? "root: " + state.roots.map(function (k) { return data.nodes[k].n; }).join(", ")
1663
+ : "roots: entry", null);
1664
+ }
1665
+ bar.appendChild(crumb);
1666
+ var scroller = document.createElement("div");
1667
+ scroller.className = "cg-scroll";
1668
+ scroller.appendChild(svg);
1669
+ // The graph and the source panel sit side by side in a flex stage; the
1670
+ // panel is empty (hidden) until a method node is clicked, so the graph
1671
+ // uses the full width until then.
1672
+ var srcPanel = document.createElement("div");
1673
+ srcPanel.className = "cg-src";
1674
+ srcPanel.style.display = "none";
1675
+ var stage = document.createElement("div");
1676
+ stage.className = "cg-stage";
1677
+ stage.appendChild(scroller);
1678
+ stage.appendChild(srcPanel);
1679
+ // The scroll pane is capped at 72vh by CSS; before first layout its
1680
+ // clientHeight is the unconstrained content height, so derive the cap
1681
+ // from the viewport. Guards keep a collapsed pane (mid-layout measure)
1682
+ // from producing a negative or zero scale — invalid CSS would silently
1683
+ // keep the previous size.
1684
+ function paneSize() {
1685
+ var mw = scroller.clientWidth || mount.clientWidth || 0;
1686
+ var mh = 0;
1687
+ try {
1688
+ mh = Math.floor(window.innerHeight * 0.72);
1689
+ }
1690
+ catch (e) { /* no window (stub) */ }
1691
+ return { w: mw, h: mh };
1692
+ }
1693
+ // The fit BUTTON: whole-graph preview, both axes visible, no floor.
1694
+ function fitScale() {
1695
+ var p = paneSize(), s = 1;
1696
+ if (p.w > 60 && W)
1697
+ s = Math.min(s, (p.w - 26) / W);
1698
+ if (p.h > 60 && H)
1699
+ s = Math.min(s, (p.h - 10) / (H + 8));
1700
+ return Math.max(s, 0.05);
1701
+ }
1702
+ // The INITIAL view fits the CROSS axis only — height in left-right,
1703
+ // width in top-down; the reading axis is meant to scroll — clamped to
1704
+ // [2/3, 1] so text never drops below ~8px. Small and medium graphs
1705
+ // land on exactly 1:1; the overview stays one "fit" click away.
1706
+ function initialScale() {
1707
+ var p = paneSize(), s = 1;
1708
+ if (LR) {
1709
+ if (p.h > 60 && H)
1710
+ s = (p.h - 10) / (H + 8);
1711
+ }
1712
+ else if (p.w > 60 && W)
1713
+ s = (p.w - 26) / W;
1714
+ return Math.min(1, Math.max(2 / 3, s));
1715
+ }
1716
+ function applyScale() {
1717
+ svg.style.width = Math.round(W * state.scale) + "px";
1718
+ svg.style.height = Math.round((H + 8) * state.scale) + "px";
1719
+ svg.style.maxWidth = "none";
1720
+ }
1721
+ function zoomBtn(label, fn) {
1722
+ var b = document.createElement("button");
1723
+ b.textContent = label;
1724
+ b.onclick = function () { fn(); applyScale(); };
1725
+ bar.appendChild(b);
1726
+ }
1727
+ zoomBtn("−", function () { state.scale = Math.max(0.1, state.scale * 0.75); });
1728
+ zoomBtn("+", function () { state.scale = Math.min(4, state.scale / 0.75); });
1729
+ zoomBtn("fit", function () { state.scale = fitScale(); });
1730
+ zoomBtn("1:1", function () { state.scale = 1; });
1731
+ var dirBtn = document.createElement("button");
1732
+ dirBtn.textContent = LR ? "top-down" : "left-right";
1733
+ dirBtn.onclick = function () {
1734
+ state.dir = LR ? "TB" : "LR";
1735
+ try {
1736
+ window.localStorage.setItem("geml-cg-dir", state.dir);
1737
+ }
1738
+ catch (e) { /* no storage */ }
1739
+ draw();
1740
+ };
1741
+ bar.appendChild(dirBtn);
1742
+ // Accessor noise: hidden by default, one honest button to bring it back.
1743
+ if (s.accHidden > 0 || state.showAcc) {
1744
+ var accBtn = document.createElement("button");
1745
+ accBtn.textContent = state.showAcc ? "hide accessors" : s.accHidden + " accessors hidden";
1746
+ accBtn.onclick = function () { state.showAcc = !state.showAcc; draw(); };
1747
+ bar.appendChild(accBtn);
1748
+ }
1749
+ // View pacing: the slice beyond the cap is one click away, never lost.
1750
+ if (s.capped > 0) {
1751
+ var capInfo = document.createElement("span");
1752
+ capInfo.className = "cg-note";
1753
+ capInfo.textContent = "showing " + (s.total - s.capped) + " of " + s.total + " reachable";
1754
+ bar.appendChild(capInfo);
1755
+ var moreBtn = document.createElement("button");
1756
+ moreBtn.textContent = "+400";
1757
+ moreBtn.onclick = function () { state.cap += 400; draw(); };
1758
+ bar.appendChild(moreBtn);
1759
+ var allBtn = document.createElement("button");
1760
+ allBtn.textContent = "all";
1761
+ allBtn.onclick = function () { state.cap = 1e9; draw(); };
1762
+ bar.appendChild(allBtn);
1763
+ }
1764
+ if (state.trail.length) {
1765
+ var backBtn = document.createElement("button");
1766
+ backBtn.textContent = "back";
1767
+ backBtn.onclick = function () { var tr = state.trail.pop(); setData(tr.data); state.roots = tr.roots; draw(); };
1768
+ bar.appendChild(backBtn);
1769
+ var resetBtn = document.createElement("button");
1770
+ resetBtn.textContent = "reset";
1771
+ resetBtn.onclick = function () { state.trail = []; setData(homeData()); state.roots = data.roots.slice(); draw(); };
1772
+ bar.appendChild(resetBtn);
1773
+ }
1774
+ mount.appendChild(bar);
1775
+ mount.appendChild(stage);
1776
+ if (state.scale === null)
1777
+ state.scale = initialScale();
1778
+ applyScale();
1779
+ if (isUp) {
1780
+ // The focused method sits at the FAR end of the callers chain —
1781
+ // scroll it into view instead of opening on the app-entry end.
1782
+ if (LR)
1783
+ scroller.scrollLeft = 1e6;
1784
+ else
1785
+ scroller.scrollTop = 1e6;
1786
+ }
1787
+ // Footer: live facts, not a static cheat-sheet (navigation lives in
1788
+ // the breadcrumb above).
1789
+ var footer = document.createElement("div");
1790
+ footer.className = "cg-legend";
1791
+ var info = document.createElement("span");
1792
+ info.textContent = data.mode === "modules"
1793
+ ? Object.keys(s.keep).length + " modules · " + data.edges.length + " edges · click a module to open it"
1794
+ : isUp && Object.keys(data.nodes).length <= 1
1795
+ ? "no recorded callers — framework/reflective entry points and dead code have none · ⊕ at the end = back to callees"
1796
+ : 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");
1797
+ footer.appendChild(info);
1798
+ mount.appendChild(footer);
1799
+ // Colour key — one chip per group (skip when it would be noise).
1800
+ if (gnames.length > 1 && gnames.length <= 14) {
1801
+ var chips = document.createElement("div");
1802
+ chips.className = "cg-groups";
1803
+ gnames.forEach(function (gn) {
1804
+ var chip = document.createElement("span");
1805
+ chip.className = "cg-chip";
1806
+ var sw = document.createElement("i");
1807
+ sw.style.background = PALETTE[gnames.indexOf(gn) % PALETTE.length] || "";
1808
+ chip.appendChild(sw);
1809
+ var lbl = document.createElement("span");
1810
+ lbl.textContent = gn || "(root)";
1811
+ chip.appendChild(lbl);
1812
+ chips.appendChild(chip);
1813
+ });
1814
+ mount.appendChild(chips);
1815
+ }
1816
+ function pushView(nd) {
1817
+ state.trail.push({ data: data, roots: state.roots });
1818
+ setData(nd);
1819
+ state.roots = nd.roots.slice();
1820
+ draw();
1821
+ }
1822
+ // Caller direction (GEP-0003): a live mount rebuilds through its
1823
+ // document loader (mount._cgView, attached by the upgrade step); a
1824
+ // static CLI page reverses its in-slice edges — partial but honest,
1825
+ // and labelled as such in the crumb.
1826
+ function showCallers(k) {
1827
+ var lv = live();
1828
+ if (lv) {
1829
+ Promise.resolve(lv({ dir: "up", node: k })).then(function (nd) {
1830
+ // In-degree-zero entry (agent/AOP hook, app top): it has no callers,
1831
+ // so "up" means the module page — not an empty caller view.
1832
+ if (nd && Object.keys(nd.nodes).length > 1)
1833
+ pushView(nd);
1834
+ else
1835
+ openDoc(navBase + "index.geml");
1836
+ });
1837
+ return;
1838
+ }
1839
+ var rin = {};
1840
+ data0.edges.forEach(function (e) { (rin[e[1]] = rin[e[1]] || []).push(e[0]); });
1841
+ var keep = {};
1842
+ keep[k] = 1;
1843
+ var q = [k], qi = 0;
1844
+ while (qi < q.length) {
1845
+ var c = q[qi++];
1846
+ (rin[c] || []).forEach(function (p) { if (!keep[p]) {
1847
+ keep[p] = 1;
1848
+ q.push(p);
1849
+ } });
1850
+ }
1851
+ if (Object.keys(keep).length <= 1) {
1852
+ openDoc(navBase + "index.geml");
1853
+ return;
1854
+ } // no callers -> module page
1855
+ var nodes = {}, edges = [];
1856
+ for (var nk in keep)
1857
+ nodes[nk] = data0.nodes[nk];
1858
+ data0.edges.forEach(function (e) { if (keep[e[0]] && keep[e[1]])
1859
+ edges.push([e[1], e[0], e[2], e[3]]); });
1860
+ pushView({ start: data0.start, depth: 99, roots: [k], nodes: nodes, edges: edges, dir: "up", focus: k, partial: 1 });
1861
+ }
1862
+ function showCallees(k) {
1863
+ var lv = live();
1864
+ if (lv) {
1865
+ Promise.resolve(lv({ dir: "down", node: k })).then(function (nd) { if (nd)
1866
+ pushView(nd); });
1867
+ return;
1868
+ }
1869
+ pushView({ start: data0.start, depth: data0.depth, roots: [k], nodes: data0.nodes, edges: data0.edges });
1870
+ }
1871
+ // A method node's `src` is a route (like a table's `src` / a chart's
1872
+ // `data`): "<path>#L<start>-<end>". Resolve it relative to navBase
1873
+ // (overridable via the mount's data-src-base), fetch the file, slice the
1874
+ // line range, and show it in the side panel — the graph stays live, so
1875
+ // clicking another node updates the panel. Unreachable (offline, a
1876
+ // static embed, or a server scoped away from the sources) DEGRADES to
1877
+ // the path, never throws.
1878
+ function showSource(k) {
1879
+ var n = data.nodes[k] || {};
1880
+ var ref = n.src ? String(n.src) : "";
1881
+ srcPanel.replaceChildren();
1882
+ srcPanel.style.display = "";
1883
+ var hd = document.createElement("div");
1884
+ hd.className = "cg-src-hd";
1885
+ var ttl = document.createElement("span");
1886
+ ttl.textContent = ref || (n.n || k);
1887
+ hd.appendChild(ttl);
1888
+ var cls = document.createElement("button");
1889
+ cls.textContent = "✕";
1890
+ cls.onclick = function () { srcPanel.style.display = "none"; srcPanel.replaceChildren(); };
1891
+ hd.appendChild(cls);
1892
+ srcPanel.appendChild(hd);
1893
+ var body = document.createElement("pre");
1894
+ body.className = "cg-src-body";
1895
+ srcPanel.appendChild(body);
1896
+ if (!ref) {
1897
+ body.textContent = "no source location recorded for this node";
1898
+ return;
1899
+ }
1900
+ var hp = ref.indexOf("#");
1901
+ var path = hp < 0 ? ref : ref.slice(0, hp);
1902
+ var rng = /L(\d+)(?:-L?(\d+))?/.exec(hp < 0 ? "" : ref.slice(hp + 1));
1903
+ var a0 = rng ? parseInt(rng[1], 10) : 0;
1904
+ var b0 = rng && rng[2] ? parseInt(rng[2], 10) : a0;
1905
+ body.textContent = "loading " + path + " …";
1906
+ var base = mount.getAttribute("data-src-base");
1907
+ if (base === null || base === undefined)
1908
+ base = navBase;
1909
+ var degrade = function () {
1910
+ body.textContent = "";
1911
+ var note = document.createElement("div");
1912
+ note.className = "cg-src-note";
1913
+ note.textContent = ref + "\nsource not reachable here";
1914
+ body.appendChild(note);
1915
+ };
1916
+ var render = function (text) {
1917
+ var lines = String(text).split(/\r?\n/);
1918
+ var out = (a0 >= 1 && a0 <= lines.length) ? lines.slice(a0 - 1, b0 >= a0 ? b0 : a0) : lines;
1919
+ body.textContent = out.join("\n");
1920
+ };
1921
+ var fetchFn = (typeof fetch === "function") ? fetch : null;
1922
+ if (!fetchFn) {
1923
+ degrade();
1924
+ return;
1925
+ }
1926
+ try {
1927
+ Promise.resolve(fetchFn(base + path)).then(function (r) {
1928
+ if (!r || r.ok === false) {
1929
+ degrade();
1930
+ return null;
1931
+ }
1932
+ return Promise.resolve(r.text ? r.text() : r).then(render);
1933
+ }).catch(degrade);
1934
+ }
1935
+ catch (e) {
1936
+ degrade();
1937
+ }
1938
+ }
1939
+ svg.addEventListener("click", function (ev) {
1940
+ var tgt = ev.target;
1941
+ var ub = tgt && tgt.closest ? tgt.closest(".cg-upbtn") : null;
1942
+ if (ub) {
1943
+ if (ub.getAttribute("data-act") === "down") {
1944
+ // The mirrored handle flips back to the callee chain — the up
1945
+ // view was pushed from there, so this is exactly one step back.
1946
+ if (state.trail.length) {
1947
+ var tr0 = state.trail.pop();
1948
+ setData(tr0.data);
1949
+ state.roots = tr0.roots;
1950
+ draw();
1951
+ }
1952
+ else
1953
+ showCallees(ub.getAttribute("data-k"));
1954
+ }
1955
+ else
1956
+ showCallers(ub.getAttribute("data-k"));
1957
+ return;
1958
+ }
1959
+ var g = tgt && tgt.closest ? tgt.closest(".cg-n") : null;
1960
+ if (!g)
1961
+ return;
1962
+ var k = g.getAttribute("data-k");
1963
+ if (data.mode === "modules") {
1964
+ var nd = data.nodes[k];
1965
+ if (nd && nd.grp) {
1966
+ pushView(deriveView(nd.grp));
1967
+ return;
1968
+ }
1969
+ if (nd && nd.ext)
1970
+ return; // external stub: informational
1971
+ if (nd && nd.doc)
1972
+ openDoc(navBase + String(nd.doc));
1973
+ return;
1974
+ }
1975
+ // Method mode: the node body now VIEWS the method's source. All chain
1976
+ // navigation (callers / flip back) lives on the standalone ⊕ node.
1977
+ showSource(k);
1978
+ });
1979
+ // Hover highlight: BFS the caller cone over upAdj, mark nodes/edges
1980
+ // with .hl and flag the svg so everything else dims. Class strings are
1981
+ // rebuilt from the recorded bases — no classList dependency.
1982
+ function clearHl() {
1983
+ svg.setAttribute("class", "cg-svg");
1984
+ for (var nk in nodeEls)
1985
+ nodeEls[nk].setAttribute("class", nodeBase[nk]);
1986
+ for (var ekk in edgeEls)
1987
+ edgeEls[ekk].setAttribute("class", edgeBase[ekk]);
1988
+ }
1989
+ svg.addEventListener("mouseover", function (ev) {
1990
+ var tgt = ev.target;
1991
+ var g = tgt && tgt.closest ? tgt.closest(".cg-n") : null;
1992
+ if (!g)
1993
+ return;
1994
+ var k = g.getAttribute("data-k");
1995
+ var seen = {};
1996
+ seen[k] = 1;
1997
+ var hlE = {};
1998
+ var q = [k], qi = 0;
1999
+ while (qi < q.length) {
2000
+ var cur = q[qi++];
2001
+ (upAdj[cur] || []).forEach(function (p) {
2002
+ hlE[p.k] = 1;
2003
+ if (!seen[p.n]) {
2004
+ seen[p.n] = 1;
2005
+ q.push(p.n);
2006
+ }
2007
+ });
2008
+ }
2009
+ svg.setAttribute("class", "cg-svg hl");
2010
+ for (var nk in nodeEls)
2011
+ nodeEls[nk].setAttribute("class", nodeBase[nk] + (seen[nk] ? " hl" : ""));
2012
+ for (var ekk in edgeEls)
2013
+ edgeEls[ekk].setAttribute("class", edgeBase[ekk] + (hlE[ekk] ? " hl" : ""));
2014
+ });
2015
+ svg.addEventListener("mouseout", function (ev) {
2016
+ var tgt = ev.target;
2017
+ if (tgt && tgt.closest && !tgt.closest(".cg-n"))
2018
+ return;
2019
+ clearHl();
2020
+ });
2021
+ }
2022
+ draw();
2023
+ }
2024
+ Array.prototype.forEach.call(root.querySelectorAll(".cg-mount"), function (mount) {
2025
+ var payload = mount.getAttribute("data-graph");
2026
+ if (payload) {
2027
+ boot(mount, JSON.parse(payload));
2028
+ return;
2029
+ }
2030
+ var side = mount.getAttribute("data-graph-src");
2031
+ if (!side)
2032
+ return; // not (yet) upgraded, or its build failed
2033
+ // Sidecar payload (served pages): the page shipped without the multi-MB
2034
+ // inline attribute — fetch it after first paint, then boot normally.
2035
+ fetch(side).then(function (r) { return r.json(); }).then(function (j) {
2036
+ if (!j || j.error !== undefined) {
2037
+ mount.textContent = "geml-code-graph: " + ((j && j.error) || "cannot load graph data");
2038
+ return;
2039
+ }
2040
+ if (j.truncated && mount.parentNode) {
2041
+ var note = document.createElement("p");
2042
+ note.className = "cg-note";
2043
+ note.textContent = "slice truncated — narrow the entry set or lower graph-depth";
2044
+ mount.parentNode.insertBefore(note, mount.nextSibling);
2045
+ }
2046
+ boot(mount, j.data);
2047
+ }).catch(function () { mount.textContent = "geml-code-graph: cannot load graph data"; });
2048
+ });
2049
+ }
2050
+ // CLI inlining: the compiled runtime function, verbatim, run against document.
2051
+ const CODE_GRAPH_JS = `(${codeGraphRuntime.toString()})(document);`;
2052
+ // Browser-side wave builder: the slice builder is synchronous with a
2053
+ // synchronous loader, but a browser fetches documents asynchronously — so
2054
+ // run the build in WAVES: every pass records the documents it needed but did
2055
+ // not have, those are fetched, and the build re-runs (builds are
2056
+ // milliseconds; the wave count is bounded by graph-depth). ONE
2057
+ // implementation, two consumers: the viewer's upgrade step and the live
2058
+ // module script injected into served pages.
2059
+ export function codeGraphWaves(fetchDoc, parseFn) {
2060
+ const cache = new Map();
2061
+ const failed = new Set();
2062
+ return {
2063
+ seed: (name, text) => { cache.set(name, text); },
2064
+ build: async (src, view) => {
2065
+ let result;
2066
+ for (;;) {
2067
+ const pending = [];
2068
+ result = buildCodeGraph(src, {
2069
+ loadDoc: (p) => {
2070
+ if (cache.has(p))
2071
+ return cache.get(p);
2072
+ if (!failed.has(p))
2073
+ pending.push(p);
2074
+ return null;
2075
+ },
2076
+ parseDoc: parseFn,
2077
+ }, view);
2078
+ if (!pending.length)
2079
+ break;
2080
+ await Promise.all(pending.map(async (p) => {
2081
+ try {
2082
+ const text = await fetchDoc(p);
2083
+ cache.set(p, text);
2084
+ if (text === null)
2085
+ failed.add(p);
2086
+ }
2087
+ catch {
2088
+ cache.set(p, null);
2089
+ failed.add(p);
2090
+ }
2091
+ }));
2092
+ }
2093
+ return result;
2094
+ },
2095
+ };
2096
+ }
470
2097
  function page(title, body, ctx, source) {
471
2098
  const mathHead = ctx.usedMath
472
2099
  ? `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">\n` +
@@ -479,6 +2106,40 @@ function page(title, body, ctx, source) {
479
2106
  const footer = source
480
2107
  ? `<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
2108
  : "";
2109
+ // Live enhancement for served pages: attach _cgView loaders after the
2110
+ // static bootstrap has drawn. The runtime reads the hook lazily, so late
2111
+ // binding works with no redraw; if this module never loads (offline copy,
2112
+ // old browser), the page simply stays static. The parser dist imports
2113
+ // node:* for its CLI paths — an import map points those at the served stub
2114
+ // (same trick as the viewer's esbuild alias), and the process shim must be
2115
+ // in place BEFORE the modules evaluate, hence the dynamic import().
2116
+ const wantLive = ctx.usedCodeGraph && !!ctx.opts.liveGraph;
2117
+ const lg = wantLive ? escAttr(ctx.opts.liveGraph) : "";
2118
+ const importMap = wantLive
2119
+ ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
2120
+ : "";
2121
+ const liveJs = wantLive
2122
+ ? `<script type="module">
2123
+ globalThis.process ??= { argv: [], env: {} };
2124
+ const { parse } = await import("${lg}geml.js");
2125
+ const { codeGraphWaves } = await import("${lg}render.js");
2126
+ const w = codeGraphWaves(async (rel) => {
2127
+ try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
2128
+ }, parse);
2129
+ for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
2130
+ const start = m.getAttribute("data-start");
2131
+ m._cgView = async (view) => {
2132
+ // A directed view builds from the node's OWN document (its meta names the
2133
+ // module and graph-depth); {doc} opens that document; else the mount's.
2134
+ const src = view && view.doc ? view.doc
2135
+ : view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
2136
+ : start;
2137
+ const r = await w.build(src, view && view.doc ? undefined : view);
2138
+ return r.error !== undefined ? null : r.data;
2139
+ };
2140
+ }
2141
+ </script>\n`
2142
+ : "";
482
2143
  return `<!doctype html>
483
2144
  <html lang="en">
484
2145
  <head>
@@ -486,23 +2147,36 @@ function page(title, body, ctx, source) {
486
2147
  <meta name="viewport" content="width=device-width, initial-scale=1">
487
2148
  <title>${esc(title)}</title>
488
2149
  <style>${CSS}</style>
489
- ${mathHead}${mermaidHead}</head>
2150
+ ${importMap}${mathHead}${mermaidHead}</head>
490
2151
  <body>
491
2152
  <main>
492
2153
  ${body}
493
2154
  </main>
494
2155
  ${footer}
495
2156
  <script>${JS}</script>
496
- </body>
2157
+ ${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
497
2158
  </html>
498
2159
  `;
499
2160
  }
500
2161
  // ---------------------------------------------------------------------------
501
2162
  // Public entry
502
2163
  // ---------------------------------------------------------------------------
2164
+ export { buildCodeGraph };
503
2165
  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");
2166
+ const ctx = new RenderCtx(doc, opts);
2167
+ let body = doc.children.map((b) => ctx.block(b)).filter((s) => s !== "").join("\n");
2168
+ // Codemap scenario ① (GEP-0003): a codemap document (meta declares module=
2169
+ // or container=, plus an entry surface) IS the graph data — offer the layered
2170
+ // method-flow view at the top, an implicit self-embed.
2171
+ const meta = doc.children.find((b) => b.kind === "block" && b.type === "meta" && b.data);
2172
+ const md = meta?.data ?? {};
2173
+ if ((md["module"] !== undefined || md["container"] !== undefined)
2174
+ && opts.loadDoc && opts.parseDoc && opts.source) {
2175
+ const cap = md["entry"] !== undefined || md["container"] !== undefined
2176
+ ? `layered method flow — roots from this document's <code>entry</code>`
2177
+ : `layered method flow — roots: in-degree-zero methods (no <code>entry</code> declared)`;
2178
+ body = ctx.codeGraphFigure(opts.source, "", `<figcaption>${cap}</figcaption>`) + "\n" + body;
2179
+ }
506
2180
  const title = opts.title ?? ctx.docTitle() ?? "GEML document";
507
2181
  return page(title, body, ctx, opts.source);
508
2182
  }