@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/THIRD_PARTY.md +36 -0
- package/dist/cli.js +415 -277
- package/licenses/octicons-LICENSE +21 -0
- package/package.json +1 -1
- package/web/app.js +484 -48
- package/web/forge-mark.js +235 -0
- package/web/forge-mark.test.js +112 -0
- package/web/kiosk-runs.js +47 -2
- package/web/kiosk.html +17 -11
- package/web/kiosk.js +7 -0
- package/web/theme.js +109 -6
- package/web/theme.test.js +56 -1
- package/web/tool-mark.js +161 -0
- package/web/tool-mark.test.js +85 -0
- package/web/vendors/sql-yodeler-160.png +0 -0
- package/web/vendors/sql-yodeler-44.png +0 -0
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
|
-
|
|
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
|
-
|
|
3726
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
3833
|
-
|
|
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)" :
|
|
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
|
-
//
|
|
5459
|
-
//
|
|
5460
|
-
//
|
|
5461
|
-
//
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
const
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
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);
|