@webpieces/nx-webpieces-rules 0.4.852 → 0.4.854

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.
@@ -45,28 +45,17 @@ declare class QueueCylinders {
45
45
  private boundsOf;
46
46
  private pathEl;
47
47
  }
48
- /**
49
- * Every node of the runtime graph opens the shared floating menu.
50
- *
51
- * There is exactly ONE item, and it is the lock. NO "View Design": a node here is a running SERVICE,
52
- * a queue, a datastore or a third-party system, not an nx project, so there is no design.html to point
53
- * at and the item is ABSENT rather than present-and-dead.
54
- *
55
- * LOCK means the literal thing the box shows: dim every other node and every edge, light the locked
56
- * box alone. That is `WpNodeLock`, the same lock a design page uses — this page has no lock dropdown
57
- * and no responsibilities list, so there is no second control for it to fall out of step with, and
58
- * the menu label is derived from the lock's own state on every open.
59
- */
48
+ /** Runtime keeps the single-box Lock while Filter follows the full directed chain. */
60
49
  declare class RuntimeNodeMenu {
61
50
  private readonly svg;
62
51
  private readonly lock;
63
- constructor(svg: SVGSVGElement);
52
+ private readonly page;
53
+ constructor(svg: SVGSVGElement, lock: WpNodeLock, page: RuntimePage);
64
54
  wire(): void;
65
55
  }
66
- /**
67
- * Renders the DOT, reshapes the queues, wires the node menu, and reports a failure into the page
68
- * rather than only the console.
69
- */
70
- declare class RuntimePage {
71
- render(): void;
56
+ declare class RuntimePage extends WpFilterPage {
57
+ private lock;
58
+ protected captureBinding(svg: SVGSVGElement | null): () => void;
59
+ protected prepareSvg(svg: SVGSVGElement): void;
60
+ protected wireSvg(svg: SVGSVGElement): void;
72
61
  }
@@ -13,7 +13,7 @@
13
13
  * renderSVGElement is then SYNCHRONOUS — v2's returned a promise);
14
14
  * 2. upgrade every queue node into a TRUE horizontal cylinder;
15
15
  * 3. make every node clickable, opening the SHARED floating menu (graph-node-menu.ts — the same one
16
- * architecture/dependencies.html and every design.html use) whose only item here is Lock/Unlock.
16
+ * architecture/dependencies.html and every design.html use) with independent Lock/Unlock and chain filtering.
17
17
  *
18
18
  * Why (2) is post-processing rather than a shape:
19
19
  *
@@ -160,58 +160,45 @@ class QueueCylinders {
160
160
  return el;
161
161
  }
162
162
  }
