@intentius/behold 0.29.16 → 0.29.18

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/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
@@ -3487,12 +3489,16 @@ function paintTgProgress() {
3487
3489
  // says which run all of it comes from. `view.focus` brings some roots forward
3488
3490
  // and dims the rest. From the pull request's plan, or from the run view once
3489
3491
  // merged. Not live; redrawn on each render and each read.
3490
- const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
3492
+ // behold#571: the state colours as the frame draws them on cards and boxes, each legible on every ground (theme.js frameInks).
3493
+ const TG_TONE = { plan: "var(--frame-pending, var(--pending))", wait: "var(--frame-foreign, var(--foreign))", busy: "var(--frame-runtime, var(--runtime))", ok: "var(--frame-managed, var(--managed))", bad: "var(--frame-degraded, var(--degraded))", idle: "var(--frame-neutral, var(--neutral))" };
3494
+ /** The same, for words on a card: read on the card's own ground. */
3495
+ const TG_CARD_TONE = Object.fromEntries(Object.entries(TG_TONE).map(([k, v]) => [k, v.replace("--frame-", "--frame-card-")]));
3491
3496
  function drawTgReads() {
3492
3497
  if (embedOpts.bare) queueMicrotask(kioskDecorate); // behold#571: once this draw is done
3493
3498
  watchTgRuns(); // #571: the wave boxes' jobs, while the components zoom is on screen
3494
3499
  const svg = document.querySelector("#graph svg");
3495
3500
  if (!svg) return;
3501
+ drawPipelineMarks(svg); // behold#571: the forge and tool marks, before the wave boxes' status finds room around them
3496
3502
  for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
3497
3503
  for (const old of svg.querySelectorAll("[data-tg-root]")) old.remove();
3498
3504
  for (const r of svg.querySelectorAll("rect[data-tg-stroke0]")) {
@@ -3722,8 +3728,9 @@ function drawTgReads() {
3722
3728
  };
3723
3729
  // Text to keep clear: every text drawn so far (cards, box names, badges,
3724
3730
  // 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())
3731
+ // behold#571: and each wave box's forge and tool marks, icons too.
3732
+ const textBoxes = [...svg.querySelectorAll("text"), ...svg.querySelectorAll(".pipeline-marks")]
3733
+ .filter((t) => !t.closest("[data-tg-reads]") && t.getAttribute("visibility") !== "hidden" && t.style.display !== "none" && (t.textContent || "").trim())
3727
3734
  .map(boxOf)
3728
3735
  .filter(Boolean)
3729
3736
  .map((b) => grow(b, 3));
@@ -3744,7 +3751,7 @@ function drawTgReads() {
3744
3751
  };
3745
3752
  const edgeIn = (r) => edgePoints.filter((p) => p.x >= r.x - 3 && p.x <= r.x2 + 3 && p.y >= r.y - 3 && p.y <= r.y2 + 3).length;
3746
3753
  const defs = svg.querySelector("defs") || svg.insertBefore(make("defs", {}), svg.firstChild);
3747
- for (const [id, color] of [["tg-arrow-unknown", "var(--foreign)"], ["tg-arrow-planned", "var(--pending)"], ["tg-arrow-applied", "var(--focus)"]]) {
3754
+ for (const [id, color] of [["tg-arrow-unknown", "var(--frame-foreign, var(--foreign))"], ["tg-arrow-planned", "var(--frame-pending, var(--pending))"], ["tg-arrow-applied", "var(--frame-focus, var(--focus))"]]) {
3748
3755
  if (svg.querySelector(`#${id}`)) continue;
3749
3756
  const m = make("marker", { id, viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse", "data-tg-reads": "marker" });
3750
3757
  m.appendChild(make("path", { d: "M0,0 L10,5 L0,10 z", fill: color }));
@@ -3755,7 +3762,7 @@ function drawTgReads() {
3755
3762
  for (const e of edges) {
3756
3763
  const card = tgReadCardIds(tgAnswer, e).map((id) => svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]')).find(Boolean);
3757
3764
  const kind = e.unknown.length ? "unknown" : e.outputs === "planned" ? "planned" : "applied";
3758
- const color = kind === "unknown" ? "var(--foreign)" : kind === "planned" ? "var(--pending)" : "var(--focus)";
3765
+ const color = kind === "unknown" ? "var(--frame-foreign, var(--foreign))" : kind === "planned" ? "var(--frame-pending, var(--pending))" : "var(--frame-focus, var(--focus))";
3759
3766
  if (card) {
3760
3767
  // The tag on the reader's card, at its bottom right, clear of the card's own "root:" line.
3761
3768
  const rect = card.querySelector("rect");
@@ -3796,6 +3803,11 @@ function drawTgReads() {
3796
3803
  drawn.push({ p0, p1 });
3797
3804
  labels.push({ e, p0, p1, color, kind });
3798
3805
  }
3806
+ // behold#571: the marks step aside from the read lines, and a box lifts its marks off an arrowhead (its box then reaches higher).
3807
+ for (const [rect] of clearMarksOfReads(svg, drawn)) {
3808
+ const now = boxOf(rect);
3809
+ for (const b of boxes) if (now && Math.abs(b.x - now.x) < 0.5 && Math.abs(b.x2 - now.x2) < 0.5 && Math.abs(b.y2 - now.y2) < 0.5) b.y = now.y;
3810
+ }
3799
3811
  // Each read's words beside its line: covering no text, crossing no line,
3800
3812
  // over as few cards and box headers as can be.
3801
3813
  const cardBoxes = cardEls.map((g) => cardBoxOf.get(g)).filter(Boolean);
@@ -3811,10 +3823,12 @@ function drawTgReads() {
3811
3823
  const dx = l.p1.x - l.p0.x;
3812
3824
  const dy = l.p1.y - l.p0.y;
3813
3825
  let best = null;
3814
- for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
3826
+ // behold#571: the label's own line may run under it; every other read line may not.
3827
+ const others = drawn.filter((d) => !(d.p0 === l.p0 && d.p1 === l.p1));
3828
+ for (const t of [0.5, 0.35, 0.65, 0.2, 0.8, 0.1, 0.9]) {
3815
3829
  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 = [];
3830
+ // On the line itself, slid along its width; then beside the line, then farther off it into open ground: the boxes sit close.
3831
+ 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
3832
  for (let k = 0; k < 6; k++) {
3819
3833
  const off = k * (h + 6);
3820
3834
  spots.push(
@@ -3826,11 +3840,14 @@ function drawTgReads() {
3826
3840
  }
3827
3841
  for (const c of spots) {
3828
3842
  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);
3843
+ const lineHits = others.filter((d) => crosses(d.p0, d.p1, r)).length;
3830
3844
  // Outside the boxes' own span the label may leave the drawing when the view is fitted: keep it in.
3845
+ // behold#571: never above the top box, where the caption says which run the marks are from.
3831
3846
  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;
3847
+ 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;
3848
+ // 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.
3849
+ // A dependency line under a label costs less than a card or text does, and far less than another read's line.
3850
+ 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
3851
  if (!best || score < best.score) best = { c, r, score };
3835
3852
  }
3836
3853
  }
@@ -3928,7 +3945,7 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
3928
3945
  const hits = (a, b) => a.x < b.x2 && a.x2 > b.x && a.y < b.y2 && a.y2 > b.y;
3929
3946
  const textBoxes = () =>
3930
3947
  [...svg.querySelectorAll("text")]
3931
- .filter((t) => t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim() && !t.closest("[data-tg-reads]"))
3948
+ .filter((t) => t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim() && !t.closest("[data-tg-reads]") && !t.closest(".pipeline-marks"))
3932
3949
  .map(boxOf)
3933
3950
  .filter(Boolean);
3934
3951
  const cardEls = [...svg.querySelectorAll("[data-node-id]")];
@@ -3980,7 +3997,7 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
3980
3997
  const words = width(m.words, 12, 700) <= room ? m.words : m.short;
3981
3998
  sub.setAttribute("visibility", "hidden");
3982
3999
  sub.setAttribute("data-tg-hid", "1");
3983
- const t = make("text", { "data-tg-root": "card", x, y, "font-size": 12, "font-weight": 700, fill: quiet ? "var(--muted)" : tone(m.tone) }, words);
4000
+ const t = make("text", { "data-tg-root": "card", x, y, "font-size": 12, "font-weight": 700, fill: quiet ? "var(--frame-card-muted, var(--muted))" : TG_CARD_TONE[m.tone] || TG_CARD_TONE.idle }, words);
3984
4001
  if (m.state === "applying") t.setAttribute("class", "pin-pulse");
3985
4002
  if (!inFocus) t.style.opacity = "0.6";
3986
4003
  t.appendChild(make("title", {}, [`wave ${m.wave} · ${m.words}`, ...m.facts].join("\n") + "\n(from terragucci's reports, not live)"));
@@ -4008,6 +4025,10 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
4008
4025
  const tb = title ? boxOf(title) : null;
4009
4026
  const others = () => [...textBoxes(), ...placed].filter((x) => !(tb && sameBox(x, tb)));
4010
4027
  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));
4028
+ // behold#571: the wave's forge and tool marks (drawPipelineMarks) left the job's status its room after the title.
4029
+ const row = svg.querySelector(`.pipeline-marks[data-pm-wave="${n}"]`);
4030
+ const rb = row && row.style.display !== "none" ? boxOf(row) : null;
4031
+ if (rb) placed.push(rb);
4011
4032
  // The job's status: after the title on its line, else at the right of that line.
4012
4033
  if (w.status && tb) {
4013
4034
  const size = 12;
@@ -4079,7 +4100,7 @@ function markTerragucciCards() {
4079
4100
  tag.setAttribute("text-anchor", "end");
4080
4101
  tag.setAttribute("font-size", "11");
4081
4102
  tag.setAttribute("font-weight", "600");
4082
- tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
4103
+ tag.setAttribute("fill", corner.tone === "drift" ? "var(--frame-card-degraded, var(--degraded))" : corner.tone === "wave" ? "var(--frame-card-pending, var(--pending))" : "var(--frame-card-muted, var(--muted))");
4083
4104
  tag.textContent = `${corner.text} · ${markAge(marks)}`;
4084
4105
  const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
4085
4106
  title.setAttribute("data-tg-mark", "title");
@@ -5448,36 +5469,455 @@ function wireEdgeHighlight(svgEl, ir) {
5448
5469
  }
5449
5470
  }
5450
5471
 
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
5472
  const SVGNS = "http://www.w3.org/2000/svg";
5457
5473
 
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);
5474
+ // behold#571: the marks on each wave box's title row of the Components view,
5475
+ // so the CI projection says which forge runs each wave and which tools its
5476
+ // job runs: the forge's mark (linked to the wave's job when a report carried
5477
+ // its URL), then each tool's (web/forge-mark.js, web/tool-mark.js). Each is
5478
+ // a small icon and the name beside it. Right-aligned in the row after the
5479
+ // title; when the row is short the icons go first, then the marks from the
5480
+ // end, so a name stays. On chant's own GitLab-generated projection the row
5481
+ // holds the forge chant generated for (the tanuki since #58).
5482
+ const MARK_STEP = 6; // between two marks in a row
5483
+
5484
+ /** What a wave's row says: its forge, its job and its tools; null for no row. */
5485
+ function pipelineMarksFor(n) {
5486
+ if (!view.components) return null;
5487
+ if (terragucciComponentsRepo && tgComponentFacets) {
5488
+ const jobs = (tgComponentFacets.jobs || []).filter((j) => j.wave === n);
5489
+ // The repo's tools, and what the wave's roots carry inside them (a SQL Yodeler project under app/).
5490
+ const roots = jobs.map((j) => (tgComponentFacets.roots && tgComponentFacets.roots[j.component]) || j.component);
5491
+ const carried = roots.flatMap((r) => (tgComponentFacets.rootTools && tgComponentFacets.rootTools[r]) || []);
5492
+ return { forge: componentsForge(tgComponentFacets, tgAnswer), job: jobs[0] ? jobs[0].jobName : null, tools: orderTools([...(tgComponentFacets.tools || []), ...carried]) };
5493
+ }
5494
+ if (ciByComponent.size) return { forge: forgeOfName(ciForge), job: null, tools: [], projection: true };
5495
+ return null;
5496
+ }
5497
+
5498
+ /** One mark: an icon and the name, with its tooltip; an `<a>` for the forge so it can link. */
5499
+ function markElement(tag, cls, spec, name, tip, light) {
5500
+ const el = document.createElementNS(SVGNS, tag);
5501
+ el.setAttribute("class", `pm-mark ${cls}`);
5502
+ const title = document.createElementNS(SVGNS, "title");
5503
+ title.textContent = tip;
5504
+ el.appendChild(title);
5505
+ if (spec) {
5506
+ const icon = iconSvg(document, spec, MARK.size, { light });
5507
+ icon.setAttribute("class", "pm-icon");
5508
+ el.appendChild(icon);
5509
+ }
5510
+ const text = document.createElementNS(SVGNS, "text");
5511
+ text.setAttribute("class", "pm-label");
5512
+ text.setAttribute("font-size", String(MARK.font));
5513
+ text.setAttribute("font-weight", "600");
5514
+ text.setAttribute("fill", "var(--muted)");
5515
+ text.setAttribute("dominant-baseline", "central");
5516
+ text.textContent = name;
5517
+ el.appendChild(text);
5518
+ return el;
5519
+ }
5520
+
5521
+ /** Each mark's label width, measured. */
5522
+ function markWidths(marks) {
5523
+ return marks.map((m) => {
5524
+ const label = m.querySelector(".pm-label");
5525
+ try {
5526
+ return label.getComputedTextLength() || label.textContent.length * MARK.font * 0.6;
5527
+ } catch {
5528
+ return label.textContent.length * MARK.font * 0.6;
5529
+ }
5530
+ });
5531
+ }
5532
+
5533
+ /** Put a row's marks at `xs` (each mark's left), `icons` with or without their icon; marks past `k` hidden. */
5534
+ function placeMarks(row, marks, widths, xs, icons, k, cys = null) {
5535
+ const cy0 = Number(row.getAttribute("data-pm-cy"));
5536
+ const show = (n, on) => (n.style.display = on ? "" : "none");
5537
+ show(row, k > 0);
5538
+ marks.forEach((m, i) => {
5539
+ show(m, i < k);
5540
+ if (i >= k) return;
5541
+ const icon = m.querySelector(".pm-icon");
5542
+ const label = m.querySelector(".pm-label");
5543
+ const cy = cys ? cys[i] : cy0;
5544
+ let x = xs[i];
5545
+ if (icon) show(icon, icons);
5546
+ if (icon && icons) {
5547
+ icon.setAttribute("x", x.toFixed(1));
5548
+ icon.setAttribute("y", (cy - MARK.size / 2).toFixed(1));
5549
+ x += MARK.size + MARK.gap;
5550
+ }
5551
+ label.setAttribute("x", x.toFixed(1));
5552
+ label.setAttribute("y", cy.toFixed(1));
5553
+ });
5554
+ }
5555
+
5556
+ /** One mark's width, with or without its icon. */
5557
+ const markWidth = (m, w, icons) => w + (icons && m.querySelector(".pm-icon") ? MARK.size + MARK.gap : 0);
5558
+
5559
+ /**
5560
+ * The title row: every mark with its icon, right-aligned in the rightmost
5561
+ * gap between `left` and the box's right edge that the edges crossing the
5562
+ * row (`data-pm-avoid`) leave. True when they fit; else nothing is placed.
5563
+ */
5564
+ function titleRowFits(row, left) {
5565
+ const right = Number(row.getAttribute("data-pm-right"));
5566
+ const marks = [...row.querySelectorAll(":scope > .pm-mark")];
5567
+ const widths = markWidths(marks);
5568
+ const need = marks.reduce((s, m, i) => s + markWidth(m, widths[i], true), 0) + MARK_STEP * (marks.length - 1);
5569
+ const cuts = (row.getAttribute("data-pm-avoid") || "").split(",").filter(Boolean).map(Number).filter((x) => x > left && x < right).sort((a, b) => a - b);
5570
+ const gaps = [];
5571
+ let at = left;
5572
+ for (const x of cuts) {
5573
+ if (x - 5 > at) gaps.push([at, x - 5]);
5574
+ at = Math.max(at, x + 5);
5575
+ }
5576
+ if (right > at) gaps.push([at, right]);
5577
+ const gap = gaps.reverse().find(([a, z]) => z - a >= need);
5578
+ if (!gap) return false;
5579
+ const xs = [];
5580
+ let x = gap[1] - need;
5581
+ marks.forEach((m, i) => {
5582
+ xs.push(x);
5583
+ x += markWidth(m, widths[i], true) + MARK_STEP;
5584
+ });
5585
+ placeMarks(row, marks, widths, xs, true, marks.length);
5586
+ row.setAttribute("data-pm-mode", "title");
5587
+ return true;
5588
+ }
5589
+
5590
+ /**
5591
+ * The marks' own row, just under the title: the box grows upward by a row
5592
+ * and its title moves up into it, so no card moves and none is covered. The
5593
+ * marks stay one cluster, the forge first, at the row's start or end where
5594
+ * no edge crosses it, else at its start with the edge behind. They keep
5595
+ * their icons: short of room the cluster runs onto another row, and the box
5596
+ * grows by that one too. Only at phone width do the icons go
5597
+ * first, while one row of names alone holds them.
5598
+ */
5599
+ function marksOwnRow(row, rect, title, grown = 0) {
5600
+ const G = MARK.size + 6;
5601
+ const left = Number(row.getAttribute("data-pm-start"));
5602
+ const right = Number(row.getAttribute("data-pm-end"));
5603
+ const cy = Number(row.getAttribute("data-pm-cy"));
5604
+ const avoid = (row.getAttribute("data-pm-avoid") || "").split(",").filter(Boolean).map(Number);
5605
+ const marks = [...row.querySelectorAll(":scope > .pm-mark")];
5606
+ const widths = markWidths(marks);
5607
+ // One cluster, the forge first, then the tools: in rows when one row can't hold it, each row's
5608
+ // part together. A part goes at the row's start or its end, whichever an edge doesn't cross
5609
+ // (`clear`); else at the start, any edge passing behind it.
5610
+ const lay = (icons, clear, maxRows) => {
5611
+ const parts = [[]];
5612
+ let used = 0;
5613
+ for (let i = 0; i < marks.length; i++) {
5614
+ const w = markWidth(marks[i], widths[i], icons);
5615
+ const part = parts[parts.length - 1];
5616
+ const need = used + (part.length ? MARK_STEP : 0) + w;
5617
+ if (part.length && need > right - left) {
5618
+ parts.push([i]);
5619
+ used = w;
5620
+ } else {
5621
+ part.push(i);
5622
+ used = need;
5623
+ }
5624
+ if (w > right - left || parts.length > maxRows) return null;
5625
+ }
5626
+ const xs = [];
5627
+ const rowOf = [];
5628
+ for (const [r, part] of parts.entries()) {
5629
+ const width = part.reduce((s, i) => s + markWidth(marks[i], widths[i], icons), 0) + MARK_STEP * (part.length - 1);
5630
+ const crosses = (x0) => avoid.some((e) => e > x0 - 4 && e < x0 + width + 4);
5631
+ let x0 = left;
5632
+ if (clear) {
5633
+ const spot = [left, right - width].find((x) => !crosses(x));
5634
+ if (spot === undefined) return null;
5635
+ x0 = spot;
5636
+ }
5637
+ for (const i of part) {
5638
+ xs[i] = x0;
5639
+ rowOf[i] = r;
5640
+ x0 += markWidth(marks[i], widths[i], icons) + MARK_STEP;
5641
+ }
5642
+ }
5643
+ return { xs, rowOf, rows: parts.length };
5644
+ };
5645
+ // Phone width is the viewer's screen: the kiosk frames this page narrow on a wide one.
5646
+ const phone = (() => {
5647
+ try {
5648
+ return (window.top || window).innerWidth <= 600;
5649
+ } catch {
5650
+ return window.innerWidth <= 600;
5651
+ }
5652
+ })();
5653
+ // A second pass (`grown`, the rows the box already grew by) keeps within those rows.
5654
+ const most = grown || marks.length;
5655
+ const tries = [[true, true, 1], [true, false, 1]];
5656
+ if (phone) tries.push([false, true, 1], [false, false, 1]);
5657
+ tries.push([true, true, most], [true, false, most], [false, true, most], [false, false, most]);
5658
+ let fit = null;
5659
+ for (const [icons, step, maxRows] of tries) {
5660
+ const l = lay(icons, step, maxRows);
5661
+ if (l) {
5662
+ fit = { ...l, icons };
5663
+ break;
5664
+ }
5665
+ }
5666
+ row.setAttribute("data-pm-mode", "row");
5667
+ if (!fit) return grown ? false : placeMarks(row, marks, widths, [], true, 0);
5668
+ const rows = grown || fit.rows;
5669
+ if (!grown) {
5670
+ const grow = G * rows;
5671
+ rect.setAttribute("y", String(Number(rect.getAttribute("y")) - grow));
5672
+ rect.setAttribute("height", String(Number(rect.getAttribute("height")) + grow));
5673
+ if (title) title.setAttribute("y", String(Number(title.getAttribute("y")) - grow));
5674
+ row.setAttribute("data-pm-rows", String(rows));
5675
+ }
5676
+ // The last row stands where the title stood, just over the cards; the others above it.
5677
+ const cys = fit.rowOf.map((r) => cy - (rows - 1 - r) * G);
5678
+ placeMarks(row, marks, widths, fit.xs, fit.icons, marks.length, cys);
5679
+ return true;
5680
+ }
5681
+
5682
+ /**
5683
+ * Once the reads are drawn: each wave box's marks stand clear of the read
5684
+ * lines that cross their row too, where the row has room; else they stay.
5685
+ */
5686
+ function clearMarksOfReads(svgEl, lines) {
5687
+ for (const row of svgEl.querySelectorAll(".pipeline-marks")) {
5688
+ if (row.style.display === "none") continue;
5689
+ const m = /translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/.exec((row.parentNode.getAttribute && row.parentNode.getAttribute("transform")) || "");
5690
+ const dx = m ? Number(m[1]) : 0;
5691
+ const dy = m ? Number(m[2]) : 0;
5692
+ const G = MARK.size + 6;
5693
+ const cy = Number(row.getAttribute("data-pm-cy"));
5694
+ const own = row.getAttribute("data-pm-mode") === "row";
5695
+ const top = cy - (own ? (Number(row.getAttribute("data-pm-rows")) || 1) * G : 0) - MARK.size / 2 - 2 + dy;
5696
+ const bottom = cy + MARK.size / 2 + 2 + dy;
5697
+ const xs = [];
5698
+ for (const { p0, p1 } of lines) {
5699
+ if (Math.max(p0.y, p1.y) < top || Math.min(p0.y, p1.y) > bottom) continue;
5700
+ for (const y of [top, (top + bottom) / 2, bottom]) {
5701
+ const f = p1.y === p0.y ? 0.5 : (y - p0.y) / (p1.y - p0.y);
5702
+ if (f >= 0 && f <= 1) xs.push(p0.x + (p1.x - p0.x) * f - dx);
5703
+ }
5704
+ }
5705
+ if (xs.length) stepAside(row, xs, own);
5706
+ }
5707
+ return liftOffArrowheads(svgEl, lines);
5708
+ }
5709
+
5710
+ /** Lay a row out again clear of `xs` too, or leave it as it was where that doesn't fit. */
5711
+ function stepAside(row, xs, own) {
5712
+ {
5713
+ const was = row.getAttribute("data-pm-avoid") || "";
5714
+ row.setAttribute("data-pm-avoid", [was, ...xs.map((x) => x.toFixed(1))].filter(Boolean).join(","));
5715
+ // The old places, should the new avoidance not fit.
5716
+ const keep = [...row.querySelectorAll("[x], [y]")].map((n) => [n, n.getAttribute("x"), n.getAttribute("y"), n.style.display]);
5717
+ const ok = own ? marksOwnRow(row, null, null, Number(row.getAttribute("data-pm-rows")) || 1) : titleRowFits(row, Number(row.getAttribute("data-pm-left")));
5718
+ if (!ok) {
5719
+ row.setAttribute("data-pm-avoid", was);
5720
+ for (const [n, x, y, d] of keep) {
5721
+ if (x !== null) n.setAttribute("x", x);
5722
+ if (y !== null) n.setAttribute("y", y);
5723
+ n.style.display = d;
5724
+ }
5725
+ }
5726
+ }
5727
+ }
5728
+
5729
+ /** How far a read's arrowhead reaches back from its tip, and how wide it spreads (marker 7 x stroke 3). */
5730
+ const ARROWHEAD = { length: 22, half: 11 };
5731
+
5732
+ /**
5733
+ * No arrowhead sits on a mark. A read that ends in a box whose marks its head
5734
+ * would cover lifts the box's title and marks a row up, the box growing by
5735
+ * that row, so the head lands on open ground just over the card. Returns each
5736
+ * lifted box as [its rect, the rows it grew by].
5737
+ */
5738
+ function liftOffArrowheads(svgEl, lines) {
5739
+ const G = MARK.size + 6;
5740
+ const lifted = [];
5741
+ const hit = (a, b) => a.x < b.x2 && a.x2 > b.x && a.y < b.y2 && a.y2 > b.y;
5742
+ const heads = lines.map(({ p0, p1 }) => {
5743
+ const len = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1;
5744
+ const bx = p1.x - ((p1.x - p0.x) / len) * ARROWHEAD.length;
5745
+ const by = p1.y - ((p1.y - p0.y) / len) * ARROWHEAD.length;
5746
+ const h = ARROWHEAD.half;
5747
+ return { x: Math.min(p1.x, bx) - h, y: Math.min(p1.y, by) - h, x2: Math.max(p1.x, bx) + h, y2: Math.max(p1.y, by) + h };
5748
+ });
5749
+ for (const row of svgEl.querySelectorAll(".pipeline-marks")) {
5750
+ if (row.style.display === "none") continue;
5751
+ const m = /translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/.exec((row.parentNode.getAttribute && row.parentNode.getAttribute("transform")) || "");
5752
+ const dx = m ? Number(m[1]) : 0;
5753
+ const dy = m ? Number(m[2]) : 0;
5754
+ const boxes = [...row.querySelectorAll(":scope > .pm-mark")]
5755
+ .filter((mk) => mk.style.display !== "none")
5756
+ .map((mk) => {
5757
+ try {
5758
+ const b = mk.getBBox();
5759
+ return { x: b.x + dx - 2, y: b.y + dy - 2, x2: b.x + b.width + dx + 2, y2: b.y + b.height + dy + 2 };
5760
+ } catch {
5761
+ return null;
5762
+ }
5763
+ })
5764
+ .filter(Boolean);
5765
+ if (!heads.some((h) => boxes.some((b) => hit(h, b)))) continue;
5766
+ const rect = svgEl.querySelector(`rect[data-group-id="wave-${row.getAttribute("data-pm-wave")}"]`);
5767
+ const title = row.nextElementSibling && row.nextElementSibling.tagName.toLowerCase() === "text" ? row.nextElementSibling : null;
5768
+ if (!rect) continue;
5769
+ if (!rect.hasAttribute("data-pm-y0")) {
5770
+ rect.setAttribute("data-pm-y0", rect.getAttribute("y"));
5771
+ rect.setAttribute("data-pm-h0", rect.getAttribute("height"));
5772
+ }
5773
+ if (title && !title.hasAttribute("data-pm-ty0")) title.setAttribute("data-pm-ty0", title.getAttribute("y"));
5774
+ const titleCy = title ? Number(title.getAttribute("y")) : null;
5775
+ rect.setAttribute("y", String(Number(rect.getAttribute("y")) - G));
5776
+ rect.setAttribute("height", String(Number(rect.getAttribute("height")) + G));
5777
+ if (title) title.setAttribute("y", String(titleCy - G));
5778
+ row.setAttribute("data-pm-cy", String(Number(row.getAttribute("data-pm-cy")) - G));
5779
+ for (const n of row.querySelectorAll(".pm-icon, .pm-label")) n.setAttribute("y", String(Number(n.getAttribute("y")) - G));
5780
+ // The job's status rides its title's line.
5781
+ if (title) {
5782
+ const tb = title.getBBox();
5783
+ for (const s of svgEl.querySelectorAll('[data-tg-root="wave-status"]')) {
5784
+ const y = Number(s.getAttribute("y"));
5785
+ if (Math.abs(y - (tb.y + tb.height / 2 + G + dy)) < tb.height) s.setAttribute("y", String(y - G));
5786
+ }
5787
+ }
5788
+ lifted.push([rect, G]);
5789
+ }
5790
+ return lifted;
5791
+ }
5792
+
5793
+ /** The x's at which the graph's edges cross a band of the svg (`y0`..`y1`), in the svg's own coordinates. */
5794
+ function edgeXsIn(svgEl, y0, y1) {
5795
+ const xs = [];
5796
+ for (const path of svgEl.querySelectorAll("g[data-edge-from] path, g[data-edge-from] line")) {
5797
+ let len = 0;
5798
+ try {
5799
+ len = path.getTotalLength();
5800
+ } catch {
5801
+ continue;
5802
+ }
5803
+ for (let d = 0; d <= len; d += 3) {
5804
+ const p = path.getPointAtLength(d);
5805
+ if (p.y >= y0 && p.y <= y1) xs.push(p.x);
5806
+ }
5807
+ }
5808
+ return xs;
5809
+ }
5810
+
5811
+ /** The width of the job's status words a terragucci wave box will say after its title, or 0. */
5812
+ function waveStatusRoom(svgEl, n) {
5813
+ if (!(terragucciComponentsRepo && tgComponentFacets && tgAnswer && !tgAnswer.error)) return 0;
5814
+ let w;
5815
+ try {
5816
+ w = tgComponentMarks(tgAnswerDrawn(), tgProgress, tgComponentFacets, { runs: tgRuns, pin: view.run || null }).waves[n];
5817
+ } catch {
5818
+ return 0;
5819
+ }
5820
+ if (!w || !w.status) return 0;
5821
+ const probe = document.createElementNS(SVGNS, "text");
5822
+ probe.setAttribute("font-size", "12");
5823
+ probe.setAttribute("font-weight", "700");
5824
+ probe.textContent = `· ${w.status.words}`;
5825
+ svgEl.appendChild(probe);
5826
+ let len = 0;
5827
+ try {
5828
+ len = probe.getComputedTextLength();
5829
+ } catch {
5830
+ len = probe.textContent.length * 7;
5831
+ }
5832
+ probe.remove();
5833
+ return len + 6 + 10;
5834
+ }
5835
+
5836
+ /** 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. */
5837
+ function drawPipelineMarks(svgEl) {
5838
+ for (const old of svgEl.querySelectorAll(".pipeline-marks")) old.remove();
5839
+ // A box an earlier draw grew for its marks' row goes back as pinhole drew it.
5840
+ for (const r of svgEl.querySelectorAll("rect[data-pm-y0]")) {
5841
+ r.setAttribute("y", r.getAttribute("data-pm-y0"));
5842
+ r.setAttribute("height", r.getAttribute("data-pm-h0"));
5843
+ r.removeAttribute("data-pm-y0");
5844
+ r.removeAttribute("data-pm-h0");
5845
+ }
5846
+ for (const tx of svgEl.querySelectorAll("text[data-pm-ty0]")) {
5847
+ tx.setAttribute("y", tx.getAttribute("data-pm-ty0"));
5848
+ tx.removeAttribute("data-pm-ty0");
5849
+ }
5850
+ const light = kioskLight();
5851
+ const pin = view.run || null;
5852
+ const commit = tgRunsCommit(pin, tgProgress);
5853
+ for (const rect of svgEl.querySelectorAll("rect[data-group-id]")) {
5854
+ const key = rect.getAttribute("data-group-id");
5855
+ const n = tgWaveOfKey(key);
5856
+ if (n === null) continue;
5857
+ const what = pipelineMarksFor(n);
5858
+ if (!what) continue;
5859
+ const forge = what.forge && FORGES[what.forge] ? what.forge : null;
5860
+ const tools = what.tools.filter((t) => TOOLS[t]);
5861
+ if (!forge && !tools.length) continue;
5862
+ // The box's title: the text right after its rect (pinhole's order), inside the box's group once wrapped.
5863
+ let title = null;
5864
+ for (let el = rect.nextElementSibling; el; el = el.nextElementSibling) {
5865
+ if (el.tagName.toLowerCase() === "text") {
5866
+ title = el;
5867
+ break;
5868
+ }
5869
+ if (el.tagName.toLowerCase() === "rect") break;
5870
+ }
5871
+ const rx = Number(rect.getAttribute("x"));
5872
+ const ry = Number(rect.getAttribute("y"));
5873
+ const rw = Number(rect.getAttribute("width"));
5874
+ let tb = null;
5875
+ try {
5876
+ tb = title ? title.getBBox() : null;
5877
+ } catch {
5878
+ tb = null;
5879
+ }
5880
+ const cy = tb && tb.height ? tb.y + tb.height / 2 : ry + 10 + MARK.size / 2;
5881
+ const row = document.createElementNS(SVGNS, "g");
5882
+ row.setAttribute("class", "pipeline-marks");
5883
+ row.setAttribute("data-pm-wave", String(n));
5884
+ row.setAttribute("data-pm-right", String(rx + rw - MARK.pad));
5885
+ row.setAttribute("data-pm-cy", String(cy));
5886
+ row.setAttribute("data-pm-start", String(rx + 12));
5887
+ row.setAttribute("data-pm-end", String(rx + rw - 10));
5888
+ if (forge) {
5889
+ const f = FORGES[forge];
5890
+ const url = what.projection ? null : markLink(waveJobUrl(tgAnswer, n, commit), forge);
5891
+ const tip = what.projection ? `${key} is a ${f.label} CI stage of the pipeline chant generates` : markTitle(what.job, forge);
5892
+ const a = markElement("a", `pm-forge pm-forge-${forge}`, f, f.label, tip, light);
5893
+ if (url) {
5894
+ a.setAttribute("href", url);
5895
+ a.setAttribute("target", "_blank");
5896
+ a.setAttribute("rel", "noopener noreferrer");
5897
+ }
5898
+ row.appendChild(a);
5899
+ }
5900
+ for (const t of tools) {
5901
+ const tool = TOOLS[t];
5902
+ row.appendChild(markElement("g", `pm-tool pm-tool-${t}`, tool.icon, tool.name, what.job ? `${what.job} runs ${tool.name}` : `Runs ${tool.name}`, light));
5903
+ }
5904
+ // The edges that run through the title row, so the marks stand clear of them. Pinhole draws edges
5905
+ // in the svg's coordinates and a moved box carries a translate, so their x's are taken back into the box's.
5906
+ const shift = (() => {
5907
+ const m = /translate\(\s*([-\d.]+)/.exec((rect.parentNode.getAttribute && rect.parentNode.getAttribute("transform")) || "");
5908
+ return m ? Number(m[1]) : 0;
5909
+ })();
5910
+ const band = edgeXsIn(svgEl, cy - MARK.size / 2 - 2, cy + MARK.size / 2 + 2).map((x) => x - shift);
5911
+ row.setAttribute("data-pm-avoid", band.filter((x) => x >= rx && x <= rx + rw).map((x) => x.toFixed(1)).join(","));
5912
+ rect.parentNode.insertBefore(row, title || rect.nextSibling);
5913
+ // The title row holds the title, the job's status after it, then the marks; else the marks take their own row.
5914
+ const left = (tb && tb.width ? tb.x + tb.width : rx + 12) + 8 + waveStatusRoom(svgEl, n);
5915
+ row.setAttribute("data-pm-left", String(left));
5916
+ if (titleRowFits(row, left)) continue;
5917
+ rect.setAttribute("data-pm-y0", rect.getAttribute("y"));
5918
+ rect.setAttribute("data-pm-h0", rect.getAttribute("height"));
5919
+ if (title) title.setAttribute("data-pm-ty0", title.getAttribute("y"));
5920
+ marksOwnRow(row, rect, title);
5481
5921
  }
5482
5922
  }
5483
5923
 
@@ -5603,10 +6043,6 @@ function render(ir, svg, m) {
5603
6043
  if (embedOpts.bare) quietCardLines(svgEl);
5604
6044
  setupGraphViewBox(svgEl);
5605
6045
  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
6046
  }
5611
6047
  ensureZoomControls(g);
5612
6048
  ensureBackToInfra(g);