@intentius/behold 0.29.15 → 0.29.17

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GitHub Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.15",
3
+ "version": "0.29.17",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
package/web/app.js CHANGED
@@ -21,7 +21,9 @@ import { renderLane } from "./terragucci-lifecycle.js";
21
21
  import { renderReleases } from "./releases.js";
22
22
  import { renderYodelLane } from "./sql-yodeler-lane.js";
23
23
  import { initTerragucciEstate } from "./terragucci-estate.js";
24
- import { componentMarks as tgComponentMarks, waveOfKey as tgWaveOfKey } from "./terragucci-components-live.js";
24
+ import { componentMarks as tgComponentMarks, runsCommit as tgRunsCommit, waveOfKey as tgWaveOfKey } from "./terragucci-components-live.js";
25
+ import { FORGES, MARK, componentsForge, forgeOfName, iconSvg, markLink, markTitle, waveJobUrl } from "./forge-mark.js";
26
+ import { TOOLS, orderTools } from "./tool-mark.js";
25
27
  import { STATE_WORDS as tgStateWords, changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLabel as tgReadLabel, readLine as tgReadLine, readTag as tgReadTag, rootStates as tgRootStates, viewCaption as tgViewCaption, pinnedAnswer as tgPinnedAnswer, pinnedCaption as tgPinnedCaption, pinsCommit as tgPinsCommit } from "./terragucci-change.js";
26
28
  import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
27
29
  import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
@@ -284,7 +286,7 @@ function recolorNodesByCategory(ir) {
284
286
  if (bar) bar.setAttribute("fill", "var(--runtime)");
285
287
  }
286
288
  }
287
- onThemeChange(() => recolorNodesByCategory());
289
+ onThemeChange(() => (recolorNodesByCategory(), drawTgReads())); // behold#571: drawTgReads redraws the marks in the theme's colours
288
290
 
289
291
  // #229, the one motion signature behold spends on data: a node whose drift
290
292
  // `_status` CHANGED between two renders pulses once in the colour it just
