@intentius/behold 0.29.16 → 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.
- 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 +388 -43
- 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 +7 -3
- 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
|
@@ -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.
|
|
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
|
-
|
|
3726
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
3833
|
-
|
|
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
|
-
//
|
|
5459
|
-
//
|
|
5460
|
-
//
|
|
5461
|
-
//
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
const
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
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);
|