163
- /**
164
- * Every node of the runtime graph opens the shared floating menu.
165
- *
166
- * There is exactly ONE item, and it is the lock. NO "View Design": a node here is a running SERVICE,
167
- * a queue, a datastore or a third-party system, not an nx project, so there is no design.html to point
168
- * at and the item is ABSENT rather than present-and-dead.
169
- *
170
- * LOCK means the literal thing the box shows: dim every other node and every edge, light the locked
171
- * box alone. That is `WpNodeLock`, the same lock a design page uses — this page has no lock dropdown
172
- * and no responsibilities list, so there is no second control for it to fall out of step with, and
173
- * the menu label is derived from the lock's own state on every open.
174
- */
163
+ /** Runtime keeps the single-box Lock while Filter follows the full directed chain. */
175
164
  class RuntimeNodeMenu {
176
165
  svg;
177
166
  lock;
178
- constructor(svg) {
167
+ page;
168
+ constructor(svg, lock, page) {
179
169
  this.svg = svg;
180
- this.lock = new WpNodeLock(svg);
170
+ this.lock = lock;
171
+ this.page = page;
181
172
  }
182
173
  wire() {
183
174
  WpNodeMenu.wire(this.svg, (name, node) => {
184
175
  const label = this.lock.isLocked(name) ? 'Unlock' : 'Lock';
185
- return [new WpNodeMenuItem(label, () => { this.lock.toggle(name, node); })];
176
+ return [new WpNodeMenuItem(label, () => { this.lock.toggle(name, node); }), this.page.filterItem(name)];
186
177
  });
187
178
  }
188
179
  }
189
- /**
190
- * Renders the DOT, reshapes the queues, wires the node menu, and reports a failure into the page
191
- * rather than only the console.
192
- */
193
- class RuntimePage {
194
- render() {
195
- Viz.instance()
196
- .then((viz) => {
197
- const element = viz.renderSVGElement(__DOT__);
198
- new QueueCylinders().applyTo(element);
199
- const host = document.getElementById('graph');
200
- if (host === null)
201
- return;
202
- host.appendChild(element);
203
- // AFTER the cylinders: wiring reads each node's <title>, which the redraw leaves
204
- // alone, but the clickable class belongs on the shape that is finally there.
205
- new RuntimeNodeMenu(element).wire();
206
- })
207
- // webpieces-disable no-any-unknown -- a promise rejection reason is untyped BY THE LANGUAGE (any value can be thrown), and this browser script cannot import the repo's toError helper; it is stringified, never dereferenced
208
- .catch((err) => {
209
- console.error(err);
210
- const host = document.getElementById('graph');
211
- if (host !== null)
212
- host.innerHTML = '<pre>' + String(err) + '</pre>';
180
+ class RuntimePage extends WpFilterPage {
181
+ lock = null;
182
+ captureBinding(svg) {
183
+ const previous = this.lock;
184
+ return () => {
185
+ this.lock = previous;
186
+ if (svg !== null)
187
+ this.lock?.rebind(svg);
188
+ };
189
+ }
190
+ prepareSvg(svg) { new QueueCylinders().applyTo(svg); }
191
+ wireSvg(svg) {
192
+ if (this.lock === null)
193
+ this.lock = new WpNodeLock(svg);
194
+ else
195
+ this.lock.rebind(svg);
196
+ new RuntimeNodeMenu(svg, this.lock, this).wire();
197
+ svg.querySelectorAll('g.node').forEach(node => {
198
+ if (node.querySelector('title')?.textContent === this.anchor)
199
+ node.classList.add('wp-filter-anchor');
213
200
  });
214
201
  }
215
202
  }
216
- new RuntimePage().render();
203
+ new RuntimePage(__RENDER_MODEL__).render();
217
204
  //# sourceMappingURL=runtime-visualizer.client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-visualizer.client.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-visualizer.client.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Browser-side script for tmp/webpieces/runtime-architecture.html.\n *\n * runtime-visualizer.ts reads this file's COMPILED output with readFileSync, substitutes the DOT\n * placeholder (see viz-client-globals.d.ts) with the JSON-encoded DOT, and inlines the result into a <script> tag. It runs in a\n * browser, never in node — but it is ordinary TypeScript, compiled in place by tsc like the package's\n * bin entry points, and linted like every other file here. (It used to be a committed .js asset\n * exempted from `no-js-files`; see graph-visualizer.client.ts for why that exemption is gone.)\n *\n * Three jobs:\n * 1. render the DOT with @viz-js/viz v3 (instance() resolves the WASM renderer, and\n * renderSVGElement is then SYNCHRONOUS — v2's returned a promise);\n * 2. upgrade every queue node into a TRUE horizontal cylinder;\n * 3. make every node clickable, opening the SHARED floating menu (graph-node-menu.ts — the same one\n * architecture/dependencies.html and every design.html use) whose only item here is Lock/Unlock.\n *\n * Why (2) is post-processing rather than a shape:\n *\n * Graphviz has exactly one `cylinder` and it is upright-only. `orientation=` is documented as rotating\n * POLYGON shapes, and `cylinder` is drawn with beziers, so it silently ignores the attribute — graphviz\n * issue #2244, open since 2022 and still reproducible on Graphviz 15, the version this page's renderer\n * carries. All 54 native shapes were compared at the real label size; none is a horizontal cylinder. So\n * the DOT emits `Mrecord` (which renders sensibly on its own for anyone running `dot` over the\n * committed .dot file) and this script redraws it in the browser, where the geometry is computed from\n * each node's actual bounding box and therefore fits any label width — which a fixed image asset never\n * can.\n */\n\n// The DOT placeholder and the `Viz` global are declared once in viz-client-globals.d.ts — both\n// scripts share a global scope, so they cannot each declare them. Do NOT spell the placeholder token\n// in a comment: the inliner is a blind split/join, so every literal occurrence gets the whole DOT.\n\n/** Cap width as a fraction of the node's half-height. Below ~0.6 it reads as a rounded box. */\nconst CAP_RATIO = 0.75;\n/** A node narrower than this is not a queue box worth reshaping. */\nconst MIN_WIDTH = 40;\n\n/** The union bounding box of a node's shape elements, in user space. Data-only → a class. */\nclass Bounds {\n constructor(\n readonly x0: number,\n readonly x1: number,\n readonly y0: number,\n readonly y1: number,\n ) {}\n\n width(): number {\n return this.x1 - this.x0;\n }\n\n halfHeight(): number {\n return (this.y1 - this.y0) / 2;\n }\n}\n\n/**\n * Redraws the nodes the DOT marked `wp_queue` as cylinders lying on their side.\n *\n * Selected by CLASS, not by id prefix: a queue-kind external system shares the `system__` id space with\n * databases, and a database is an UPRIGHT cylinder that must be left alone.\n */\nclass QueueCylinders {\n private static readonly SVG_NS = 'http://www.w3.org/2000/svg';\n\n applyTo(svg: SVGSVGElement): void {\n svg.querySelectorAll('g.wp_queue').forEach((node: Element): void => { this.reshape(node); });\n }\n\n /**\n * Replace one node's shape elements with a horizontal cylinder sized to its own bounding box.\n *\n * The bbox is measured from the shape elements BEFORE they are removed, never from getBBox() on the\n * whole group — the group includes the text, which is inset, so using it would shrink the cylinder\n * inside the very label it is meant to contain.\n */\n private reshape(node: Element): void {\n const shapes = node.querySelectorAll('polygon, path, polyline');\n if (shapes.length === 0) return;\n const box = this.boundsOf(shapes);\n if (box === null || box.width() < MIN_WIDTH) return;\n\n const first = shapes[0];\n const fill = first.getAttribute('fill') ?? 'none';\n const stroke = first.getAttribute('stroke') ?? 'black';\n // Measured BEFORE the shapes go: the record separator is where the label text starts.\n const separator = this.separatorX(shapes);\n shapes.forEach((shape: Element): void => { shape.remove(); });\n\n const ry = box.halfHeight();\n const rx = this.capRadius(box, ry, separator);\n const body =\n 'M' + (box.x0 + rx) + ',' + box.y0 +\n ' L' + (box.x1 - rx) + ',' + box.y0 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x1 - rx) + ',' + box.y1 +\n ' L' + (box.x0 + rx) + ',' + box.y1 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x0 + rx) + ',' + box.y0 + ' Z';\n // Only the NEAR end cap is drawn: that single arc is what reads as \"tube\" rather than\n // \"stadium\", and a real cylinder hides the far one behind the body.\n const cap =\n 'M' + (box.x0 + rx) + ',' + box.y0 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x0 + rx) + ',' + box.y1;\n\n const anchor = node.firstChild;\n if (anchor === null || anchor.nextSibling === null) return;\n node.insertBefore(this.pathEl(fill, stroke, body), anchor.nextSibling);\n node.insertBefore(this.pathEl('none', stroke, cap), anchor.nextSibling.nextSibling);\n\n // The label is deliberately NOT nudged. QUEUE_LABEL_PREFIX already gives the node an empty\n // leading record field, so Graphviz has centred the text in the space to the RIGHT of where the\n // cap lands. Shifting it again double-counts that offset and pushes the longest line out\n // through the far end of the cylinder.\n }\n\n /**\n * A box listing several queues is TALL, and a cap scaled off half-height alone would then reach\n * past the empty leading field and sit on top of the first characters of the label. Graphviz sizes\n * that field from the font, not from the label's height, so the separator is the only honest bound\n * on how far right the cap may go.\n */\n private capRadius(box: Bounds, ry: number, separator: number | null): number {\n const fromHeight = Math.max(6, ry * CAP_RATIO);\n if (separator === null) return fromHeight;\n return Math.max(6, Math.min(fromHeight, separator - box.x0));\n }\n\n /**\n * The x of the record's field separator — the VERTICAL polyline Graphviz draws between the empty\n * leading field and the label — or null if this node has none.\n */\n private separatorX(shapes: NodeListOf<Element>): number | null {\n for (const shape of Array.from(shapes)) {\n if (shape.tagName !== 'polyline') continue;\n const nums = (shape.getAttribute('points') ?? '').match(/-?[\\d.]+/g);\n if (nums === null || nums.length < 4) continue;\n const x = parseFloat(nums[0]);\n if (Math.abs(x - parseFloat(nums[2])) < 0.5) return x;\n }\n return null;\n }\n\n /** The union bounding box of some SVG shape elements, from their raw geometry attributes. */\n private boundsOf(shapes: NodeListOf<Element>): Bounds | null {\n const xs: number[] = [];\n const ys: number[] = [];\n for (const shape of Array.from(shapes)) {\n const pts = shape.getAttribute('points');\n const source = pts ?? shape.getAttribute('d') ?? '';\n const nums = source.match(/-?[\\d.]+/g);\n if (nums === null) continue;\n for (let n = 0; n + 1 < nums.length; n += 2) {\n xs.push(parseFloat(nums[n]));\n ys.push(parseFloat(nums[n + 1]));\n }\n }\n if (xs.length === 0) return null;\n return new Bounds(Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys));\n }\n\n private pathEl(fill: string, stroke: string, d: string): SVGPathElement {\n const el = document.createElementNS(QueueCylinders.SVG_NS, 'path');\n el.setAttribute('fill', fill);\n el.setAttribute('stroke', stroke);\n el.setAttribute('d', d);\n return el;\n }\n}\n\n/**\n * Every node of the runtime graph opens the shared floating menu.\n *\n * There is exactly ONE item, and it is the lock. NO \"View Design\": a node here is a running SERVICE,\n * a queue, a datastore or a third-party system, not an nx project, so there is no design.html to point\n * at and the item is ABSENT rather than present-and-dead.\n *\n * LOCK means the literal thing the box shows: dim every other node and every edge, light the locked\n * box alone. That is `WpNodeLock`, the same lock a design page uses — this page has no lock dropdown\n * and no responsibilities list, so there is no second control for it to fall out of step with, and\n * the menu label is derived from the lock's own state on every open.\n */\nclass RuntimeNodeMenu {\n private readonly lock: WpNodeLock;\n\n constructor(private readonly svg: SVGSVGElement) {\n this.lock = new WpNodeLock(svg);\n }\n\n wire(): void {\n WpNodeMenu.wire(this.svg, (name: string, node: SVGGElement): WpNodeMenuItem[] => {\n const label = this.lock.isLocked(name) ? 'Unlock' : 'Lock';\n return [new WpNodeMenuItem(label, (): void => { this.lock.toggle(name, node); })];\n });\n }\n}\n\n/**\n * Renders the DOT, reshapes the queues, wires the node menu, and reports a failure into the page\n * rather than only the console.\n */\nclass RuntimePage {\n render(): void {\n Viz.instance()\n .then((viz: VizInstance): void => {\n const element = viz.renderSVGElement(__DOT__);\n new QueueCylinders().applyTo(element);\n const host = document.getElementById('graph');\n if (host === null) return;\n host.appendChild(element);\n // AFTER the cylinders: wiring reads each node's <title>, which the redraw leaves\n // alone, but the clickable class belongs on the shape that is finally there.\n new RuntimeNodeMenu(element).wire();\n })\n // webpieces-disable no-any-unknown -- a promise rejection reason is untyped BY THE LANGUAGE (any value can be thrown), and this browser script cannot import the repo's toError helper; it is stringified, never dereferenced\n .catch((err: unknown): void => {\n console.error(err);\n const host = document.getElementById('graph');\n if (host !== null) host.innerHTML = '<pre>' + String(err) + '</pre>';\n });\n }\n}\n\nnew RuntimePage().render();\n"]}
1
+ {"version":3,"file":"runtime-visualizer.client.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-visualizer.client.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,+FAA+F;AAC/F,qGAAqG;AACrG,mGAAmG;AAEnG,+FAA+F;AAC/F,MAAM,SAAS,GAAG,IAAI,CAAC;AACvB,oEAAoE;AACpE,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB,6FAA6F;AAC7F,MAAM,MAAM;IAEK;IACA;IACA;IACA;IAJb,YACa,EAAU,EACV,EAAU,EACV,EAAU,EACV,EAAU;QAHV,OAAE,GAAF,EAAE,CAAQ;QACV,OAAE,GAAF,EAAE,CAAQ;QACV,OAAE,GAAF,EAAE,CAAQ;QACV,OAAE,GAAF,EAAE,CAAQ;IACpB,CAAC;IAEJ,KAAK;QACD,OAAO,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;IAC7B,CAAC;IAED,UAAU;QACN,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;CACJ;AAED;;;;;GAKG;AACH,MAAM,cAAc;IACR,MAAM,CAAU,MAAM,GAAG,4BAA4B,CAAC;IAE9D,OAAO,CAAC,GAAkB;QACtB,GAAG,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,CAAC,IAAa,EAAQ,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACjG,CAAC;IAED;;;;;;OAMG;IACK,OAAO,CAAC,IAAa;QACzB,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,yBAAyB,CAAC,CAAC;QAChE,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAChC,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAClC,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,CAAC,KAAK,EAAE,GAAG,SAAS;YAAE,OAAO;QAEpD,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACxB,MAAM,IAAI,GAAG,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;QAClD,MAAM,MAAM,GAAG,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC;QACvD,sFAAsF;QACtF,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAC1C,MAAM,CAAC,OAAO,CAAC,CAAC,KAAc,EAAQ,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAE9D,MAAM,EAAE,GAAG,GAAG,CAAC,UAAU,EAAE,CAAC;QAC5B,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,EAAE,SAAS,CAAC,CAAC;QAC9C,MAAM,IAAI,GACN,GAAG,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE;YAClC,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE;YACnC,IAAI,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,GAAG,SAAS,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE;YAC/D,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE;YACnC,IAAI,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,GAAG,SAAS,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC;QAC3E,sFAAsF;QACtF,oEAAoE;QACpE,MAAM,GAAG,GACL,GAAG,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE;YAClC,IAAI,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,GAAG,SAAS,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE,CAAC;QAEpE,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC;QAC/B,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,WAAW,KAAK,IAAI;YAAE,OAAO;QAC3D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC;QACvE,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;QAEpF,2FAA2F;QAC3F,gGAAgG;QAChG,yFAAyF;QACzF,uCAAuC;IAC3C,CAAC;IAED;;;;;OAKG;IACK,SAAS,CAAC,GAAW,EAAE,EAAU,EAAE,SAAwB;QAC/D,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,GAAG,SAAS,CAAC,CAAC;QAC/C,IAAI,SAAS,KAAK,IAAI;YAAE,OAAO,UAAU,CAAC;QAC1C,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACjE,CAAC;IAED;;;OAGG;IACK,UAAU,CAAC,MAA2B;QAC1C,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;YACrC,IAAI,KAAK,CAAC,OAAO,KAAK,UAAU;gBAAE,SAAS;YAC3C,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;YACrE,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;gBAAE,SAAS;YAC/C,MAAM,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9B,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG;gBAAE,OAAO,CAAC,CAAC;QAC1D,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,6FAA6F;IACrF,QAAQ,CAAC,MAA2B;QACxC,MAAM,EAAE,GAAa,EAAE,CAAC;QACxB,MAAM,EAAE,GAAa,EAAE,CAAC;QACxB,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;YACrC,MAAM,GAAG,GAAG,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;YACzC,MAAM,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YACpD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;YACvC,IAAI,IAAI,KAAK,IAAI;gBAAE,SAAS;YAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1C,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC7B,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACrC,CAAC;QACL,CAAC;QACD,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACjC,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IAC1F,CAAC;IAEO,MAAM,CAAC,IAAY,EAAE,MAAc,EAAE,CAAS;QAClD,MAAM,EAAE,GAAG,QAAQ,CAAC,eAAe,CAAC,cAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACnE,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC9B,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAClC,EAAE,CAAC,YAAY,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QACxB,OAAO,EAAE,CAAC;IACd,CAAC;;AAGL,sFAAsF;AACtF,MAAM,eAAe;IACY;IAAqC;IAAmC;IAArG,YAA6B,GAAkB,EAAmB,IAAgB,EAAmB,IAAiB;QAAzF,QAAG,GAAH,GAAG,CAAe;QAAmB,SAAI,GAAJ,IAAI,CAAY;QAAmB,SAAI,GAAJ,IAAI,CAAa;IAAG,CAAC;IAE1H,IAAI;QACA,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAY,EAAE,IAAiB,EAAoB,EAAE;YAC5E,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC;YAC3D,OAAO,CAAC,IAAI,cAAc,CAAC,KAAK,EAAE,GAAS,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;QAClH,CAAC,CAAC,CAAC;IACP,CAAC;CACJ;AAED,MAAM,WAAY,SAAQ,YAAY;IAC1B,IAAI,GAAsB,IAAI,CAAC;IAEpB,cAAc,CAAC,GAAyB;QACvD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC;QAC3B,OAAO,GAAS,EAAE;YACd,IAAI,CAAC,IAAI,GAAG,QAAQ,CAAC;YACrB,IAAI,GAAG,KAAK,IAAI;gBAAE,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;QAC7C,CAAC,CAAC;IACN,CAAC;IAEkB,UAAU,CAAC,GAAkB,IAAU,IAAI,cAAc,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAEpF,OAAO,CAAC,GAAkB;QAChC,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI;YAAE,IAAI,CAAC,IAAI,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;;YACnD,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;QACjD,GAAG,CAAC,gBAAgB,CAAc,QAAQ,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;YACvD,IAAI,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,WAAW,KAAK,IAAI,CAAC,MAAM;gBAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;QACzG,CAAC,CAAC,CAAC;IACP,CAAC;CACJ;AAED,IAAI,WAAW,CAAC,gBAAgB,CAAC,CAAC,MAAM,EAAE,CAAC","sourcesContent":["/*\n * Browser-side script for tmp/webpieces/runtime-architecture.html.\n *\n * runtime-visualizer.ts reads this file's COMPILED output with readFileSync, substitutes the DOT\n * placeholder (see viz-client-globals.d.ts) with the JSON-encoded DOT, and inlines the result into a <script> tag. It runs in a\n * browser, never in node — but it is ordinary TypeScript, compiled in place by tsc like the package's\n * bin entry points, and linted like every other file here. (It used to be a committed .js asset\n * exempted from `no-js-files`; see graph-visualizer.client.ts for why that exemption is gone.)\n *\n * Three jobs:\n * 1. render the DOT with @viz-js/viz v3 (instance() resolves the WASM renderer, and\n * renderSVGElement is then SYNCHRONOUS — v2's returned a promise);\n * 2. upgrade every queue node into a TRUE horizontal cylinder;\n * 3. make every node clickable, opening the SHARED floating menu (graph-node-menu.ts — the same one\n * architecture/dependencies.html and every design.html use) with independent Lock/Unlock and chain filtering.\n *\n * Why (2) is post-processing rather than a shape:\n *\n * Graphviz has exactly one `cylinder` and it is upright-only. `orientation=` is documented as rotating\n * POLYGON shapes, and `cylinder` is drawn with beziers, so it silently ignores the attribute — graphviz\n * issue #2244, open since 2022 and still reproducible on Graphviz 15, the version this page's renderer\n * carries. All 54 native shapes were compared at the real label size; none is a horizontal cylinder. So\n * the DOT emits `Mrecord` (which renders sensibly on its own for anyone running `dot` over the\n * committed .dot file) and this script redraws it in the browser, where the geometry is computed from\n * each node's actual bounding box and therefore fits any label width — which a fixed image asset never\n * can.\n */\n\n// The DOT placeholder and the `Viz` global are declared once in viz-client-globals.d.ts — both\n// scripts share a global scope, so they cannot each declare them. Do NOT spell the placeholder token\n// in a comment: the inliner is a blind split/join, so every literal occurrence gets the whole DOT.\n\n/** Cap width as a fraction of the node's half-height. Below ~0.6 it reads as a rounded box. */\nconst CAP_RATIO = 0.75;\n/** A node narrower than this is not a queue box worth reshaping. */\nconst MIN_WIDTH = 40;\n\n/** The union bounding box of a node's shape elements, in user space. Data-only → a class. */\nclass Bounds {\n constructor(\n readonly x0: number,\n readonly x1: number,\n readonly y0: number,\n readonly y1: number,\n ) {}\n\n width(): number {\n return this.x1 - this.x0;\n }\n\n halfHeight(): number {\n return (this.y1 - this.y0) / 2;\n }\n}\n\n/**\n * Redraws the nodes the DOT marked `wp_queue` as cylinders lying on their side.\n *\n * Selected by CLASS, not by id prefix: a queue-kind external system shares the `system__` id space with\n * databases, and a database is an UPRIGHT cylinder that must be left alone.\n */\nclass QueueCylinders {\n private static readonly SVG_NS = 'http://www.w3.org/2000/svg';\n\n applyTo(svg: SVGSVGElement): void {\n svg.querySelectorAll('g.wp_queue').forEach((node: Element): void => { this.reshape(node); });\n }\n\n /**\n * Replace one node's shape elements with a horizontal cylinder sized to its own bounding box.\n *\n * The bbox is measured from the shape elements BEFORE they are removed, never from getBBox() on the\n * whole group — the group includes the text, which is inset, so using it would shrink the cylinder\n * inside the very label it is meant to contain.\n */\n private reshape(node: Element): void {\n const shapes = node.querySelectorAll('polygon, path, polyline');\n if (shapes.length === 0) return;\n const box = this.boundsOf(shapes);\n if (box === null || box.width() < MIN_WIDTH) return;\n\n const first = shapes[0];\n const fill = first.getAttribute('fill') ?? 'none';\n const stroke = first.getAttribute('stroke') ?? 'black';\n // Measured BEFORE the shapes go: the record separator is where the label text starts.\n const separator = this.separatorX(shapes);\n shapes.forEach((shape: Element): void => { shape.remove(); });\n\n const ry = box.halfHeight();\n const rx = this.capRadius(box, ry, separator);\n const body =\n 'M' + (box.x0 + rx) + ',' + box.y0 +\n ' L' + (box.x1 - rx) + ',' + box.y0 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x1 - rx) + ',' + box.y1 +\n ' L' + (box.x0 + rx) + ',' + box.y1 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x0 + rx) + ',' + box.y0 + ' Z';\n // Only the NEAR end cap is drawn: that single arc is what reads as \"tube\" rather than\n // \"stadium\", and a real cylinder hides the far one behind the body.\n const cap =\n 'M' + (box.x0 + rx) + ',' + box.y0 +\n ' A' + rx + ',' + ry + ' 0 0 1 ' + (box.x0 + rx) + ',' + box.y1;\n\n const anchor = node.firstChild;\n if (anchor === null || anchor.nextSibling === null) return;\n node.insertBefore(this.pathEl(fill, stroke, body), anchor.nextSibling);\n node.insertBefore(this.pathEl('none', stroke, cap), anchor.nextSibling.nextSibling);\n\n // The label is deliberately NOT nudged. QUEUE_LABEL_PREFIX already gives the node an empty\n // leading record field, so Graphviz has centred the text in the space to the RIGHT of where the\n // cap lands. Shifting it again double-counts that offset and pushes the longest line out\n // through the far end of the cylinder.\n }\n\n /**\n * A box listing several queues is TALL, and a cap scaled off half-height alone would then reach\n * past the empty leading field and sit on top of the first characters of the label. Graphviz sizes\n * that field from the font, not from the label's height, so the separator is the only honest bound\n * on how far right the cap may go.\n */\n private capRadius(box: Bounds, ry: number, separator: number | null): number {\n const fromHeight = Math.max(6, ry * CAP_RATIO);\n if (separator === null) return fromHeight;\n return Math.max(6, Math.min(fromHeight, separator - box.x0));\n }\n\n /**\n * The x of the record's field separator — the VERTICAL polyline Graphviz draws between the empty\n * leading field and the label — or null if this node has none.\n */\n private separatorX(shapes: NodeListOf<Element>): number | null {\n for (const shape of Array.from(shapes)) {\n if (shape.tagName !== 'polyline') continue;\n const nums = (shape.getAttribute('points') ?? '').match(/-?[\\d.]+/g);\n if (nums === null || nums.length < 4) continue;\n const x = parseFloat(nums[0]);\n if (Math.abs(x - parseFloat(nums[2])) < 0.5) return x;\n }\n return null;\n }\n\n /** The union bounding box of some SVG shape elements, from their raw geometry attributes. */\n private boundsOf(shapes: NodeListOf<Element>): Bounds | null {\n const xs: number[] = [];\n const ys: number[] = [];\n for (const shape of Array.from(shapes)) {\n const pts = shape.getAttribute('points');\n const source = pts ?? shape.getAttribute('d') ?? '';\n const nums = source.match(/-?[\\d.]+/g);\n if (nums === null) continue;\n for (let n = 0; n + 1 < nums.length; n += 2) {\n xs.push(parseFloat(nums[n]));\n ys.push(parseFloat(nums[n + 1]));\n }\n }\n if (xs.length === 0) return null;\n return new Bounds(Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys));\n }\n\n private pathEl(fill: string, stroke: string, d: string): SVGPathElement {\n const el = document.createElementNS(QueueCylinders.SVG_NS, 'path');\n el.setAttribute('fill', fill);\n el.setAttribute('stroke', stroke);\n el.setAttribute('d', d);\n return el;\n }\n}\n\n/** Runtime keeps the single-box Lock while Filter follows the full directed chain. */\nclass RuntimeNodeMenu {\n constructor(private readonly svg: SVGSVGElement, private readonly lock: WpNodeLock, private readonly page: RuntimePage) {}\n\n wire(): void {\n WpNodeMenu.wire(this.svg, (name: string, node: SVGGElement): WpNodeMenuItem[] => {\n const label = this.lock.isLocked(name) ? 'Unlock' : 'Lock';\n return [new WpNodeMenuItem(label, (): void => { this.lock.toggle(name, node); }), this.page.filterItem(name)];\n });\n }\n}\n\nclass RuntimePage extends WpFilterPage {\n private lock: WpNodeLock | null = null;\n\n protected override captureBinding(svg: SVGSVGElement | null): () => void {\n const previous = this.lock;\n return (): void => {\n this.lock = previous;\n if (svg !== null) this.lock?.rebind(svg);\n };\n }\n\n protected override prepareSvg(svg: SVGSVGElement): void { new QueueCylinders().applyTo(svg); }\n\n protected wireSvg(svg: SVGSVGElement): void {\n if (this.lock === null) this.lock = new WpNodeLock(svg);\n else this.lock.rebind(svg);\n new RuntimeNodeMenu(svg, this.lock, this).wire();\n svg.querySelectorAll<SVGGElement>('g.node').forEach(node => {\n if (node.querySelector('title')?.textContent === this.anchor) node.classList.add('wp-filter-anchor');\n });\n }\n}\n\nnew RuntimePage(__RENDER_MODEL__).render();\n"]}
@@ -37,13 +37,15 @@
37
37
  *
38
38
  * EVERY node on the rendered page is clickable and opens the SHARED floating menu
39
39
  * (graph-node-menu.ts — one implementation, also used by architecture/dependencies.html
40
- * and every project's design.html). Its only item is Lock/Unlock: locking dims every
40
+ * and every project's design.html). Lock/Unlock dims every
41
41
  * other node and every edge and lights the locked box alone. There is no "View Design"
42
42
  * item here — a node is a running service, queue, datastore or third-party system, not
43
43
  * an nx project, so no design.html exists to point at and the item is absent rather than
44
- * dead. This page has no lock dropdown, so the menu is the one and only lock control.
44
+ * dead. Filter Unconnected follows separate incoming/outgoing chains and compacts the rendered
45
+ * topology; Turn off Filter restores the full picture. Lock remains independent of Filter.
45
46
  */
46
47
  import type { RuntimeGraph } from './runtime-graph';
48
+ import { GraphRenderModel } from './graph-render-model';
47
49
  /** Render options for the runtime graph. */
48
50
  export declare class RuntimeVizOptions {
49
51
  /**
@@ -62,6 +64,8 @@ export declare class RuntimeVizOptions {
62
64
  }
63
65
  /** Build the Graphviz DOT for the runtime service graph. */
64
66
  export declare function generateRuntimeDot(graph: RuntimeGraph, title?: string, options?: RuntimeVizOptions): string;
67
+ /** Capture synthetic drawable nodes and edges at their original emission sites. */
68
+ export declare function generateRuntimeRenderModel(graph: RuntimeGraph, title?: string, options?: RuntimeVizOptions): GraphRenderModel;
65
69
  export interface RuntimeVisualizationPaths {
66
70
  dotPath: string;
67
71
  htmlPath: string;
@@ -38,20 +38,23 @@
38
38
  *
39
39
  * EVERY node on the rendered page is clickable and opens the SHARED floating menu
40
40
  * (graph-node-menu.ts — one implementation, also used by architecture/dependencies.html
41
- * and every project's design.html). Its only item is Lock/Unlock: locking dims every
41
+ * and every project's design.html). Lock/Unlock dims every
42
42
  * other node and every edge and lights the locked box alone. There is no "View Design"
43
43
  * item here — a node is a running service, queue, datastore or third-party system, not
44
44
  * an nx project, so no design.html exists to point at and the item is absent rather than
45
- * dead. This page has no lock dropdown, so the menu is the one and only lock control.
45
+ * dead. Filter Unconnected follows separate incoming/outgoing chains and compacts the rendered
46
+ * topology; Turn off Filter restores the full picture. Lock remains independent of Filter.
46
47
  */
47
48
  Object.defineProperty(exports, "__esModule", { value: true });
48
49
  exports.RuntimeVizOptions = void 0;
49
50
  exports.generateRuntimeDot = generateRuntimeDot;
51
+ exports.generateRuntimeRenderModel = generateRuntimeRenderModel;
50
52
  exports.writeRuntimeVisualization = writeRuntimeVisualization;
51
53
  const tslib_1 = require("tslib");
52
54
  const fs = tslib_1.__importStar(require("fs"));
53
55
  const path = tslib_1.__importStar(require("path"));
54
56
  const dot_syntax_1 = require("./dot-syntax");
57
+ const graph_render_model_1 = require("./graph-render-model");
55
58
  const runtime_html_page_1 = require("./runtime-html-page");
56
59
  const runtime_viz_theme_1 = require("./runtime-viz-theme");
57
60
  /** Apis per line inside a node label — beyond this the box grows wider than it is readable. */
@@ -192,18 +195,18 @@ function queueLine(key, queue) {
192
195
  return line;
193
196
  return `${line}\\nqueue: ${(0, dot_syntax_1.recordValue)(queue.queueName)}`;
194
197
  }
195
- /** The node statement + enqueue/deliver arrows for one queue box. Names arrive pre-escaped. */
198
+ /** The node statement + enqueue/deliver arrows for one queue box. Service identities remain raw; only emitted DOT is escaped. */
196
199
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
197
- function queueBoxDot(id, body, producers, consumers) {
200
+ function queueBoxDot(id, body, producers, consumers, model) {
198
201
  // Record-mode label: the text must clear recordValue(), and QUEUE_LABEL_PREFIX supplies the
199
202
  // empty leading field that draws the cylinder's end cap. Drop it and the node silently
200
203
  // degrades to a plain box.
201
- let dot = ` "${id}" [shape=${runtime_viz_theme_1.QUEUE_SHAPE}, style="filled", fillcolor="${runtime_viz_theme_1.QUEUE_FILL}", ` +
202
- `class="${runtime_viz_theme_1.QUEUE_CLASS}", label="${runtime_viz_theme_1.QUEUE_LABEL_PREFIX}${body}"];\n`;
204
+ let dot = model.node(id, ` "${id}" [shape=${runtime_viz_theme_1.QUEUE_SHAPE}, style="filled", fillcolor="${runtime_viz_theme_1.QUEUE_FILL}", ` +
205
+ `class="${runtime_viz_theme_1.QUEUE_CLASS}", label="${runtime_viz_theme_1.QUEUE_LABEL_PREFIX}${body}"];\n`);
203
206
  for (const producer of producers)
204
- dot += ` "${producer}" -> "${id}" [label="enqueue", style=dashed];\n`;
207
+ dot += model.edge(producer, id, ` "${serviceNodeId(producer)}" -> "${id}" [label="enqueue", style=dashed];\n`);
205
208
  for (const consumer of consumers)
206
- dot += ` "${id}" -> "${consumer}" [label="deliver", style=dashed];\n`;
209
+ dot += model.edge(id, consumer, ` "${id}" -> "${serviceNodeId(consumer)}" [label="deliver", style=dashed];\n`);
207
210
  return dot;
208
211
  }
209
212
  /**
@@ -245,7 +248,7 @@ function groupQueues(byQueue, graph) {
245
248
  * one is stuck) at a fraction of the node count.
246
249
  */
247
250
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
248
- function queuesDot(graph, hidden) {
251
+ function queuesDot(graph, hidden, model) {
249
252
  const queued = graph.runtimeEdges.filter((e) => e.type === 'pubsub' && !hidden.has(e.from) && !hidden.has(e.to));
250
253
  if (queued.length === 0)
251
254
  return '';
@@ -255,12 +258,12 @@ function queuesDot(graph, hidden) {
255
258
  // per-method queue at all, so it keeps the historical unnamed per-pair box.
256
259
  const byQueue = new Map();
257
260
  for (const edge of queued) {
258
- const from = serviceNodeId(edge.from);
259
- const to = serviceNodeId(edge.to);
261
+ const from = edge.from;
262
+ const to = edge.to;
260
263
  if (edge.queue === undefined) {
261
264
  // Kept RAW: recordValue already applies dotValue, so escaping here would double it.
262
265
  const viaRaw = edge.via.map((v) => getShortName(v)).join(', ');
263
- dot += queueBoxDot(`queue__${dotId(edge.from)}__${dotId(edge.to)}`, `${(0, dot_syntax_1.recordValue)(viaRaw)}\\nqueue`, [from], [to]);
266
+ dot += queueBoxDot(`queue__${dotId(edge.from)}__${dotId(edge.to)}`, `${(0, dot_syntax_1.recordValue)(viaRaw)}\\nqueue`, [from], [to], model);
264
267
  continue;
265
268
  }
266
269
  if (!byQueue.has(edge.queue))
@@ -272,7 +275,7 @@ function queuesDot(graph, hidden) {
272
275
  const body = group.members
273
276
  .map((key) => queueLine(key, graph.queues[key]))
274
277
  .join('\\n');
275
- dot += queueBoxDot(`queue__${dotId(group.members[0])}`, body, group.producers, group.consumers);
278
+ dot += queueBoxDot(`queue__${dotId(group.members[0])}`, body, group.producers, group.consumers, model);
276
279
  }
277
280
  return dot;
278
281
  }
@@ -291,7 +294,7 @@ function dotId(raw) {
291
294
  * copy-pasted attribute string would have let the two drift until they stopped being one box.
292
295
  */
293
296
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
294
- function externalSystemNodeDot(identity, kind, label, subtitle) {
297
+ function externalSystemNodeDot(identity, kind, label, subtitle, model) {
295
298
  const shape = runtime_viz_theme_1.EXTERNAL_SHAPES[kind] ?? 'box';
296
299
  const fill = runtime_viz_theme_1.EXTERNAL_FILLS[kind] ?? runtime_viz_theme_1.EXTERNAL_FILL;
297
300
  // An Mrecord-shaped system needs the same empty leading field as a queue node, or it renders as
@@ -302,7 +305,7 @@ function externalSystemNodeDot(identity, kind, label, subtitle) {
302
305
  // Only a queue-kind system is marked: a database here is an UPRIGHT cylinder and must not be
303
306
  // caught by the browser-side reshaping that lays queues on their side.
304
307
  const marker = isQueue ? `class="${runtime_viz_theme_1.QUEUE_CLASS}", ` : '';
305
- return (` "system__${dotId(identity)}" [shape=${shape}, style="filled", fillcolor="${fill}", ` +
308
+ return model.node(`system__${dotId(identity)}`, ` "system__${dotId(identity)}" [shape=${shape}, style="filled", fillcolor="${fill}", ` +
306
309
  `${marker}label="${prefix}${text}\\n(${subtitle})"];\n`);
307
310
  }
308
311
  /**
@@ -326,7 +329,7 @@ function externalSystemNodeDot(identity, kind, label, subtitle) {
326
329
  * must not restate the node statement.
327
330
  */
328
331
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
329
- function triggerDot(graph, hidden, options) {
332
+ function triggerDot(graph, hidden, options, model) {
330
333
  const triggers = graph.triggers.filter((t) => !hidden.has(t.service));
331
334
  if (triggers.length === 0)
332
335
  return '';
@@ -340,10 +343,9 @@ function triggerDot(graph, hidden, options) {
340
343
  if (trigger.kind === 'cron') {
341
344
  const id = `cron__${dotId(`${trigger.api}_${trigger.method}`)}`;
342
345
  const schedule = (0, dot_syntax_1.dotValue)(trigger.queueName ?? `${trigger.api}-${trigger.method}`);
343
- dot +=
344
- ` "${id}" [shape=circle, style="filled", fillcolor="${runtime_viz_theme_1.CRON_FILL}", ` +
345
- `color="${runtime_viz_theme_1.CRON_BORDER}", label="⏰\\ncron"];\n` +
346
- ` "${id}" -> "${service}" [label="${label}\\n${schedule}", color="${runtime_viz_theme_1.CRON_BORDER}"];\n`;
346
+ dot += model.node(id, ` "${id}" [shape=circle, style="filled", fillcolor="${runtime_viz_theme_1.CRON_FILL}", ` +
347
+ `color="${runtime_viz_theme_1.CRON_BORDER}", label="⏰\\ncron"];\n`) +
348
+ model.edge(id, trigger.service, ` "${id}" -> "${service}" [label="${label}\\n${schedule}", color="${runtime_viz_theme_1.CRON_BORDER}"];\n`);
347
349
  continue;
348
350
  }
349
351
  const caller = trigger.caller;
@@ -357,11 +359,11 @@ function triggerDot(graph, hidden, options) {
357
359
  emitted.add(id);
358
360
  dot +=
359
361
  caller === undefined
360
- ? ` "${id}" [shape=box, style="dotted,filled", fillcolor="${runtime_viz_theme_1.EXTERNAL_FILL}", ` +
361
- `color="${runtime_viz_theme_1.EXTERNAL_BORDER}", label="${(0, dot_syntax_1.dotValue)(trigger.api)}\\n? unknown caller"];\n`
362
- : externalSystemNodeDot(caller.label, caller.kind, caller.label, 'external caller');
362
+ ? model.node(id, ` "${id}" [shape=box, style="dotted,filled", fillcolor="${runtime_viz_theme_1.EXTERNAL_FILL}", ` +
363
+ `color="${runtime_viz_theme_1.EXTERNAL_BORDER}", label="${(0, dot_syntax_1.dotValue)(trigger.api)}\\n? unknown caller"];\n`)
364
+ : externalSystemNodeDot(caller.label, caller.kind, caller.label, 'external caller', model);
363
365
  }
364
- dot += ` "${id}" -> "${service}" [label="${label}", style=dashed, color="${runtime_viz_theme_1.EXTERNAL_BORDER}"];\n`;
366
+ dot += model.edge(id, trigger.service, ` "${id}" -> "${service}" [label="${label}", style=dashed, color="${runtime_viz_theme_1.EXTERNAL_BORDER}"];\n`);
365
367
  }
366
368
  return dot;
367
369
  }
@@ -376,7 +378,7 @@ function triggerDot(graph, hidden, options) {
376
378
  * read look like an event.
377
379
  */
378
380
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
379
- function externalSystemsDot(graph, hidden) {
381
+ function externalSystemsDot(graph, hidden, model) {
380
382
  const systems = graph.externalSystems ?? {};
381
383
  const ids = Object.keys(systems).sort();
382
384
  if (ids.length === 0)
@@ -384,7 +386,7 @@ function externalSystemsDot(graph, hidden) {
384
386
  let dot = '\n // Declared external systems — drawn with the shape of what they actually are.\n';
385
387
  for (const id of ids) {
386
388
  const system = systems[id];
387
- dot += externalSystemNodeDot(id, system.kind, system.label, `external ${(0, dot_syntax_1.dotValue)(system.kind)}`);
389
+ dot += externalSystemNodeDot(id, system.kind, system.label, `external ${(0, dot_syntax_1.dotValue)(system.kind)}`, model);
388
390
  }
389
391
  for (const id of ids) {
390
392
  const system = systems[id];
@@ -392,7 +394,7 @@ function externalSystemsDot(graph, hidden) {
392
394
  for (const service of [...system.usedBy].sort()) {
393
395
  if (hidden.has(service))
394
396
  continue;
395
- dot += ` "${serviceNodeId(service)}" -> "system__${dotId(id)}"${via};\n`;
397
+ dot += model.edge(service, `system__${dotId(id)}`, ` "${serviceNodeId(service)}" -> "system__${dotId(id)}"${via};\n`);
396
398
  }
397
399
  }
398
400
  return dot;
@@ -410,7 +412,7 @@ function externalSystemsDot(graph, hidden) {
410
412
  * has already drawn it with a real shape, and rendering it in both places would double the node.
411
413
  */
412
414
  // webpieces-disable no-function-outside-class -- DOT string builder, matching getShortName in this file
413
- function externalDot(graph, hidden) {
415
+ function externalDot(graph, hidden, model) {
414
416
  // "service|externalName" -> the apis flowing over it.
415
417
  const apisByPair = new Map();
416
418
  for (const use of graph.unresolvedUses) {
@@ -434,9 +436,8 @@ function externalDot(graph, hidden) {
434
436
  // carries the bare name.
435
437
  const externals = new Set([...apisByPair.keys()].map((key) => key.split(PAIR_SEP)[1]));
436
438
  for (const external of [...externals].sort()) {
437
- dot +=
438
- ` "external__${(0, dot_syntax_1.dotValue)(external)}" [shape=box, style="dashed,filled", fillcolor="${runtime_viz_theme_1.EXTERNAL_FILL}", ` +
439
- `color="${runtime_viz_theme_1.EXTERNAL_BORDER}", label="${(0, dot_syntax_1.dotValue)(getShortName(external))}\\n(external)"];\n`;
439
+ dot += model.node(`external__${external}`, ` "external__${(0, dot_syntax_1.dotValue)(external)}" [shape=box, style="dashed,filled", fillcolor="${runtime_viz_theme_1.EXTERNAL_FILL}", ` +
440
+ `color="${runtime_viz_theme_1.EXTERNAL_BORDER}", label="${(0, dot_syntax_1.dotValue)(getShortName(external))}\\n(external)"];\n`);
440
441
  }
441
442
  for (const key of [...apisByPair.keys()].sort()) {
442
443
  const parts = key.split(PAIR_SEP);
@@ -445,19 +446,25 @@ function externalDot(graph, hidden) {
445
446
  const via = labelList(apisByPair.get(key).sort());
446
447
  // SOLID: this is a synchronous call that returns a value. Dashed is reserved for events, and
447
448
  // "outside the repo" is already said by the node's dashed border.
448
- dot +=
449
- ` "${serviceNodeId(service)}" -> "external__${(0, dot_syntax_1.dotValue)(external)}" ` +
450
- `[label="${via}", color="${runtime_viz_theme_1.EXTERNAL_BORDER}"];\n`;
449
+ dot += model.edge(service, `external__${external}`, ` "${serviceNodeId(service)}" -> "external__${(0, dot_syntax_1.dotValue)(external)}" ` +
450
+ `[label="${via}", color="${runtime_viz_theme_1.EXTERNAL_BORDER}"];\n`);
451
451
  }
452
452
  return dot;
453
453
  }
454
454
  /** Build the Graphviz DOT for the runtime service graph. */
455
455
  // webpieces-disable no-function-outside-class -- module entry point, matching the sibling builders here
456
456
  function generateRuntimeDot(graph, title = 'WebPieces Runtime Architecture', options = new RuntimeVizOptions()) {
457
+ return generateRuntimeRenderModel(graph, title, options).fullDot;
458
+ }
459
+ /** Capture synthetic drawable nodes and edges at their original emission sites. */
460
+ // webpieces-disable no-function-outside-class -- renderer entry point matching generateRuntimeDot
461
+ function generateRuntimeRenderModel(graph, title = 'WebPieces Runtime Architecture', options = new RuntimeVizOptions()) {
462
+ const model = new graph_render_model_1.GraphRenderModel();
457
463
  let dot = 'digraph RuntimeArchitecture {\n';
458
464
  dot += ' rankdir=TB;\n';
459
465
  dot += ' node [shape=box, style="filled,rounded", fontname="Arial"];\n';
460
466
  dot += ' edge [fontname="Arial", fontsize=10];\n\n';
467
+ model.header = dot;
461
468
  // Services tagged drawOnGraph:false stay in the JSON but are omitted here —
462
469
  // both their node and any edge touching them are dropped from the render.
463
470
  const hidden = new Set(Object.keys(graph.services).filter((name) => graph.services[name].drawOnGraph === false));
@@ -466,7 +473,7 @@ function generateRuntimeDot(graph, title = 'WebPieces Runtime Architecture', opt
466
473
  continue;
467
474
  const svc = graph.services[name];
468
475
  const color = runtime_viz_theme_1.LEVEL_COLORS[svc.level] || '#F5F5F5';
469
- dot += ` "${serviceNodeId(name)}" [fillcolor="${color}", label="${nodeLabel(name, svc)}"];\n`;
476
+ dot += model.node(name, ` "${serviceNodeId(name)}" [fillcolor="${color}", label="${nodeLabel(name, svc)}"];\n`);
470
477
  }
471
478
  dot += '\n';
472
479
  for (const edge of graph.runtimeEdges) {
@@ -475,14 +482,15 @@ function generateRuntimeDot(graph, title = 'WebPieces Runtime Architecture', opt
475
482
  // Queued hops are merged across methods, so they cannot be emitted one edge at a time.
476
483
  if (edge.type === 'pubsub')
477
484
  continue;
478
- dot += edgeDot(edge);
485
+ dot += model.edge(edge.from, edge.to, edgeDot(edge));
479
486
  }
480
- dot += queuesDot(graph, hidden);
481
- dot += triggerDot(graph, hidden, options);
487
+ dot += queuesDot(graph, hidden, model);
488
+ dot += triggerDot(graph, hidden, options, model);
482
489
  if (options.showExternalNodes) {
483
- dot += externalSystemsDot(graph, hidden);
484
- dot += externalDot(graph, hidden);
490
+ dot += externalSystemsDot(graph, hidden, model);
491
+ dot += externalDot(graph, hidden, model);
485
492
  }
493
+ const footerStart = dot.length;
486
494
  dot += '\n labelloc="t";\n';
487
495
  dot += ` label="${(0, dot_syntax_1.dotValue)(title)}\\n(from architecture/runtime-dependencies.json)";\n`;
488
496
  dot += ' fontsize=20;\n';
@@ -490,18 +498,22 @@ function generateRuntimeDot(graph, title = 'WebPieces Runtime Architecture', opt
490
498
  // Nothing downstream parses this DOT until a human opens the page, so parse-shape is checked
491
499
  // HERE — a graph that cannot render is a generation failure, not a blank page to discover later.
492
500
  (0, dot_syntax_1.assertValidDot)(dot, 'runtime-architecture.dot');
493
- return dot;
501
+ model.footer = dot.slice(footerStart);
502
+ model.completeEndpoints();
503
+ model.fullDot = dot;
504
+ return model;
494
505
  }
495
506
  /** Write the DOT + HTML renderings to tmp/webpieces/. */
496
507
  function writeRuntimeVisualization(graph, workspaceRoot, title = 'WebPieces Runtime Architecture', options = new RuntimeVizOptions()) {
497
508
  const outputDir = path.join(workspaceRoot, 'tmp', 'webpieces');
498
509
  if (!fs.existsSync(outputDir))
499
510
  fs.mkdirSync(outputDir, { recursive: true });
500
- const dot = generateRuntimeDot(graph, title, options);
511
+ const model = generateRuntimeRenderModel(graph, title, options);
512
+ const dot = model.fullDot;
501
513
  const dotPath = path.join(outputDir, 'runtime-architecture.dot');
502
514
  fs.writeFileSync(dotPath, dot, 'utf-8');
503
515
  const htmlPath = path.join(outputDir, 'runtime-architecture.html');
504
- fs.writeFileSync(htmlPath, new runtime_html_page_1.RuntimeHtmlPage().render(dot, title), 'utf-8');
516
+ fs.writeFileSync(htmlPath, new runtime_html_page_1.RuntimeHtmlPage().render(model, title), 'utf-8');
505
517
  return { dotPath, htmlPath };
506
518
  }
507
519
  //# sourceMappingURL=runtime-visualizer.js.map