@intentius/behold 0.29.0 → 0.29.2
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/README.md +17 -4
- package/dist/cli.js +281 -125
- package/package.json +1 -1
- package/web/app.js +348 -51
- package/web/embed.js +6 -3
- package/web/embed.test.js +15 -1
- package/web/index.html +7 -0
- package/web/kiosk-runs.js +485 -0
- package/web/kiosk-runs.test.js +98 -0
- package/web/kiosk.html +98 -29
- package/web/kiosk.js +177 -83
- package/web/kiosk.test.js +65 -0
- package/web/terragucci-change.js +148 -3
- package/web/terragucci-change.test.js +99 -0
- package/web/view-url.js +24 -2
- package/web/view-url.test.js +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.2",
|
|
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
|
@@ -18,7 +18,7 @@ import { renderLane } from "./terragucci-lifecycle.js";
|
|
|
18
18
|
import { renderReleases } from "./releases.js";
|
|
19
19
|
import { renderYodelLane } from "./sql-yodeler-lane.js";
|
|
20
20
|
import { initTerragucciEstate } from "./terragucci-estate.js";
|
|
21
|
-
import { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLine as tgReadLine, readTag as tgReadTag } from "./terragucci-change.js";
|
|
21
|
+
import { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLabel as tgReadLabel, readLine as tgReadLine, readTag as tgReadTag, rootStates as tgRootStates, viewCaption as tgViewCaption } from "./terragucci-change.js";
|
|
22
22
|
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";
|
|
23
23
|
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
24
24
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
@@ -108,6 +108,7 @@ initTheme();
|
|
|
108
108
|
const embedOpts = readEmbed(location.search);
|
|
109
109
|
if (embedOpts.theme && !setTheme(embedOpts.theme, { persist: false })) console.warn(`behold: no theme named ${embedOpts.theme}`);
|
|
110
110
|
if (embedOpts.embed) document.documentElement.classList.add("embed");
|
|
111
|
+
if (embedOpts.embed && embedOpts.bare) document.documentElement.classList.add("bare"); // #569: the graph alone, for a kiosk
|
|
111
112
|
initPanel({ embed: embedOpts.embed });
|
|
112
113
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
113
114
|
|
|
@@ -192,10 +193,17 @@ function colourPlan(graphIr) {
|
|
|
192
193
|
const mode = activeColourMode();
|
|
193
194
|
const tokens = getTokens();
|
|
194
195
|
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
196
|
+
const theme = getTheme();
|
|
197
|
+
// #569 review: a terragucci repo's view with no drift read draws each root's
|
|
198
|
+
// wave state on its box, so the cards wear the neutral ground: colour there
|
|
199
|
+
// means a wave's state, never a category hue that reads as an error.
|
|
200
|
+
if (mode === "drift" && tokens && tgStatesDrawn(graphIr)) {
|
|
201
|
+
const neutral = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
|
|
202
|
+
return { mode, fillOf: () => ({ fill: neutral, unpriced: false }) };
|
|
203
|
+
}
|
|
195
204
|
if (mode === "drift" || !tokens) {
|
|
196
205
|
return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
|
|
197
206
|
}
|
|
198
|
-
const theme = getTheme();
|
|
199
207
|
// The drift overlay's own neutral card ground — the colour a card wears when
|
|
200
208
|
// chant could not read it. #399: an entity with no block draws THIS, never
|
|
201
209
|
// the zero end of the scale, which would say "free" or "saturated" about an
|
|
@@ -210,6 +218,12 @@ function colourPlan(graphIr) {
|
|
|
210
218
|
return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
/** Whether the view draws a terragucci repo's root states over cards that carry no drift read. */
|
|
222
|
+
function tgStatesDrawn(graphIr) {
|
|
223
|
+
if (!tgAnswer || tgAnswer.error || !tgAnswer.roots || !tgAnswer.roots.length) return false;
|
|
224
|
+
return !graphIr.nodes.some((n) => n.attrs && n.attrs._status);
|
|
225
|
+
}
|
|
226
|
+
|
|
213
227
|
function recolorNodesByCategory(ir) {
|
|
214
228
|
if (ir) lastGraphIr = ir;
|
|
215
229
|
const graphIr = ir || lastGraphIr;
|
|
@@ -1580,7 +1594,7 @@ function wire(ir) {
|
|
|
1580
1594
|
// member (#475) is the estate member the view is on: a host opens behold with
|
|
1581
1595
|
// ?member=, and picking a card or a member box moves it. It changes no fetch;
|
|
1582
1596
|
// the graph is the whole estate, framed on that member's box.
|
|
1583
|
-
const view = { member: null, env: null, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
|
|
1597
|
+
const view = { member: null, env: null, gates: null, focus: null, run: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
|
|
1584
1598
|
|
|
1585
1599
|
// #182: `components` is the boot default, but a project that declares no
|
|
1586
1600
|
// components renders it as ZERO nodes — the first screen was a blank graph
|
|
@@ -2473,6 +2487,8 @@ function applyWantedView(want) {
|
|
|
2473
2487
|
if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
|
|
2474
2488
|
if ("radial" in apply) view.radial = apply.radial;
|
|
2475
2489
|
if ("gates" in apply) view.gates = apply.gates;
|
|
2490
|
+
if ("focus" in apply) view.focus = apply.focus; // #569: drawn by drawTgReads, no new read
|
|
2491
|
+
if ("run" in apply) view.run = apply.run; // #569: which run the view draws; read in the message handler
|
|
2476
2492
|
if (want.member === null) {
|
|
2477
2493
|
view.member = null;
|
|
2478
2494
|
selectedNodeId = null;
|
|
@@ -2496,7 +2512,11 @@ window.addEventListener("message", (e) => {
|
|
|
2496
2512
|
if (!want) return;
|
|
2497
2513
|
const lensBefore = colourMode;
|
|
2498
2514
|
const gatesBefore = view.gates;
|
|
2515
|
+
const focusBefore = view.focus;
|
|
2516
|
+
const runBefore = view.run;
|
|
2499
2517
|
const moved = applyWantedView(want);
|
|
2518
|
+
if (view.run !== runBefore) void loadTgProgress({ fresh: true }); // #569: the run the narration is about
|
|
2519
|
+
else if (view.focus !== focusBefore && !moved) drawTgReads(); // #569
|
|
2500
2520
|
// #477: the host's env for the gate strip moved. Read it now, not on the
|
|
2501
2521
|
// next `changed`; renderStatusbar below writes it into the URL.
|
|
2502
2522
|
if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
|
|
@@ -2735,6 +2755,8 @@ function syncViewUrl() {
|
|
|
2735
2755
|
node: place.node || selectedNodeId,
|
|
2736
2756
|
radial: view.radial && !view.components && !view.logical && !view.ops,
|
|
2737
2757
|
gates: view.gates,
|
|
2758
|
+
focus: view.focus,
|
|
2759
|
+
run: view.run,
|
|
2738
2760
|
};
|
|
2739
2761
|
const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
|
|
2740
2762
|
const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
|
|
@@ -3367,8 +3389,12 @@ function watchTerragucci() {
|
|
|
3367
3389
|
es.addEventListener("reports", () => loadTerragucci({ fresh: true }));
|
|
3368
3390
|
// #511: the run view moved while a wave applies.
|
|
3369
3391
|
es.addEventListener("progress", (e) => {
|
|
3370
|
-
|
|
3371
|
-
|
|
3392
|
+
const pushed = JSON.parse(e.data);
|
|
3393
|
+
// #569: the pushed view is the newest run's; a view pinned to another run reads its own again.
|
|
3394
|
+
if (tgPushFits(pushed)) {
|
|
3395
|
+
tgProgress = pushed;
|
|
3396
|
+
paintTgProgress();
|
|
3397
|
+
} else if (view.run && view.run !== "none" && view.run !== "base") void loadTgProgress({ fresh: true });
|
|
3372
3398
|
});
|
|
3373
3399
|
es.addEventListener("lifecycle", (e) => {
|
|
3374
3400
|
tgLifecycle = JSON.parse(e.data);
|
|
@@ -3403,29 +3429,70 @@ async function loadTerragucci({ fresh = false } = {}) {
|
|
|
3403
3429
|
// #511: /api/terragucci/progress, the newest apply's run view on the cards.
|
|
3404
3430
|
let tgProgress = null;
|
|
3405
3431
|
async function loadTgProgress({ fresh = false } = {}) {
|
|
3406
|
-
|
|
3432
|
+
// #569: `run` pins the run view: a commit's, or none at all (the pull request's plan, or the base).
|
|
3433
|
+
const pin = view.run;
|
|
3434
|
+
if (pin === "none" || pin === "base") {
|
|
3435
|
+
tgProgress = null;
|
|
3436
|
+
paintTgProgress();
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
const q = new URLSearchParams();
|
|
3440
|
+
if (pin) q.set("commit", pin);
|
|
3441
|
+
if (fresh) q.set("fresh", "1");
|
|
3442
|
+
const qs = q.toString();
|
|
3443
|
+
const answer = await apiFetch(`/api/terragucci/progress${qs ? `?${qs}` : ""}`)
|
|
3407
3444
|
.then((r) => r.json())
|
|
3408
3445
|
.catch((e) => ({ error: String(e) }));
|
|
3446
|
+
if (view.run !== pin) return; // the pin moved while this read ran
|
|
3447
|
+
tgProgress = answer;
|
|
3409
3448
|
paintTgProgress();
|
|
3410
3449
|
}
|
|
3450
|
+
/** #569: whether a pushed run view is the one the view draws: any, unless `run` pins another. */
|
|
3451
|
+
function tgPushFits(answer) {
|
|
3452
|
+
const pin = view.run;
|
|
3453
|
+
if (!pin) return true;
|
|
3454
|
+
if (pin === "none" || pin === "base") return false;
|
|
3455
|
+
return !!(answer && answer.run && answer.run.commit && (answer.run.commit.startsWith(pin) || pin.startsWith(answer.run.commit)));
|
|
3456
|
+
}
|
|
3457
|
+
/** #569: the reports as the view draws them: at `run=base` nothing is in flight, so no pull request either. */
|
|
3458
|
+
function tgAnswerDrawn() {
|
|
3459
|
+
return view.run === "base" && tgAnswer && !tgAnswer.error ? { ...tgAnswer, pull_request: null } : tgAnswer;
|
|
3460
|
+
}
|
|
3411
3461
|
function paintTgProgress() {
|
|
3412
3462
|
tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
|
|
3413
3463
|
drawTgReads(); // #568: the run view's reads, once the change merged
|
|
3414
3464
|
renderTerragucciPanel();
|
|
3415
3465
|
}
|
|
3416
|
-
// #568: the reads edges between roots of one repo, drawn over the card view
|
|
3417
|
-
//
|
|
3418
|
-
//
|
|
3419
|
-
//
|
|
3420
|
-
//
|
|
3421
|
-
//
|
|
3466
|
+
// #568: the reads edges between roots of one repo, drawn over the card view.
|
|
3467
|
+
// #569: drawn so the view shows the change without the panel: each read is a
|
|
3468
|
+
// line from the reader (its data card when the checkout draws one, else its
|
|
3469
|
+
// root's box) to the box of the root it reads, ending at the box's border,
|
|
3470
|
+
// arrowed, over the boxes, with what it reads written on it; each root's box
|
|
3471
|
+
// carries its wave and that wave's state, in the state's colour; a caption
|
|
3472
|
+
// says which run all of it comes from. `view.focus` brings some roots forward
|
|
3473
|
+
// and dims the rest. From the pull request's plan, or from the run view once
|
|
3474
|
+
// merged. Not live; redrawn on each render and each read.
|
|
3475
|
+
const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
|
|
3422
3476
|
function drawTgReads() {
|
|
3423
3477
|
const svg = document.querySelector("#graph svg");
|
|
3424
3478
|
if (!svg) return;
|
|
3425
3479
|
for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
|
|
3480
|
+
for (const old of svg.querySelectorAll("[data-tg-root]")) old.remove();
|
|
3481
|
+
for (const r of svg.querySelectorAll("rect[data-tg-stroke0]")) {
|
|
3482
|
+
r.setAttribute("stroke", r.getAttribute("data-tg-stroke0"));
|
|
3483
|
+
r.setAttribute("stroke-width", r.getAttribute("data-tg-width0") || "1.2");
|
|
3484
|
+
r.removeAttribute("data-tg-stroke0");
|
|
3485
|
+
r.removeAttribute("data-tg-width0");
|
|
3486
|
+
}
|
|
3487
|
+
for (const t of svg.querySelectorAll("[data-tg-hid]")) {
|
|
3488
|
+
t.removeAttribute("data-tg-hid");
|
|
3489
|
+
t.removeAttribute("visibility");
|
|
3490
|
+
}
|
|
3491
|
+
for (const g of svg.querySelectorAll("[data-tg-dim]")) {
|
|
3492
|
+
g.removeAttribute("data-tg-dim");
|
|
3493
|
+
g.style.opacity = "";
|
|
3494
|
+
}
|
|
3426
3495
|
if (!tgAnswer || tgAnswer.error || !tgAnswer.roots) return;
|
|
3427
|
-
const edges = tgReadEdges(tgAnswer, tgProgress);
|
|
3428
|
-
if (!edges.length) return;
|
|
3429
3496
|
const toSvg = svg.getScreenCTM && svg.getScreenCTM();
|
|
3430
3497
|
if (!toSvg) return;
|
|
3431
3498
|
const inv = toSvg.inverse();
|
|
@@ -3444,56 +3511,286 @@ function drawTgReads() {
|
|
|
3444
3511
|
return { x: Math.min(pts[0].x, pts[1].x), y: Math.min(pts[0].y, pts[1].y), x2: Math.max(pts[0].x, pts[1].x), y2: Math.max(pts[0].y, pts[1].y) };
|
|
3445
3512
|
};
|
|
3446
3513
|
const union = (boxes) => (boxes.length ? { x: Math.min(...boxes.map((b) => b.x)), y: Math.min(...boxes.map((b) => b.y)), x2: Math.max(...boxes.map((b) => b.x2)), y2: Math.max(...boxes.map((b) => b.y2)) } : null);
|
|
3447
|
-
const
|
|
3448
|
-
|
|
3449
|
-
|
|
3514
|
+
const rootOf = (path) => tgAnswer.roots.find((x) => x.path === path) || null;
|
|
3515
|
+
const groupRect = (path) => {
|
|
3516
|
+
const r = rootOf(path);
|
|
3517
|
+
return r ? svg.querySelector(`rect[data-group-id="${CSS.escape(r.name)}"]`) : null;
|
|
3518
|
+
};
|
|
3519
|
+
const cardsOf = (path) => {
|
|
3520
|
+
const r = rootOf(path);
|
|
3521
|
+
if (!r) return [];
|
|
3450
3522
|
const prefix = `${r.member}/${r.name}/`;
|
|
3451
|
-
return
|
|
3523
|
+
return [...svg.querySelectorAll("[data-node-id]")].filter((g) => (g.getAttribute("data-node-id") || "").startsWith(prefix));
|
|
3524
|
+
};
|
|
3525
|
+
const rootBox = (path) => {
|
|
3526
|
+
const rect = groupRect(path);
|
|
3527
|
+
return (rect && boxOf(rect)) || union(cardsOf(path).map(boxOf).filter(Boolean));
|
|
3452
3528
|
};
|
|
3453
3529
|
const NS = "http://www.w3.org/2000/svg";
|
|
3454
|
-
const
|
|
3455
|
-
|
|
3456
|
-
|
|
3530
|
+
const make = (tag, attrs, text) => {
|
|
3531
|
+
const n = document.createElementNS(NS, tag);
|
|
3532
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
|
3533
|
+
if (text !== undefined) n.textContent = text;
|
|
3534
|
+
return n;
|
|
3535
|
+
};
|
|
3536
|
+
const layer = make("g", { "data-tg-reads": "1", "pointer-events": "none" });
|
|
3537
|
+
svg.appendChild(layer); // over the boxes and cards: the lines end at the borders, and the labels carry a background
|
|
3538
|
+
// A pill of text with a background, centred on (cx, cy); measured once it is in the svg.
|
|
3539
|
+
const pill = (parent, cx, cy, text, { stroke, fill = "var(--panel)", color = "var(--fg)", size = 13, weight = 600, anchor = "middle", cls = "" } = {}) => {
|
|
3540
|
+
const g = make("g", cls ? { class: cls } : {});
|
|
3541
|
+
const rect = make("rect", { rx: 6, fill, stroke: stroke || "var(--rule)", "stroke-width": 1.5 });
|
|
3542
|
+
const t = make("text", { x: cx, y: cy, "text-anchor": anchor, "dominant-baseline": "central", "font-size": size, "font-weight": weight, fill: color }, text);
|
|
3543
|
+
g.append(rect, t);
|
|
3544
|
+
parent.appendChild(g);
|
|
3545
|
+
let w = text.length * size * 0.58;
|
|
3546
|
+
try {
|
|
3547
|
+
w = t.getComputedTextLength() || w;
|
|
3548
|
+
} catch {
|
|
3549
|
+
/* not laid out: the estimate stands */
|
|
3550
|
+
}
|
|
3551
|
+
const padX = 8;
|
|
3552
|
+
const h = size + 10;
|
|
3553
|
+
const left = anchor === "end" ? cx - w : anchor === "start" ? cx : cx - w / 2;
|
|
3554
|
+
rect.setAttribute("x", String(left - padX));
|
|
3555
|
+
rect.setAttribute("y", String(cy - h / 2));
|
|
3556
|
+
rect.setAttribute("width", String(w + 2 * padX));
|
|
3557
|
+
rect.setAttribute("height", String(h));
|
|
3558
|
+
return { g, w: w + 2 * padX, h };
|
|
3559
|
+
};
|
|
3560
|
+
|
|
3561
|
+
// The roots: each box's wave and state, in its colour.
|
|
3562
|
+
const drawnAnswer = tgAnswerDrawn();
|
|
3563
|
+
const states = tgRootStates(drawnAnswer, tgProgress);
|
|
3564
|
+
const focus = view.focus ? new Set(view.focus.split(",")) : null;
|
|
3565
|
+
const boxes = [];
|
|
3566
|
+
for (const r of tgAnswer.roots) {
|
|
3567
|
+
const rect = groupRect(r.path);
|
|
3568
|
+
const st = states.roots[r.path];
|
|
3569
|
+
const b = rootBox(r.path);
|
|
3570
|
+
if (b) boxes.push(b);
|
|
3571
|
+
if (focus && !focus.has(r.path) && !focus.has(r.name)) {
|
|
3572
|
+
for (const el of [rect && rect.parentElement, ...cardsOf(r.path)]) {
|
|
3573
|
+
if (!el) continue;
|
|
3574
|
+
el.setAttribute("data-tg-dim", "1");
|
|
3575
|
+
el.style.opacity = "0.5";
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
if (!rect || !st || !b) continue;
|
|
3579
|
+
const tone = TG_TONE[st.tone] || TG_TONE.idle;
|
|
3580
|
+
rect.setAttribute("data-tg-stroke0", rect.getAttribute("stroke") || "");
|
|
3581
|
+
rect.setAttribute("data-tg-width0", rect.getAttribute("stroke-width") || "1.2");
|
|
3582
|
+
if (st.state !== "outside" && st.state !== "unchanged") {
|
|
3583
|
+
rect.setAttribute("stroke", tone);
|
|
3584
|
+
rect.setAttribute("stroke-width", focus && (focus.has(r.path) || focus.has(r.name)) ? "4" : "2.5");
|
|
3585
|
+
}
|
|
3586
|
+
const mark = make("g", { "data-tg-root": r.path, "pointer-events": "none" });
|
|
3587
|
+
if (focus && !focus.has(r.path) && !focus.has(r.name)) mark.style.opacity = "0.6";
|
|
3588
|
+
layer.parentNode.insertBefore(mark, layer);
|
|
3589
|
+
// The badge: on the box's top border, right-aligned, like a tab; the "N
|
|
3590
|
+
// resources" count in the header row under it gives way.
|
|
3591
|
+
const local = rect.getBBox();
|
|
3592
|
+
const headY = local.y + 23;
|
|
3593
|
+
const tabY = local.y;
|
|
3594
|
+
for (const t of svg.querySelectorAll("text")) {
|
|
3595
|
+
if (t.closest("[data-node-id]") || t.closest("[data-tg-root]") || t.closest("[data-tg-reads]")) continue;
|
|
3596
|
+
const tb = t.getBBox();
|
|
3597
|
+
if (/^\d+ resources?$/.test((t.textContent || "").trim()) && Math.abs(tb.y + tb.height / 2 - headY) < 12 && tb.x > local.x && tb.x < local.x + local.width) {
|
|
3598
|
+
t.setAttribute("visibility", "hidden");
|
|
3599
|
+
t.setAttribute("data-tg-hid", "1");
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
// Filled when something happened or waits on the root; an outline when nothing has yet.
|
|
3603
|
+
const solid = st.state !== "outside" && st.state !== "not-started" && st.state !== "unchanged";
|
|
3604
|
+
const badge = pill(mark, local.x + local.width - 16, tabY, st.words, { stroke: tone, fill: solid ? tone : "var(--panel)", color: solid ? "var(--bg)" : "var(--fg)", size: 14, weight: 700, anchor: "end", cls: st.state === "applying" ? "pin-pulse" : "" });
|
|
3605
|
+
badge.g.appendChild(make("title", {}, [st.words, ...st.facts].join("\n") + "\n(from terragucci's reports, not live)"));
|
|
3606
|
+
// The facts under it, at the box's bottom right: the plan a wave waits on, its changes.
|
|
3607
|
+
// Packed into one line that fits the box, most telling first; the rest is in the badge's tooltip.
|
|
3608
|
+
if (st.facts.length) {
|
|
3609
|
+
// Right-aligned and at most about three fifths of the box, so a read's arrow leaving the box has room beside it.
|
|
3610
|
+
const room = local.width * 0.6 - 14;
|
|
3611
|
+
const fill = st.state === "waiting" ? tone : "var(--muted)";
|
|
3612
|
+
const probe = make("text", { "font-size": 12.5, "font-weight": 600 });
|
|
3613
|
+
mark.appendChild(probe);
|
|
3614
|
+
const width = (t) => {
|
|
3615
|
+
probe.textContent = t;
|
|
3616
|
+
try {
|
|
3617
|
+
return probe.getComputedTextLength();
|
|
3618
|
+
} catch {
|
|
3619
|
+
return t.length * 7;
|
|
3620
|
+
}
|
|
3621
|
+
};
|
|
3622
|
+
const lines = [];
|
|
3623
|
+
let cur = "";
|
|
3624
|
+
const more = width(" …");
|
|
3625
|
+
for (const f of st.facts) {
|
|
3626
|
+
const next = cur ? `${cur} · ${f}` : f;
|
|
3627
|
+
if (!cur || width(next) <= room - more) cur = next;
|
|
3628
|
+
else {
|
|
3629
|
+
lines.push(cur);
|
|
3630
|
+
cur = f;
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
if (cur) lines.push(cur);
|
|
3634
|
+
probe.remove();
|
|
3635
|
+
// One line: the box's bottom padding holds no more without touching a card.
|
|
3636
|
+
const shown = lines.slice(0, 1);
|
|
3637
|
+
if (lines.length > 1) shown[0] += " …";
|
|
3638
|
+
shown.forEach((t, i) => {
|
|
3639
|
+
mark.appendChild(make("text", { x: local.x + local.width - 14, y: local.y + local.height - 10 - (shown.length - 1 - i) * 15, "text-anchor": "end", "font-size": 12.5, "font-weight": 600, fill }, t));
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
// The reads. #569 review: no line or label crosses text. Each line is the
|
|
3645
|
+
// shortest one, from a point on the reader's card (or box) border to a point
|
|
3646
|
+
// on the read root's box border, that crosses no text and as few cards as
|
|
3647
|
+
// there are; each label then sits beside its line where it covers no text
|
|
3648
|
+
// and crosses no line.
|
|
3649
|
+
const edges = tgReadEdges(drawnAnswer, tgProgress);
|
|
3650
|
+
const centre = (b) => ({ x: (b.x + b.x2) / 2, y: (b.y + b.y2) / 2 });
|
|
3651
|
+
const grow = (b, d) => ({ x: b.x - d, y: b.y - d, x2: b.x2 + d, y2: b.y2 + d });
|
|
3652
|
+
// Whether the segment p-q passes through the box (Liang-Barsky).
|
|
3653
|
+
const crosses = (p, q, b) => {
|
|
3654
|
+
let t0 = 0;
|
|
3655
|
+
let t1 = 1;
|
|
3656
|
+
const dx = q.x - p.x;
|
|
3657
|
+
const dy = q.y - p.y;
|
|
3658
|
+
for (const [pp, qq] of [[-dx, p.x - b.x], [dx, b.x2 - p.x], [-dy, p.y - b.y], [dy, b.y2 - p.y]]) {
|
|
3659
|
+
if (pp === 0) {
|
|
3660
|
+
if (qq < 0) return false;
|
|
3661
|
+
continue;
|
|
3662
|
+
}
|
|
3663
|
+
const r = qq / pp;
|
|
3664
|
+
if (pp < 0) t0 = Math.max(t0, r);
|
|
3665
|
+
else t1 = Math.min(t1, r);
|
|
3666
|
+
if (t0 > t1) return false;
|
|
3667
|
+
}
|
|
3668
|
+
return true;
|
|
3669
|
+
};
|
|
3670
|
+
// Points along a box's four sides, its corners left out.
|
|
3671
|
+
const borderPoints = (b, fractions) => {
|
|
3672
|
+
const out = [];
|
|
3673
|
+
for (const f of fractions) {
|
|
3674
|
+
const x = b.x + (b.x2 - b.x) * f;
|
|
3675
|
+
const y = b.y + (b.y2 - b.y) * f;
|
|
3676
|
+
out.push({ x, y: b.y }, { x, y: b.y2 }, { x: b.x, y }, { x: b.x2, y });
|
|
3677
|
+
}
|
|
3678
|
+
return out;
|
|
3679
|
+
};
|
|
3680
|
+
// Text to keep clear: every text drawn so far (cards, box names, badges,
|
|
3681
|
+
// facts, the checkout's own edge labels), a little grown.
|
|
3682
|
+
const textBoxes = [...svg.querySelectorAll("text")]
|
|
3683
|
+
.filter((t) => !t.closest("[data-tg-reads]") && t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim())
|
|
3684
|
+
.map(boxOf)
|
|
3685
|
+
.filter(Boolean)
|
|
3686
|
+
.map((b) => grow(b, 3));
|
|
3687
|
+
const cardEls = [...svg.querySelectorAll("[data-node-id]")];
|
|
3688
|
+
const cardBoxOf = new Map(cardEls.map((g) => [g, boxOf(g)]));
|
|
3689
|
+
const defs = svg.querySelector("defs") || svg.insertBefore(make("defs", {}), svg.firstChild);
|
|
3690
|
+
for (const [id, color] of [["tg-arrow-unknown", "var(--foreign)"], ["tg-arrow-planned", "var(--pending)"], ["tg-arrow-applied", "var(--focus)"]]) {
|
|
3691
|
+
if (svg.querySelector(`#${id}`)) continue;
|
|
3692
|
+
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" });
|
|
3693
|
+
m.appendChild(make("path", { d: "M0,0 L10,5 L0,10 z", fill: color }));
|
|
3694
|
+
defs.appendChild(m);
|
|
3695
|
+
}
|
|
3696
|
+
const labels = [];
|
|
3697
|
+
const drawn = [];
|
|
3457
3698
|
for (const e of edges) {
|
|
3458
3699
|
const card = tgReadCardIds(tgAnswer, e).map((id) => svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]')).find(Boolean);
|
|
3459
|
-
const
|
|
3460
|
-
const
|
|
3700
|
+
const kind = e.unknown.length ? "unknown" : e.outputs === "planned" ? "planned" : "applied";
|
|
3701
|
+
const color = kind === "unknown" ? "var(--foreign)" : kind === "planned" ? "var(--pending)" : "var(--focus)";
|
|
3461
3702
|
if (card) {
|
|
3462
|
-
|
|
3463
|
-
tag.setAttribute("data-tg-reads", "tag");
|
|
3703
|
+
// The tag on the reader's card, at its bottom right, clear of the card's own "root:" line.
|
|
3464
3704
|
const rect = card.querySelector("rect");
|
|
3465
3705
|
const local = rect && rect.getBBox ? rect.getBBox() : null;
|
|
3466
|
-
tag
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3706
|
+
const tag = make("text", { "data-tg-reads": "tag", x: (local ? local.x + local.width : 150) - 10, y: (local ? local.y + local.height : 60) - 9, "text-anchor": "end", "font-size": 11, "font-weight": 700, fill: (card.querySelector("text") && card.querySelector("text").getAttribute("fill")) || "var(--fg)", opacity: 0.8 }, tgReadTag(e));
|
|
3707
|
+
card.append(make("title", { "data-tg-reads": "title" }, tgReadLine(e) + "\n(from terragucci reports, not live)"), tag);
|
|
3708
|
+
}
|
|
3709
|
+
const fromBox = (card && cardBoxOf.get(card)) || (card && boxOf(card)) || rootBox(e.from);
|
|
3710
|
+
const toBox = rootBox(e.to);
|
|
3711
|
+
if (!fromBox || !toBox) continue;
|
|
3712
|
+
const others = cardEls.filter((g) => g !== card).map((g) => cardBoxOf.get(g)).filter(Boolean);
|
|
3713
|
+
let best = null;
|
|
3714
|
+
for (const p0 of borderPoints(fromBox, [0.25, 0.5, 0.75])) {
|
|
3715
|
+
for (const p1 of borderPoints(toBox, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])) {
|
|
3716
|
+
// Pull the ends a hair off the borders, so a line never counts the box it starts or ends on.
|
|
3717
|
+
const len = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
|
3718
|
+
if (len < 20) continue;
|
|
3719
|
+
const ux = (p1.x - p0.x) / len;
|
|
3720
|
+
const uy = (p1.y - p0.y) / len;
|
|
3721
|
+
const a = { x: p0.x + ux * 2, y: p0.y + uy * 2 };
|
|
3722
|
+
const z = { x: p1.x - ux * 2, y: p1.y - uy * 2 };
|
|
3723
|
+
// Leaving the reader inward, or reaching the read box from inside it, is no route.
|
|
3724
|
+
if (crosses({ x: a.x + ux * 4, y: a.y + uy * 4 }, { x: a.x + ux * 4, y: a.y + uy * 4 }, fromBox)) continue;
|
|
3725
|
+
if (crosses({ x: z.x - ux * 4, y: z.y - uy * 4 }, { x: z.x - ux * 4, y: z.y - uy * 4 }, toBox)) continue;
|
|
3726
|
+
const texts = textBoxes.filter((t) => crosses(a, z, t)).length;
|
|
3727
|
+
const cards = others.filter((c) => crosses(a, z, c)).length;
|
|
3728
|
+
const lines = drawn.filter((d) => crosses(a, z, grow({ x: Math.min(d.p0.x, d.p1.x), y: Math.min(d.p0.y, d.p1.y), x2: Math.max(d.p0.x, d.p1.x), y2: Math.max(d.p0.y, d.p1.y) }, 0)) && Math.hypot(d.p1.x - p1.x, d.p1.y - p1.y) < 30).length;
|
|
3729
|
+
const score = texts * 10000 + cards * 2000 + lines * 500 + len;
|
|
3730
|
+
if (!best || score < best.score) best = { p0, p1, score };
|
|
3731
|
+
}
|
|
3476
3732
|
}
|
|
3477
|
-
if (!
|
|
3478
|
-
const
|
|
3479
|
-
line
|
|
3480
|
-
line.
|
|
3481
|
-
|
|
3482
|
-
line.setAttribute("y2", String((to.y + to.y2) / 2));
|
|
3483
|
-
line.setAttribute("stroke", e.unknown.length ? "var(--pending)" : "var(--muted)");
|
|
3484
|
-
line.setAttribute("stroke-width", "2");
|
|
3485
|
-
line.setAttribute("stroke-dasharray", e.outputs === "planned" ? "6 4" : "2 4");
|
|
3486
|
-
line.setAttribute("data-tg-read-from", e.from);
|
|
3487
|
-
line.setAttribute("data-tg-read-to", e.to);
|
|
3488
|
-
const t = document.createElementNS(NS, "title");
|
|
3489
|
-
t.textContent = tgReadLine(e);
|
|
3490
|
-
line.appendChild(t);
|
|
3733
|
+
if (!best) continue;
|
|
3734
|
+
const { p0, p1 } = best;
|
|
3735
|
+
const line = make("line", { x1: p0.x, y1: p0.y, x2: p1.x, y2: p1.y, stroke: color, "stroke-width": 3, "stroke-dasharray": e.outputs === "planned" ? "9 6" : "none", "marker-end": `url(#tg-arrow-${kind})`, "data-tg-read-from": e.from, "data-tg-read-to": e.to });
|
|
3736
|
+
line.appendChild(make("title", {}, tgReadLine(e)));
|
|
3737
|
+
if (focus && !(focus.has(e.from) || focus.has(e.to))) line.style.opacity = "0.5";
|
|
3491
3738
|
layer.appendChild(line);
|
|
3739
|
+
drawn.push({ p0, p1 });
|
|
3740
|
+
labels.push({ e, p0, p1, color, kind });
|
|
3741
|
+
}
|
|
3742
|
+
// Each read's words beside its line: covering no text, crossing no line,
|
|
3743
|
+
// over as few cards and box headers as can be.
|
|
3744
|
+
const cardBoxes = cardEls.map((g) => cardBoxOf.get(g)).filter(Boolean);
|
|
3745
|
+
const headers = boxes.map((b) => ({ x: b.x, y: b.y - 14, x2: b.x2, y2: b.y + 36 }));
|
|
3746
|
+
const placed = [];
|
|
3747
|
+
const overlap = (r, b) => Math.max(0, Math.min(r.x2, b.x2) - Math.max(r.x, b.x)) * Math.max(0, Math.min(r.y2, b.y2) - Math.max(r.y, b.y));
|
|
3748
|
+
const sum = (r, list, k) => list.reduce((a, b) => a + k * overlap(r, b), 0);
|
|
3749
|
+
for (const l of labels) {
|
|
3750
|
+
const words = `${l.e.from} ${tgReadLabel(l.e)}`;
|
|
3751
|
+
const size = 13;
|
|
3752
|
+
const w = words.length * size * 0.56 + 16;
|
|
3753
|
+
const h = size + 10;
|
|
3754
|
+
const dx = l.p1.x - l.p0.x;
|
|
3755
|
+
const dy = l.p1.y - l.p0.y;
|
|
3756
|
+
let best = null;
|
|
3757
|
+
for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
|
|
3758
|
+
const m = { x: l.p0.x + dx * t, y: l.p0.y + dy * t };
|
|
3759
|
+
// Beside the line, then farther off it into open ground: the boxes sit close.
|
|
3760
|
+
const spots = [];
|
|
3761
|
+
for (let k = 0; k < 6; k++) {
|
|
3762
|
+
const off = k * (h + 6);
|
|
3763
|
+
spots.push(
|
|
3764
|
+
{ x: m.x + w / 2 + 12 + off, y: m.y, off },
|
|
3765
|
+
{ x: m.x - w / 2 - 12 - off, y: m.y, off },
|
|
3766
|
+
{ x: m.x, y: m.y - h / 2 - 10 - off, off },
|
|
3767
|
+
{ x: m.x, y: m.y + h / 2 + 10 + off, off },
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
for (const c of spots) {
|
|
3771
|
+
const r = { x: c.x - w / 2, y: c.y - h / 2, x2: c.x + w / 2, y2: c.y + h / 2 };
|
|
3772
|
+
const lineHits = drawn.filter((d) => crosses(d.p0, d.p1, r)).length;
|
|
3773
|
+
// Outside the boxes' own span the label may leave the drawing when the view is fitted: keep it in.
|
|
3774
|
+
const span = union(boxes);
|
|
3775
|
+
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;
|
|
3776
|
+
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;
|
|
3777
|
+
if (!best || score < best.score) best = { c, r, score };
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
placed.push(best.r);
|
|
3781
|
+
const p = pill(layer, best.c.x, best.c.y, words, { stroke: l.color, fill: "var(--bg)", color: "var(--fg)", size, weight: 600 });
|
|
3782
|
+
if (focus && !(focus.has(l.e.from) || focus.has(l.e.to))) p.g.style.opacity = "0.5";
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
// Which run the marks come from, above the boxes.
|
|
3786
|
+
const caption = view.run === "base" ? "nothing in flight: the states went back to the base" : tgViewCaption(drawnAnswer, tgProgress, new Date());
|
|
3787
|
+
const all = union(boxes);
|
|
3788
|
+
if (caption && all) {
|
|
3789
|
+
layer.appendChild(make("text", { "data-tg-reads": "caption", x: all.x, y: all.y - 22, "font-size": 16, "font-weight": 600, fill: "var(--muted)" }, `Showing ${caption}`));
|
|
3492
3790
|
}
|
|
3493
|
-
// Behind every card: first in the svg.
|
|
3494
|
-
svg.insertBefore(layer, svg.firstChild);
|
|
3495
3791
|
}
|
|
3496
3792
|
function markTerragucciCards() {
|
|
3793
|
+
recolorNodesByCategory(); // #569 review: the reports arrived, so the cards may go neutral
|
|
3497
3794
|
const svg = document.querySelector("#graph svg");
|
|
3498
3795
|
tgStampProgress(svg, tgProgress, new Date()); // #511
|
|
3499
3796
|
if (!svg || !tgAnswer || !tgAnswer.cards) return;
|
package/web/embed.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// panel and the inspect pane start folded
|
|
8
8
|
// ?theme=<name> a theme for this frame only (light, dark, or any theme
|
|
9
9
|
// name the picker lists); never saved
|
|
10
|
+
// ?bare=1 with embed: the graph alone, no panel, inspect pane,
|
|
11
|
+
// buttons or now line (#569, the kiosk's frame)
|
|
10
12
|
// ?host=<origin> the parent's origin. Messages go only there, and only
|
|
11
13
|
// messages from there are taken. No host, no messages.
|
|
12
14
|
//
|
|
@@ -35,7 +37,8 @@ export function readEmbed(search) {
|
|
|
35
37
|
}
|
|
36
38
|
const t = (q.get("theme") || "").trim();
|
|
37
39
|
const theme = t ? THEME_ALIASES[t.toLowerCase()] || t : null;
|
|
38
|
-
|
|
40
|
+
const bare = embed && /^(1|true|on|yes)$/i.test(q.get("bare") || "");
|
|
41
|
+
return { embed, host, theme, bare };
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** The message a pick sends the parent. `node` is null when a member's box was
|
|
@@ -61,9 +64,9 @@ export function viewFromMessage(event, host) {
|
|
|
61
64
|
if (typeof v === "boolean") want.radial = v;
|
|
62
65
|
continue;
|
|
63
66
|
}
|
|
64
|
-
if (k === "env" || k === "gates") {
|
|
67
|
+
if (k === "env" || k === "gates" || k === "focus" || k === "run") {
|
|
65
68
|
// env: null or "" is the source graph. gates: null or "" hands the
|
|
66
|
-
// gate strip back to the graph's env.
|
|
69
|
+
// gate strip back to the graph's env. focus: null or "" brings nothing forward. run: null or "" draws the newest run again.
|
|
67
70
|
if (v === null || v === "") want[k] = null;
|
|
68
71
|
else if (typeof v === "string") want[k] = v;
|
|
69
72
|
continue;
|
package/web/embed.test.js
CHANGED
|
@@ -7,8 +7,13 @@ describe("readEmbed (#476)", () => {
|
|
|
7
7
|
embed: true,
|
|
8
8
|
host: "http://localhost:7777",
|
|
9
9
|
theme: THEME_ALIASES.dark,
|
|
10
|
+
bare: false,
|
|
10
11
|
});
|
|
11
12
|
});
|
|
13
|
+
it("reads bare only inside embed (#569)", () => {
|
|
14
|
+
expect(readEmbed("?embed=1&bare=1").bare).toBe(true);
|
|
15
|
+
expect(readEmbed("?bare=1").bare).toBe(false);
|
|
16
|
+
});
|
|
12
17
|
it("takes no host that isn't an http(s) origin", () => {
|
|
13
18
|
expect(readEmbed("?host=localhost").host).toBe(null);
|
|
14
19
|
expect(readEmbed("?host=javascript%3Aalert(1)").host).toBe(null);
|
|
@@ -17,7 +22,7 @@ describe("readEmbed (#476)", () => {
|
|
|
17
22
|
expect(readEmbed("?theme=Atom%20One%20Light").theme).toBe("Atom One Light");
|
|
18
23
|
});
|
|
19
24
|
it("is off by default", () => {
|
|
20
|
-
expect(readEmbed("")).toEqual({ embed: false, host: null, theme: null });
|
|
25
|
+
expect(readEmbed("")).toEqual({ embed: false, host: null, theme: null, bare: false });
|
|
21
26
|
});
|
|
22
27
|
});
|
|
23
28
|
|
|
@@ -49,3 +54,12 @@ describe("viewFromMessage (#476)", () => {
|
|
|
49
54
|
expect(viewFromMessage({ origin: host, data: { type: "behold:view" } }, null)).toBe(null);
|
|
50
55
|
});
|
|
51
56
|
});
|
|
57
|
+
|
|
58
|
+
describe("viewFromMessage focus (#569)", () => {
|
|
59
|
+
const host = "http://localhost:7777";
|
|
60
|
+
it("takes focus as box names, and null or empty as nothing brought forward", () => {
|
|
61
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "network,other" } }, host)).toEqual({ focus: "network,other" });
|
|
62
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: null } }, host)).toEqual({ focus: null });
|
|
63
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "" } }, host)).toEqual({ focus: null });
|
|
64
|
+
});
|
|
65
|
+
});
|
package/web/index.html
CHANGED
|
@@ -412,6 +412,13 @@
|
|
|
412
412
|
.embed #panel.collapsed #panel-tabs button[data-tab] { display: none; }
|
|
413
413
|
.embed #panel.collapsed { width: auto; }
|
|
414
414
|
.embed #panel.collapsed #panel-tabs { gap: 4px; padding: 2px 4px; }
|
|
415
|
+
/* #569: ?embed=1&bare=1, the kiosk's frame: the graph alone. Nothing to
|
|
416
|
+
open, nothing to press, and no backdrop of its own: the page's. */
|
|
417
|
+
.bare #panel, .bare #inspect, .bare #inspect-reopen, .bare #zoom-toggle, .bare #back-to-infra,
|
|
418
|
+
.bare #nowline, .bare #ws-gates, .bare #loading-overlay { display: none !important; }
|
|
419
|
+
.bare #app { grid-template-columns: 1fr 0 !important; }
|
|
420
|
+
.bare #graph svg > rect[fill^="url(#pin-"] { display: none; }
|
|
421
|
+
.bare #graph svg { cursor: default; }
|
|
415
422
|
#panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
|
|
416
423
|
#panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
|
|
417
424
|
font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }
|