@@ -3493,6 +3495,7 @@ function drawTgReads() {
3493
3495
  watchTgRuns(); // #571: the wave boxes' jobs, while the components zoom is on screen
3494
3496
  const svg = document.querySelector("#graph svg");
3495
3497
  if (!svg) return;
3498
+ drawPipelineMarks(svg); // behold#571: the forge and tool marks, before the wave boxes' status finds room around them
3496
3499
  for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
3497
3500
  for (const old of svg.querySelectorAll("[data-tg-root]")) old.remove();
3498
3501
  for (const r of svg.querySelectorAll("rect[data-tg-stroke0]")) {
@@ -3722,8 +3725,9 @@ function drawTgReads() {
3722
3725
  };
3723
3726
  // Text to keep clear: every text drawn so far (cards, box names, badges,
3724
3727
  // facts, the checkout's own edge labels), a little grown.
3725
- const textBoxes = [...svg.querySelectorAll("text")]
3726
- .filter((t) => !t.closest("[data-tg-reads]") && t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim())
3728
+ // behold#571: and each wave box's forge and tool marks, icons too.
3729
+ const textBoxes = [...svg.querySelectorAll("text"), ...svg.querySelectorAll(".pipeline-marks")]
3730
+ .filter((t) => !t.closest("[data-tg-reads]") && t.getAttribute("visibility") !== "hidden" && t.style.display !== "none" && (t.textContent || "").trim())
3727
3731
  .map(boxOf)
3728
3732
  .filter(Boolean)
3729
3733
  .map((b) => grow(b, 3));
@@ -3796,6 +3800,7 @@ function drawTgReads() {
3796
3800
  drawn.push({ p0, p1 });
3797
3801
  labels.push({ e, p0, p1, color, kind });
3798
3802
  }
3803
+ clearMarksOfReads(svg, drawn); // behold#571: the marks step aside from the read lines
3799
3804
  // Each read's words beside its line: covering no text, crossing no line,
3800
3805
  // over as few cards and box headers as can be.
3801
3806
  const cardBoxes = cardEls.map((g) => cardBoxOf.get(g)).filter(Boolean);
@@ -3811,10 +3816,12 @@ function drawTgReads() {
3811
3816
  const dx = l.p1.x - l.p0.x;
3812
3817
  const dy = l.p1.y - l.p0.y;
3813
3818
  let best = null;
3814
- for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
3819
+ // behold#571: the label's own line may run under it; every other read line may not.
3820
+ const others = drawn.filter((d) => !(d.p0 === l.p0 && d.p1 === l.p1));
3821
+ for (const t of [0.5, 0.35, 0.65, 0.2, 0.8, 0.1, 0.9]) {
3815
3822
  const m = { x: l.p0.x + dx * t, y: l.p0.y + dy * t };
3816
- // Beside the line, then farther off it into open ground: the boxes sit close.
3817
- const spots = [];
3823
+ // On the line itself, slid along its width; then beside the line, then farther off it into open ground: the boxes sit close.
3824
+ const spots = [-0.35, -0.2, 0, 0.2, 0.35].map((f) => ({ x: m.x + f * w, y: m.y, off: 1 + Math.abs(f) * 4 }));
3818
3825
  for (let k = 0; k < 6; k++) {
3819
3826
  const off = k * (h + 6);
3820
3827
  spots.push(
@@ -3826,11 +3833,14 @@ function drawTgReads() {
3826
3833
  }
3827
3834
  for (const c of spots) {
3828
3835
  const r = { x: c.x - w / 2, y: c.y - h / 2, x2: c.x + w / 2, y2: c.y + h / 2 };
3829
- const lineHits = drawn.filter((d) => crosses(d.p0, d.p1, r)).length + edgeIn(r);
3836
+ const lineHits = others.filter((d) => crosses(d.p0, d.p1, r)).length;
3830
3837
  // Outside the boxes' own span the label may leave the drawing when the view is fitted: keep it in.
3838
+ // behold#571: never above the top box, where the caption says which run the marks are from.
3831
3839
  const span = union(boxes);
3832
- const out = span ? (r.x2 - r.x) * (r.y2 - r.y) - overlap(r, { x: span.x - 40, y: span.y - 60, x2: span.x2 + 40, y2: span.y2 + 40 }) : 0;
3833
- const score = out * 80 + lineHits * 1e6 + sum(r, textBoxes, 50) + sum(r, placed, 50) + sum(r, cardBoxes, 1) + sum(r, headers, 1.5) + sum(r, boxes, 0.15) + Math.abs(t - 0.5) * 300 + c.off * 4;
3840
+ const out = span ? (r.x2 - r.x) * (r.y2 - r.y) - overlap(r, { x: span.x - 40, y: span.y, x2: span.x2 + 40, y2: span.y2 + 40 }) : 0;
3841
+ // behold#571: a card or a box's title row under the words costs as much as text does, so a label sits on open ground between boxes.
3842
+ // A dependency line under a label costs less than a card or text does, and far less than another read's line.
3843
+ const score = out * 80 + lineHits * 1e6 + edgeIn(r) * 30 + Math.hypot(c.x - m.x, c.y - m.y) * 2 + sum(r, textBoxes, 50) + sum(r, placed, 50) + sum(r, cardBoxes, 50) + sum(r, headers, 40) + sum(r, boxes, 0.15) + Math.abs(t - 0.5) * 300 + c.off * 4;
3834
3844
  if (!best || score < best.score) best = { c, r, score };
3835
3845
  }
3836
3846
  }
@@ -3928,7 +3938,7 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
3928
3938
  const hits = (a, b) => a.x < b.x2 && a.x2 > b.x && a.y < b.y2 && a.y2 > b.y;
3929
3939
  const textBoxes = () =>
3930
3940
  [...svg.querySelectorAll("text")]
3931
- .filter((t) => t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim() && !t.closest("[data-tg-reads]"))
3941
+ .filter((t) => t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim() && !t.closest("[data-tg-reads]") && !t.closest(".pipeline-marks"))
3932
3942
  .map(boxOf)
3933
3943
  .filter(Boolean);
3934
3944
  const cardEls = [...svg.querySelectorAll("[data-node-id]")];
@@ -4008,6 +4018,10 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
4008
4018
  const tb = title ? boxOf(title) : null;
4009
4019
  const others = () => [...textBoxes(), ...placed].filter((x) => !(tb && sameBox(x, tb)));
4010
4020
  const clear = (r) => r.x >= b.x + 6 && r.x2 <= b.x2 - 6 && !edgeHit(r) && !others().some((x) => hits(grow(x, 2), r)) && !cardBoxes.some((c) => hits(grow(c, 4), r));
4021
+ // behold#571: the wave's forge and tool marks (drawPipelineMarks) left the job's status its room after the title.
4022
+ const row = svg.querySelector(`.pipeline-marks[data-pm-wave="${n}"]`);
4023
+ const rb = row && row.style.display !== "none" ? boxOf(row) : null;
4024
+ if (rb) placed.push(rb);
4011
4025
  // The job's status: after the title on its line, else at the right of that line.
4012
4026
  if (w.status && tb) {
4013
4027
  const size = 12;
@@ -5448,36 +5462,371 @@ function wireEdgeHighlight(svgEl, ir) {
5448
5462
  }
5449
5463
  }
5450
5464
 
5451
- // GitLab's tanuki, one filled silhouette (the widely-used single-path mark),
5452
- // scaled into a small badge. Self-contained (no external URL) so it survives the
5453
- // static export + the CSP.
5454
- const GITLAB_TANUKI =
5455
- "M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z";
5456
5465
  const SVGNS = "http://www.w3.org/2000/svg";
5457
5466
 
5458
- // Stamp a GitLab tanuki in the top-right corner of each wave lane — a visual cue
5459
- // that the waves ARE the GitLab CI pipeline's stages (job `stage` = `wave-N`).
5460
- // Post-processes the inlined SVG: each wave box is a `<rect>` immediately
5461
- // followed by its `<text>wave-N</text>` title, so the rect gives the corner.
5462
- function addGitlabWaveBadges(svgEl) {
5463
- const SIZE = 17;
5464
- for (const text of svgEl.querySelectorAll("text")) {
5465
- if (!/^wave-/i.test((text.textContent || "").trim())) continue;
5466
- const rect = text.previousElementSibling;
5467
- if (!rect || rect.tagName.toLowerCase() !== "rect") continue;
5468
- const rx = parseFloat(rect.getAttribute("x"));
5469
- const ry = parseFloat(rect.getAttribute("y"));
5470
- const rw = parseFloat(rect.getAttribute("width"));
5471
- const g = document.createElementNS(SVGNS, "g");
5472
- g.setAttribute("class", "gitlab-wave-badge");
5473
- g.setAttribute("transform", `translate(${rx + rw - SIZE - 12}, ${ry + 10}) scale(${SIZE / 24})`);
5474
- const path = document.createElementNS(SVGNS, "path");
5475
- path.setAttribute("d", GITLAB_TANUKI);
5476
- path.setAttribute("fill", "#FC6D26"); // GitLab brand orange
5477
- const title = document.createElementNS(SVGNS, "title");
5478
- title.textContent = "This wave is a GitLab CI stage (chant → GitLab pipeline)";
5479
- g.append(title, path);
5480
- rect.parentNode.insertBefore(g, text);
5467
+ // behold#571: the marks on each wave box's title row of the Components view,
5468
+ // so the CI projection says which forge runs each wave and which tools its
5469
+ // job runs: the forge's mark (linked to the wave's job when a report carried
5470
+ // its URL), then each tool's (web/forge-mark.js, web/tool-mark.js). Each is
5471
+ // a small icon and the name beside it. Right-aligned in the row after the
5472
+ // title; when the row is short the icons go first, then the marks from the
5473
+ // end, so a name stays. On chant's own GitLab-generated projection the row
5474
+ // holds the forge chant generated for (the tanuki since #58).
5475
+ const MARK_STEP = 6; // between two marks in a row
5476
+
5477
+ /** What a wave's row says: its forge, its job and its tools; null for no row. */
5478
+ function pipelineMarksFor(n) {
5479
+ if (!view.components) return null;
5480
+ if (terragucciComponentsRepo && tgComponentFacets) {
5481
+ const jobs = (tgComponentFacets.jobs || []).filter((j) => j.wave === n);
5482
+ // The repo's tools, and what the wave's roots carry inside them (a SQL Yodeler project under app/).
5483
+ const roots = jobs.map((j) => (tgComponentFacets.roots && tgComponentFacets.roots[j.component]) || j.component);
5484
+ const carried = roots.flatMap((r) => (tgComponentFacets.rootTools && tgComponentFacets.rootTools[r]) || []);
5485
+ return { forge: componentsForge(tgComponentFacets, tgAnswer), job: jobs[0] ? jobs[0].jobName : null, tools: orderTools([...(tgComponentFacets.tools || []), ...carried]) };
5486
+ }
5487
+ if (ciByComponent.size) return { forge: forgeOfName(ciForge), job: null, tools: [], projection: true };
5488
+ return null;
5489
+ }
5490
+
5491
+ /** One mark: an icon and the name, with its tooltip; an `<a>` for the forge so it can link. */
5492
+ function markElement(tag, cls, spec, name, tip, light) {
5493
+ const el = document.createElementNS(SVGNS, tag);
5494
+ el.setAttribute("class", `pm-mark ${cls}`);
5495
+ const title = document.createElementNS(SVGNS, "title");
5496
+ title.textContent = tip;
5497
+ el.appendChild(title);
5498
+ if (spec) {
5499
+ const icon = iconSvg(document, spec, MARK.size, { light });
5500
+ icon.setAttribute("class", "pm-icon");
5501
+ el.appendChild(icon);
5502
+ }
5503
+ const text = document.createElementNS(SVGNS, "text");
5504
+ text.setAttribute("class", "pm-label");
5505
+ text.setAttribute("font-size", String(MARK.font));
5506
+ text.setAttribute("font-weight", "600");
5507
+ text.setAttribute("fill", "var(--muted)");
5508
+ text.setAttribute("dominant-baseline", "central");
5509
+ text.textContent = name;
5510
+ el.appendChild(text);
5511
+ return el;
5512
+ }
5513
+
5514
+ /** Each mark's label width, measured. */
5515
+ function markWidths(marks) {
5516
+ return marks.map((m) => {
5517
+ const label = m.querySelector(".pm-label");
5518
+ try {
5519
+ return label.getComputedTextLength() || label.textContent.length * MARK.font * 0.6;
5520
+ } catch {
5521
+ return label.textContent.length * MARK.font * 0.6;
5522
+ }
5523
+ });
5524
+ }
5525
+
5526
+ /** Put a row's marks at `xs` (each mark's left), `icons` with or without their icon; marks past `k` hidden. */
5527
+ function placeMarks(row, marks, widths, xs, icons, k, cys = null) {
5528
+ const cy0 = Number(row.getAttribute("data-pm-cy"));
5529
+ const show = (n, on) => (n.style.display = on ? "" : "none");
5530
+ show(row, k > 0);
5531
+ marks.forEach((m, i) => {
5532
+ show(m, i < k);
5533
+ if (i >= k) return;
5534
+ const icon = m.querySelector(".pm-icon");
5535
+ const label = m.querySelector(".pm-label");
5536
+ const cy = cys ? cys[i] : cy0;
5537
+ let x = xs[i];
5538
+ if (icon) show(icon, icons);
5539
+ if (icon && icons) {
5540
+ icon.setAttribute("x", x.toFixed(1));
5541
+ icon.setAttribute("y", (cy - MARK.size / 2).toFixed(1));
5542
+ x += MARK.size + MARK.gap;
5543
+ }
5544
+ label.setAttribute("x", x.toFixed(1));
5545
+ label.setAttribute("y", cy.toFixed(1));
5546
+ });
5547
+ }
5548
+
5549
+ /** One mark's width, with or without its icon. */
5550
+ const markWidth = (m, w, icons) => w + (icons && m.querySelector(".pm-icon") ? MARK.size + MARK.gap : 0);
5551
+
5552
+ /**
5553
+ * The title row: every mark with its icon, right-aligned in the rightmost
5554
+ * gap between `left` and the box's right edge that the edges crossing the
5555
+ * row (`data-pm-avoid`) leave. True when they fit; else nothing is placed.
5556
+ */
5557
+ function titleRowFits(row, left) {
5558
+ const right = Number(row.getAttribute("data-pm-right"));
5559
+ const marks = [...row.querySelectorAll(":scope > .pm-mark")];
5560
+ const widths = markWidths(marks);
5561
+ const need = marks.reduce((s, m, i) => s + markWidth(m, widths[i], true), 0) + MARK_STEP * (marks.length - 1);
5562
+ const cuts = (row.getAttribute("data-pm-avoid") || "").split(",").filter(Boolean).map(Number).filter((x) => x > left && x < right).sort((a, b) => a - b);
5563
+ const gaps = [];
5564
+ let at = left;
5565
+ for (const x of cuts) {
5566
+ if (x - 5 > at) gaps.push([at, x - 5]);
5567
+ at = Math.max(at, x + 5);
5568
+ }
5569
+ if (right > at) gaps.push([at, right]);
5570
+ const gap = gaps.reverse().find(([a, z]) => z - a >= need);
5571
+ if (!gap) return false;
5572
+ const xs = [];
5573
+ let x = gap[1] - need;
5574
+ marks.forEach((m, i) => {
5575
+ xs.push(x);
5576
+ x += markWidth(m, widths[i], true) + MARK_STEP;
5577
+ });
5578
+ placeMarks(row, marks, widths, xs, true, marks.length);
5579
+ row.setAttribute("data-pm-mode", "title");
5580
+ return true;
5581
+ }
5582
+
5583
+ /**
5584
+ * The marks' own row, just under the title: the box grows upward by a row
5585
+ * and its title moves up into it, so no card moves and none is covered. The
5586
+ * marks run left to right from the box's left, stepping past an edge where
5587
+ * they can, and keep their icons: short of room they run onto another row,
5588
+ * and the box grows by that one too. Only at phone width do the icons go
5589
+ * first, while one row of names alone holds them.
5590
+ */
5591
+ function marksOwnRow(row, rect, title, grown = 0) {
5592
+ const G = MARK.size + 6;
5593
+ const left = Number(row.getAttribute("data-pm-start"));
5594
+ const right = Number(row.getAttribute("data-pm-end"));
5595
+ const cy = Number(row.getAttribute("data-pm-cy"));
5596
+ const avoid = (row.getAttribute("data-pm-avoid") || "").split(",").filter(Boolean).map(Number);
5597
+ const marks = [...row.querySelectorAll(":scope > .pm-mark")];
5598
+ const widths = markWidths(marks);
5599
+ // Left to right in rows, a mark that would sit on an edge stepping past it.
5600
+ const lay = (icons, step, maxRows) => {
5601
+ const xs = [];
5602
+ const rowOf = [];
5603
+ let r = 0;
5604
+ let x = left;
5605
+ for (let i = 0; i < marks.length; i++) {
5606
+ const w = markWidth(marks[i], widths[i], icons);
5607
+ const at = (x0) => {
5608
+ let p = x0;
5609
+ if (step) for (const e of avoid.filter((e) => e > p - 4 && e < p + w + 4).sort((a, b) => a - b)) p = Math.max(p, e + 6);
5610
+ return p;
5611
+ };
5612
+ let p = at(x);
5613
+ if (p + w > right && x > left) {
5614
+ r++;
5615
+ x = left;
5616
+ p = at(x);
5617
+ }
5618
+ if (p + w > right || r >= maxRows) return null;
5619
+ xs.push(p);
5620
+ rowOf.push(r);
5621
+ x = p + w + MARK_STEP;
5622
+ }
5623
+ return { xs, rowOf, rows: r + 1 };
5624
+ };
5625
+ // Phone width is the viewer's screen: the kiosk frames this page narrow on a wide one.
5626
+ const phone = (() => {
5627
+ try {
5628
+ return (window.top || window).innerWidth <= 600;
5629
+ } catch {
5630
+ return window.innerWidth <= 600;
5631
+ }
5632
+ })();
5633
+ // A second pass (`grown`, the rows the box already grew by) keeps within those rows.
5634
+ const most = grown || marks.length;
5635
+ const tries = [[true, true, 1], [true, false, 1]];
5636
+ if (phone) tries.push([false, true, 1], [false, false, 1]);
5637
+ tries.push([true, true, most], [true, false, most], [false, true, most], [false, false, most]);
5638
+ let fit = null;
5639
+ for (const [icons, step, maxRows] of tries) {
5640
+ const l = lay(icons, step, maxRows);
5641
+ if (l) {
5642
+ fit = { ...l, icons };
5643
+ break;
5644
+ }
5645
+ }
5646
+ row.setAttribute("data-pm-mode", "row");
5647
+ if (!fit) return grown ? false : placeMarks(row, marks, widths, [], true, 0);
5648
+ const rows = grown || fit.rows;
5649
+ if (!grown) {
5650
+ const grow = G * rows;
5651
+ rect.setAttribute("y", String(Number(rect.getAttribute("y")) - grow));
5652
+ rect.setAttribute("height", String(Number(rect.getAttribute("height")) + grow));
5653
+ if (title) title.setAttribute("y", String(Number(title.getAttribute("y")) - grow));
5654
+ row.setAttribute("data-pm-rows", String(rows));
5655
+ }
5656
+ // The last row stands where the title stood, just over the cards; the others above it.
5657
+ const cys = fit.rowOf.map((r) => cy - (rows - 1 - r) * G);
5658
+ placeMarks(row, marks, widths, fit.xs, fit.icons, marks.length, cys);
5659
+ return true;
5660
+ }
5661
+
5662
+ /**
5663
+ * Once the reads are drawn: each wave box's marks stand clear of the read
5664
+ * lines that cross their row too, where the row has room; else they stay.
5665
+ */
5666
+ function clearMarksOfReads(svgEl, lines) {
5667
+ for (const row of svgEl.querySelectorAll(".pipeline-marks")) {
5668
+ if (row.style.display === "none") continue;
5669
+ const m = /translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/.exec((row.parentNode.getAttribute && row.parentNode.getAttribute("transform")) || "");
5670
+ const dx = m ? Number(m[1]) : 0;
5671
+ const dy = m ? Number(m[2]) : 0;
5672
+ const G = MARK.size + 6;
5673
+ const cy = Number(row.getAttribute("data-pm-cy"));
5674
+ const own = row.getAttribute("data-pm-mode") === "row";
5675
+ const top = cy - (own ? (Number(row.getAttribute("data-pm-rows")) || 1) * G : 0) - MARK.size / 2 - 2 + dy;
5676
+ const bottom = cy + MARK.size / 2 + 2 + dy;
5677
+ const xs = [];
5678
+ for (const { p0, p1 } of lines) {
5679
+ if (Math.max(p0.y, p1.y) < top || Math.min(p0.y, p1.y) > bottom) continue;
5680
+ for (const y of [top, (top + bottom) / 2, bottom]) {
5681
+ const f = p1.y === p0.y ? 0.5 : (y - p0.y) / (p1.y - p0.y);
5682
+ if (f >= 0 && f <= 1) xs.push(p0.x + (p1.x - p0.x) * f - dx);
5683
+ }
5684
+ }
5685
+ if (!xs.length) continue;
5686
+ const was = row.getAttribute("data-pm-avoid") || "";
5687
+ row.setAttribute("data-pm-avoid", [was, ...xs.map((x) => x.toFixed(1))].filter(Boolean).join(","));
5688
+ // The old places, should the new avoidance not fit.
5689
+ const keep = [...row.querySelectorAll("[x], [y]")].map((n) => [n, n.getAttribute("x"), n.getAttribute("y"), n.style.display]);
5690
+ const ok = own ? marksOwnRow(row, null, null, Number(row.getAttribute("data-pm-rows")) || 1) : titleRowFits(row, Number(row.getAttribute("data-pm-left")));
5691
+ if (!ok) {
5692
+ row.setAttribute("data-pm-avoid", was);
5693
+ for (const [n, x, y, d] of keep) {
5694
+ if (x !== null) n.setAttribute("x", x);
5695
+ if (y !== null) n.setAttribute("y", y);
5696
+ n.style.display = d;
5697
+ }
5698
+ }
5699
+ }
5700
+ }
5701
+
5702
+ /** The x's at which the graph's edges cross a band of the svg (`y0`..`y1`), in the svg's own coordinates. */
5703
+ function edgeXsIn(svgEl, y0, y1) {
5704
+ const xs = [];
5705
+ for (const path of svgEl.querySelectorAll("g[data-edge-from] path, g[data-edge-from] line")) {
5706
+ let len = 0;
5707
+ try {
5708
+ len = path.getTotalLength();
5709
+ } catch {
5710
+ continue;
5711
+ }
5712
+ for (let d = 0; d <= len; d += 3) {
5713
+ const p = path.getPointAtLength(d);
5714
+ if (p.y >= y0 && p.y <= y1) xs.push(p.x);
5715
+ }
5716
+ }
5717
+ return xs;
5718
+ }
5719
+
5720
+ /** The width of the job's status words a terragucci wave box will say after its title, or 0. */
5721
+ function waveStatusRoom(svgEl, n) {
5722
+ if (!(terragucciComponentsRepo && tgComponentFacets && tgAnswer && !tgAnswer.error)) return 0;
5723
+ let w;
5724
+ try {
5725
+ w = tgComponentMarks(tgAnswerDrawn(), tgProgress, tgComponentFacets, { runs: tgRuns, pin: view.run || null }).waves[n];
5726
+ } catch {
5727
+ return 0;
5728
+ }
5729
+ if (!w || !w.status) return 0;
5730
+ const probe = document.createElementNS(SVGNS, "text");
5731
+ probe.setAttribute("font-size", "12");
5732
+ probe.setAttribute("font-weight", "700");
5733
+ probe.textContent = `· ${w.status.words}`;
5734
+ svgEl.appendChild(probe);
5735
+ let len = 0;
5736
+ try {
5737
+ len = probe.getComputedTextLength();
5738
+ } catch {
5739
+ len = probe.textContent.length * 7;
5740
+ }
5741
+ probe.remove();
5742
+ return len + 6 + 10;
5743
+ }
5744
+
5745
+ /** Draw every wave box's row afresh: in the title row when the marks fit there with their icons, else in their own row under it. */
5746
+ function drawPipelineMarks(svgEl) {
5747
+ for (const old of svgEl.querySelectorAll(".pipeline-marks")) old.remove();
5748
+ // A box an earlier draw grew for its marks' row goes back as pinhole drew it.
5749
+ for (const r of svgEl.querySelectorAll("rect[data-pm-y0]")) {
5750
+ r.setAttribute("y", r.getAttribute("data-pm-y0"));
5751
+ r.setAttribute("height", r.getAttribute("data-pm-h0"));
5752
+ r.removeAttribute("data-pm-y0");
5753
+ r.removeAttribute("data-pm-h0");
5754
+ }
5755
+ for (const tx of svgEl.querySelectorAll("text[data-pm-ty0]")) {
5756
+ tx.setAttribute("y", tx.getAttribute("data-pm-ty0"));
5757
+ tx.removeAttribute("data-pm-ty0");
5758
+ }
5759
+ const light = kioskLight();
5760
+ const pin = view.run || null;
5761
+ const commit = tgRunsCommit(pin, tgProgress);
5762
+ for (const rect of svgEl.querySelectorAll("rect[data-group-id]")) {
5763
+ const key = rect.getAttribute("data-group-id");
5764
+ const n = tgWaveOfKey(key);
5765
+ if (n === null) continue;
5766
+ const what = pipelineMarksFor(n);
5767
+ if (!what) continue;
5768
+ const forge = what.forge && FORGES[what.forge] ? what.forge : null;
5769
+ const tools = what.tools.filter((t) => TOOLS[t]);
5770
+ if (!forge && !tools.length) continue;
5771
+ // The box's title: the text right after its rect (pinhole's order), inside the box's group once wrapped.
5772
+ let title = null;
5773
+ for (let el = rect.nextElementSibling; el; el = el.nextElementSibling) {
5774
+ if (el.tagName.toLowerCase() === "text") {
5775
+ title = el;
5776
+ break;
5777
+ }
5778
+ if (el.tagName.toLowerCase() === "rect") break;
5779
+ }
5780
+ const rx = Number(rect.getAttribute("x"));
5781
+ const ry = Number(rect.getAttribute("y"));
5782
+ const rw = Number(rect.getAttribute("width"));
5783
+ let tb = null;
5784
+ try {
5785
+ tb = title ? title.getBBox() : null;
5786
+ } catch {
5787
+ tb = null;
5788
+ }
5789
+ const cy = tb && tb.height ? tb.y + tb.height / 2 : ry + 10 + MARK.size / 2;
5790
+ const row = document.createElementNS(SVGNS, "g");
5791
+ row.setAttribute("class", "pipeline-marks");
5792
+ row.setAttribute("data-pm-wave", String(n));
5793
+ row.setAttribute("data-pm-right", String(rx + rw - MARK.pad));
5794
+ row.setAttribute("data-pm-cy", String(cy));
5795
+ row.setAttribute("data-pm-start", String(rx + 12));
5796
+ row.setAttribute("data-pm-end", String(rx + rw - 10));
5797
+ if (forge) {
5798
+ const f = FORGES[forge];
5799
+ const url = what.projection ? null : markLink(waveJobUrl(tgAnswer, n, commit), forge);
5800
+ const tip = what.projection ? `${key} is a ${f.label} CI stage of the pipeline chant generates` : markTitle(what.job, forge);
5801
+ const a = markElement("a", `pm-forge pm-forge-${forge}`, f, f.label, tip, light);
5802
+ if (url) {
5803
+ a.setAttribute("href", url);
5804
+ a.setAttribute("target", "_blank");
5805
+ a.setAttribute("rel", "noopener noreferrer");
5806
+ }
5807
+ row.appendChild(a);
5808
+ }
5809
+ for (const t of tools) {
5810
+ const tool = TOOLS[t];
5811
+ row.appendChild(markElement("g", `pm-tool pm-tool-${t}`, tool.icon, tool.name, what.job ? `${what.job} runs ${tool.name}` : `Runs ${tool.name}`, light));
5812
+ }
5813
+ // The edges that run through the title row, so the marks stand clear of them. Pinhole draws edges
5814
+ // in the svg's coordinates and a moved box carries a translate, so their x's are taken back into the box's.
5815
+ const shift = (() => {
5816
+ const m = /translate\(\s*([-\d.]+)/.exec((rect.parentNode.getAttribute && rect.parentNode.getAttribute("transform")) || "");
5817
+ return m ? Number(m[1]) : 0;
5818
+ })();
5819
+ const band = edgeXsIn(svgEl, cy - MARK.size / 2 - 2, cy + MARK.size / 2 + 2).map((x) => x - shift);
5820
+ row.setAttribute("data-pm-avoid", band.filter((x) => x >= rx && x <= rx + rw).map((x) => x.toFixed(1)).join(","));
5821
+ rect.parentNode.insertBefore(row, title || rect.nextSibling);
5822
+ // The title row holds the title, the job's status after it, then the marks; else the marks take their own row.
5823
+ const left = (tb && tb.width ? tb.x + tb.width : rx + 12) + 8 + waveStatusRoom(svgEl, n);
5824
+ row.setAttribute("data-pm-left", String(left));
5825
+ if (titleRowFits(row, left)) continue;
5826
+ rect.setAttribute("data-pm-y0", rect.getAttribute("y"));
5827
+ rect.setAttribute("data-pm-h0", rect.getAttribute("height"));
5828
+ if (title) title.setAttribute("data-pm-ty0", title.getAttribute("y"));
5829
+ marksOwnRow(row, rect, title);
5481
5830
  }
5482
5831
  }
5483
5832
 
@@ -5603,10 +5952,6 @@ function render(ir, svg, m) {
5603
5952
  if (embedOpts.bare) quietCardLines(svgEl);
5604
5953
  setupGraphViewBox(svgEl);
5605
5954
  wireEdgeHighlight(svgEl, ir);
5606
- // Mark each wave lane as a GitLab CI stage when the CI projection is loaded
5607
- // (components view only) — waves ARE the pipeline's stages (`chant build
5608
- // --components --generate gitlab`, see loadCi / #58).
5609
- if (view.components && ciByComponent.size && !terragucciComponentsRepo) addGitlabWaveBadges(svgEl);
5610
5955
  }
5611
5956
  ensureZoomControls(g);
5612
5957
  ensureBackToInfra(g);