1/**
2 * the status of the last build, in the corner of a page that the `Debugger` of
3 * `incremental.ts`
4 * served: a clover, colored by how the build went, or the progress spinner
5 * during one. hovering it says how long ago that was, and clicking it opens
6 * what the build did and its errors. a build that takes a while opens them
7 * with its progress. the page reloads when a build changes what it was
8 * served, unless it has input or plays media, when the clover blinks
9 * instead. a build that left nothing to serve covers the page with its
10 * errors until they are dismissed or fixed.
11 *
12 * @module
13 */
14
15const socketUrl = new URL("/.clover/socket", import.meta.url);
16socketUrl.protocol = "ws:";
17
18const params = new URL(import.meta.url).searchParams;
19/** the hash of the page as it was served, unless asking again would not give it back */
20let shown = params.get("page");
21let status: incremental.BuildStatus | null = null;
22let connected = false;
23/** when the build in progress began */
24let building: number | null = null;
25/** when the latest build began, so what it has not reached is what it may not use */
26let began = 0;
27/** how many steps ran since, which are named as they flash only while they are few */
28let ran = 0;
29/** when a build opened the panel for its progress, which stays long enough to read */
30let showing: number | null = null;
31/** the build whose progress was closed, which does not open it again */
32let quiet: number | null = null;
33/** the debugger's own page, and a page asked for before a build was served, which show the graph */
34const home = location.pathname.replace(/\/+$/, "") === "/.clover" || params.has("graph");
35const savedKey = "clover-debugger";
36/** what the last page of this tab showed as it went away, which a hard navigation shows again */
37let saved = JSON.parse(sessionStorage.getItem(savedKey) ?? "null") as Saved | null;
38/** the panel stays open */
39let open = home || (saved?.open ?? false);
40let maximized = home || (saved?.maximized ?? false);
41let tab: (typeof tabs)[number] = home ? "graph" : saved?.tab ?? "status";
42/** a build changed this page, which still shows what it was */
43let stale = false;
44/** the page has input that a reload would lose */
45let edited = false;
46/** the failed build whose errors were dismissed */
47let dismissed: incremental.BuildStatus | null = null;
48/** the build whose problems are listed */
49let listed: incremental.BuildStatus | null = null;
50/** the problems start folded, as the last fold of every one of them left them */
51let folded = saved?.folded ?? false;
52let timer: ReturnType<typeof setTimeout> | undefined;
53let ticking: ReturnType<typeof setTimeout> | undefined;
54let retry = 100;
55
56const tabs = ["status", "problems", "graph"] as const;
57interface Saved {
58 /** what the debugger's own page shows is not carried to the page after it */
59 open?: boolean;
60 maximized?: boolean;
61 tab?: (typeof tabs)[number];
62 folded: boolean;
63 view: typeof view;
64 alpha: number;
65 points: Array<[id: string, kind: Kind, label: string, x: number, y: number, vx: number, vy: number]>;
66 /** indices into `points` */
67 links: Array<[source: number, target: number]>;
68}
69const titles = {
70 offline: "Reconnecting…",
71 building: "Building…",
72 ok: "Up to date",
73 warnings: "Built with warnings",
74 failed: "Build failed",
75};
76
77type Kind = "step" | "lazy" | "code" | "file" | "directory" | "sqlite" | "output";
78interface Point {
79 id: string;
80 kind: Kind;
81 label: string;
82 /** what its ambient label names: a step's function, a sqlite table, or a file's folder */
83 group: string;
84 /** what last happened to it, and when */
85 state: keyof typeof states | null;
86 changed: number;
87 /** when it appeared, which it grows in from */
88 born: number;
89 x: number;
90 y: number;
91 vx: number;
92 vy: number;
93 /** where the point is held, like while it is dragged */
94 fx: number | null;
95 fy: number | null;
96}
97
98const kinds: Record<Kind, string> = {
99 step: "#ddd",
100 lazy: "#ff9de2",
101 code: "#4a9dff",
102 file: "#0cff10",
103 directory: "#0a8f0c",
104 sqlite: "#b47aff",
105 output: "#ff3bd0",
106};
107const sizes: Record<Kind, number> = { step: 3, lazy: 4, code: 4.5, file: 2.25, directory: 3, sqlite: 3, output: 2.25 };
108/** a flash dims to a third in this many milliseconds, and is gone in five times as many */
109const fade = 300;
110/** how long a change holds its ring before it dims, as the edit that a build follows from */
111const hold = 1500;
112const states: Record<Exclude<incremental.StepEvent["state"], "reused" | "kept"> | "changed", string> = {
113 changed: "#ff8b47",
114 checking: "#fcd330",
115 running: "#3ee6ff",
116 ran: "#fff",
117 failed: "#ff3b3b",
118};
119
120const tabButtons = tabs.map((name) =>
121 `<button type=button role=tab id=tab-${name} aria-controls=view-${name}>${name}</button>`
122).join("");
123
124const host = document.createElement("clover-debugger");
125const shadow = host.attachShadow({ mode: "open" });
126shadow.innerHTML = `<style>${css()}</style>
127<button class=clover type=button aria-label="build status" aria-describedby=tip aria-expanded=false
128 aria-controls=panel>${clover()}<div class=spinner></div></button>
129<div class=live role=status></div>
130<section id=panel class=panel aria-label="build status" hidden>
131 <div class=tabs>
132 <div role=tablist>${tabButtons}</div>
133 <button type=button class=maximize aria-label=maximize aria-pressed=false>
134 <svg viewBox="0 0 12 12" aria-hidden=true>
135 <path class=max d="M1.5 1.5h9v9h-9zM1.5 3h9" />
136 <path class=restore d="M3.5 1.5h7v7M1.5 3.5h7v7h-7zM1.5 5h7" />
137 </svg>
138 </button>
139 </div>
140 <div class=body>
141 <div class=slot></div>
142 <div id=view-status class=view role=tabpanel aria-labelledby=tab-status>
143 <p class=notice>this page changed <button type=button class=reload>reload</button></p>
144 <table><tbody></tbody></table>
145 </div>
146 <div id=view-problems class=view role=tabpanel aria-labelledby=tab-problems>
147 <div class=problems></div><p class=empty>no problems</p>
148 </div>
149 <div id=view-graph class=view role=tabpanel aria-labelledby=tab-graph>
150 <canvas role=img aria-label="the steps of the build, and what they read"></canvas>
151 <div class=legend>${legend()}</div>
152 <div class=point hidden></div>
153 </div>
154 </div>
155</section>
156<dialog aria-labelledby=failed tabindex=-1>
157 <div class=top><h2 id=failed>build failed</h2><button type=button class=dismiss></button></div>
158 <div class=slot></div>
159 <div class=problems></div>
160</dialog>
161<div id=tip class=tip role=tooltip><span class=title></span><span class=line></span></div>`;
162
163const find = <E extends Element = HTMLElement>(selector: string) => UNWRAP(shadow.querySelector<E>(selector));
164const button = find<HTMLButtonElement>(".clover");
165const live = find(".live");
166const panel = find(".panel");
167const maximize = find(".maximize");
168const dialog = find<HTMLDialogElement>("dialog");
169const tree = document.createElement("div");
170
171// a node with no text or total draws as the spinner alone
172const spinner = new progress.Root();
173spinner.start("");
174progress.attachToDocument(spinner, find(".spinner"));
175
176function render() {
177 clearTimeout(timer);
178 const now = performance.now();
179 const state = !connected ? "offline" : building !== null ? "building" : status?.state ?? "offline";
180 const cover = status?.state === "failed" && dismissed !== status;
181
182 // a slow build opens its progress, as does any after errors, and it stays long enough to read
183 const wanted = building !== null && quiet !== building
184 && (now - building >= 500 || (status !== null && status.state !== "ok"));
185 if (wanted) showing ??= now;
186 else if (showing !== null && now - showing >= 1000) showing = null;
187 if (building !== null && !wanted && quiet !== building) timer = setTimeout(render, 500 - (now - building));
188 else if (showing !== null && !wanted) timer = setTimeout(render, 1000 - (now - showing));
189 const visible = open || showing !== null;
190
191 // what the last build did, while nothing newer is on its way
192 const last = connected && building === null ? status : null;
193 button.dataset.state = state;
194 button.classList.toggle("stale", stale);
195 button.setAttribute("aria-expanded", String(visible));
196 const ago = last && string.formatDurationLetters(-Math.round((Date.now() - last.at) / 1000));
197 find(".tip .title").innerHTML = `<b>${title(state)}</b>${ago ? `, last build ${ago}` : ""}`;
198 find(".tip .line").textContent = status?.statusLine ?? "";
199
200 panel.hidden = !visible;
201 panel.dataset.tab = tab;
202 panel.classList.toggle("max", maximized);
203 maximize.setAttribute("aria-pressed", String(maximized));
204 for (const name of tabs) {
205 const tabButton = find(`#tab-${name}`);
206 tabButton.setAttribute("aria-selected", String(name === tab));
207 tabButton.tabIndex = name === tab ? 0 : -1;
208 }
209 // during a build, its progress is its status
210 find("#view-status").hidden = tab !== "status" || (building !== null && !stale);
211 find("#view-problems").hidden = tab !== "problems";
212 find("#view-graph").hidden = tab !== "graph";
213 if (visible && tab === "graph") showGraph();
214
215 const slot = UNWRAP((cover ? dialog : panel).querySelector<HTMLElement>(".slot"));
216 if (tree.parentElement !== slot) slot.append(tree);
217 for (const each of shadow.querySelectorAll<HTMLElement>(".slot")) each.hidden = building === null;
218 if (cover && !dialog.open) {
219 dialog.showModal();
220 // the errors take the focus, rather than the button that hides them
221 dialog.focus();
222 } else if (!cover && dialog.open) {
223 dialog.close();
224 }
225
226 find(".dismiss").textContent = status?.earlier ? "[view previous output]" : "[dismiss]";
227 find(".notice").hidden = !stale;
228 find("table").hidden = building !== null;
229 const rows: [string, string][] = [[
230 "state",
231 `<span data-state=${last?.state ?? state}>${title(last?.state ?? state)}</span>`,
232 ]];
233 if (last) {
234 const { stats, changed } = last;
235 rows.push(["time", duration(stats.duration)], ["steps", `${stats.ran} ran, ${stats.reused} reused`]);
236 rows.push(["written", `${stats.written} ${stats.written === 1 ? "file" : "files"}`]);
237 if (changed.length > 0) {
238 const files = changed.slice(0, 8).map(string.escapeHtmlContent).join("<br>");
239 const more = changed.length > 8 ? `<br>and ${changed.length - 8} more` : "";
240 rows.push(["changed", `<span class=mono>${files}${more}</span>`]);
241 }
242 rows.push(["cache", string.formatByteSize(stats.cacheBytes)], ["output", string.formatByteSize(stats.outputBytes)]);
243 }
244 const table = rows.map(([label, html]) => `<tr><th scope=row>${label}</th><td>${html}</td></tr>`);
245 find("tbody").innerHTML = table.join("");
246
247 const problems = status?.errors ?? [];
248 find("#view-problems .empty").hidden = problems.length > 0;
249 // what a reader folded stays folded until a build says something new
250 if (listed !== status) {
251 listed = status;
252 const html = fold + problems.map((problem) => diagnostic(problem)).join("");
253 for (const list of shadow.querySelectorAll<HTMLElement>(".problems")) {
254 list.hidden = problems.length === 0;
255 list.innerHTML = html;
256 }
257 showFold();
258 }
259}
260
261/** a button that folds every problem, or unfolds them all once none is open */
262const fold = `<button type=button class=fold><svg viewBox="0 0 12 12" aria-hidden=true>
263 <path d="M1.5 1.5h9v9h-9zM3.5 6h5" /><path class=unfold d="M6 3.5v5" /></svg></button>`;
264
265function showFold() {
266 const open = shadow.querySelector("details.diagnostic[open]") !== null;
267 for (const button of shadow.querySelectorAll<HTMLElement>(".fold")) {
268 button.classList.toggle("folded", !open);
269 button.setAttribute("aria-label", open ? "fold every problem" : "unfold every problem");
270 }
271}
272
273const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
274
275/** what a state is called, which for a build served with problems counts them */
276function title(state: keyof typeof titles): string {
277 if (state !== "warnings" || !status) return titles[state];
278 const severities = status.errors.flatMap(({ severity, similar }) => Array<string>(1 + similar.length).fill(severity));
279 const thrown = severities.filter((severity) => severity === "error").length;
280 const counts = [[thrown, "error"], [severities.length - thrown, "warning"]] as const;
281 const said = counts.filter(([n]) => n > 0).map(([n, noun]) => `${n} ${noun}${n === 1 ? "" : "s"}`);
282 return said.length > 0 ? `Built with ${said.join(" and ")}` : titles.warnings;
283}
284
285// -- problems --
286
287/**
288 * an error or a warning, laid out like the terminal prints it, with its stack
289 * folded. it folds itself down to its message and where it is.
290 */
291function diagnostic(of: stack.Diagnostic): string {
292 const head = (label: string, text: string) =>
293 `<b>${string.escapeHtmlContent(label)}</b><span class=colon>:</span> ${string.escapeHtmlContent(text)}`;
294 const where = of.location
295 ? `<span class=where>${string.escapeHtmlContent((of.location.file ?? unknown) + positionOf(of.location))}</span>`
296 : "";
297 let html = `<summary class=head>${head(of.label, of.message)}${where}</summary>`;
298 if (of.location) html += excerpt(of.location, true);
299 if (of.fields.length > 0) {
300 const fields = of.fields.map(([name, json]) =>
301 `<div><dt>${string.escapeHtmlContent(name)}</dt><dd>${string.escapeHtmlContent(json)}</dd></div>`
302 );
303 html += `<dl class=fields>${fields.join("")}</dl>`;
304 }
305 for (const note of of.notes) {
306 html += note.frames.length > 0
307 ? `<details class=note><summary>${head("note", note.text)}</summary>`
308 + `<div class=frames>${note.frames.map((frame) => excerpt(frame, false)).join("")}</div></details>`
309 : `<div class=note><p class=head>${head("note", note.text)}</p>`
310 + `${note.location ? excerpt(note.location, false) : ""}</div>`;
311 }
312 if (of.cause) {
313 html += `<div class=note><p class=head>${head("note", "caused by")}</p>`
314 + `<div class=nested>${diagnostic(of.cause)}</div></div>`;
315 }
316 for (const error of of.errors) html += `<div class=nested>${diagnostic(error)}</div>`;
317 if (of.similar.length > 0) {
318 const items = of.similar.map(({ message, from }) =>
319 `<li>${string.escapeHtmlContent(message)} <span class=from>${string.escapeHtmlContent(from)}</span></li>`
320 );
321 html += `<details class=note><summary>and ${of.similar.length} more like it</summary>`
322 + `<ul class=similar>${items.join("")}</ul></details>`;
323 }
324 return `<details class="diagnostic ${of.severity}"${folded ? "" : " open"}>${html}</details>`;
325}
326
327const unknown = "[unknown source]";
328
329/** where a place is in its file: `:line:col`, or an anchor in a url */
330function positionOf({ file, line, col }: stack.Excerpt): string {
331 if (!line) return "";
332 return file?.startsWith("https://") ? `#L${line}` : `:${line}${col ? `:${col}` : ""}`;
333}
334
335/** a place and its code: the line it points at, and with `context`, the lines around it, faded */
336function excerpt(place: stack.Excerpt, context: boolean): string {
337 const { file, line, fn, code, first, span, internal } = place;
338 const url = file?.startsWith("https://") ? file + positionOf(place) : null;
339 // the source of a runtime's own code is a link away
340 const named = url
341 ? `<a href="${string.escapeHtmlAttr(url)}" target=_blank>${string.escapeHtmlContent(url)}</a>`
342 : string.escapeHtmlContent(file ?? unknown) + `<span class=pos>${positionOf(place)}</span>`;
343 const where = named + (fn ? ` at <span class=fn>${string.escapeHtmlContent(fn)}</span>` : "");
344
345 // blank lines around the line it points at say nothing
346 const shown = code.map((text, i) => ({ text, n: first + i }))
347 .filter(({ text, n }) => n === line || (context && text.trim()));
348 // the lines keep their indentation relative to each other
349 const indents = shown.filter(({ text }) => text.trim()).map(({ text }) => /^\s*/.exec(text)![0].length);
350 const indent = indents.length > 0 ? Math.min(...indents) : 0;
351 const digits = String(shown.at(-1)?.n ?? 0).length;
352 // the lines around fade away from it
353 const side = (n: number) => n === line ? " hit" : n < line! ? " before" : " after";
354 const lines = shown.map(({ text, n }) =>
355 `<span class="line${side(n)}"><span class=n style="min-width:${digits}ch">${n}</span>`
356 + `${codeLine(text, status?.tokens[`${file}:${n}`] ?? null, n === line ? span : null, indent)}</span>`
357 );
358 return `<figure class="excerpt${internal ? " internal" : ""}"><figcaption>${where}</figcaption>`
359 + (lines.length > 0 ? `<pre>${lines.join("")}</pre>` : "") + "</figure>";
360}
361
362/** a longer line, like minified code, shows this much around what it points at */
363const widest = 160;
364
365/** a line of code from `indent`, colored by its tokens, with what `mark` covers underlined */
366function codeLine(text: string, tokens: string | null, mark: stack.Excerpt["span"], indent: number): string {
367 let from = indent;
368 let to = text.length;
369 if (to - from > widest) {
370 from = Math.max(from, Math.min((mark?.[0] ?? from) - widest / 4, to - widest));
371 to = from + widest;
372 }
373 const kind = (i: number) => tokens?.[i]?.trim() ?? "";
374 const marked = (i: number) => !!mark && i >= mark[0] && i < mark[1];
375 let html = from > indent ? "…" : "";
376 for (let i = from; i < to;) {
377 let j = i + 1;
378 while (j < to && kind(j) === kind(i) && marked(j) === marked(i)) j++;
379 let piece = string.escapeHtmlContent(text.slice(i, j));
380 if (kind(i)) piece = `<span class=t-${kind(i)}>${piece}</span>`;
381 html += marked(i) ? `<span class=mark>${piece}</span>` : piece;
382 i = j;
383 }
384 return html + (to < text.length ? "…" : "");
385}
386
387// -- the layout --
388
389/**
390 * a force-directed layout, as d3-force lays one out: points push each other
391 * apart, links pull their ends to a distance, and a weak pull to the center
392 * keeps what nothing links from drifting off. each tick moves things less than
393 * the last, until they settle. the push between every pair of points is summed
394 * with a quadtree (barnes-hut), where a square of points that is far enough
395 * away pushes as one, so a tick costs n log n.
396 */
397class Simulation {
398 nodes: Point[] = [];
399 links: Link[] = [];
400 /** how far a tick moves things, which decays toward `alphaTarget` */
401 alpha = 1;
402 alphaTarget = 0;
403 #strengths: number[] = [];
404 #biases: number[] = [];
405
406 /** start laying out `nodes`, placing those without a position on a spiral around the origin */
407 set(nodes: Point[], links: Link[]): void {
408 this.nodes = nodes;
409 this.links = links;
410 nodes.forEach((node, i) => {
411 if (Number.isFinite(node.x) && Number.isFinite(node.y)) return;
412 const radius = 10 * Math.sqrt(0.5 + i);
413 const angle = i * Math.PI * (3 - Math.sqrt(5));
414 node.x = radius * Math.cos(angle);
415 node.y = radius * Math.sin(angle);
416 });
417
418 // a link between busy nodes pulls weakly, and moves the busier end less
419 const degree = new Map<Point, number>();
420 for (const { source, target } of links) {
421 degree.set(source, (degree.get(source) ?? 0) + 1);
422 degree.set(target, (degree.get(target) ?? 0) + 1);
423 }
424 this.#strengths = links.map(({ source, target }) => 1 / Math.min(degree.get(source)!, degree.get(target)!));
425 this.#biases = links.map(({ source, target }) => {
426 const from = degree.get(source)!;
427 return from / (from + degree.get(target)!);
428 });
429 }
430
431 /** move everything once, returning whether it is still moving */
432 tick(): boolean {
433 if (this.alpha < alphaMin && this.alphaTarget < alphaMin) return false;
434 const alpha = this.alpha += (this.alphaTarget - this.alpha) * alphaDecay;
435
436 this.links.forEach(({ source, target }, i) => {
437 let x = target.x + target.vx - source.x - source.vx || jiggle();
438 let y = target.y + target.vy - source.y - source.vy || jiggle();
439 const length = Math.sqrt(x * x + y * y);
440 const pull = (length - linkDistance) / length * alpha * this.#strengths[i]!;
441 x *= pull;
442 y *= pull;
443 const bias = this.#biases[i]!;
444 target.vx -= x * bias;
445 target.vy -= y * bias;
446 source.vx += x * (1 - bias);
447 source.vy += y * (1 - bias);
448 });
449
450 const tree = this.#quadtree();
451 for (const node of this.nodes) {
452 if (tree) this.#push(node, tree.quad, tree.size, alpha);
453 node.vx -= node.x * gravity * alpha;
454 node.vy -= node.y * gravity * alpha;
455 }
456
457 for (const node of this.nodes) {
458 if (node.fx === null) node.x += node.vx *= friction;
459 else node.x = node.fx, node.vx = 0;
460 if (node.fy === null) node.y += node.vy *= friction;
461 else node.y = node.fy, node.vy = 0;
462 }
463 return true;
464 }
465
466 /** the nearest node to a point, if it is within `radius` */
467 find(x: number, y: number, radius: number): Point | null {
468 let found: Point | null = null;
469 let nearest = radius * radius;
470 for (const node of this.nodes) {
471 const distance = (node.x - x) ** 2 + (node.y - y) ** 2;
472 if (distance < nearest) found = node, nearest = distance;
473 }
474 return found;
475 }
476
477 /** a quadtree over every node, in a square that covers them */
478 #quadtree(): { quad: Quad; size: number } | null {
479 if (this.nodes.length === 0) return null;
480 let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
481 for (const { x, y } of this.nodes) {
482 x0 = Math.min(x0, x), y0 = Math.min(y0, y), x1 = Math.max(x1, x), y1 = Math.max(y1, y);
483 }
484 const size = Math.max(x1 - x0, y1 - y0, 1);
485 return { quad: this.#quad(this.nodes, x0, y0, size), size };
486 }
487
488 #quad(nodes: Point[], x0: number, y0: number, size: number): Quad {
489 const first = nodes[0]!;
490 const together = nodes.every((node) => node.x === first.x && node.y === first.y);
491 // squares stop splitting at a fraction of a pixel, where nodes are as good as together
492 if (together || size < 1e-3) {
493 return { nodes, children: null, charge: pointCharge * nodes.length, x: first.x, y: first.y };
494 }
495
496 const half = size / 2;
497 const parts: Point[][] = [[], [], [], []];
498 for (const node of nodes) parts[(node.x < x0 + half ? 0 : 1) + (node.y < y0 + half ? 0 : 2)]!.push(node);
499 const children = parts.map((part, i) =>
500 part.length === 0 ? null : this.#quad(part, x0 + (i & 1) * half, y0 + (i >> 1) * half, half)
501 );
502
503 let charge = 0, x = 0, y = 0;
504 for (const child of children) {
505 if (!child) continue;
506 charge += child.charge, x += child.x * child.charge, y += child.y * child.charge;
507 }
508 return { nodes: null, children, charge, x: x / charge, y: y / charge };
509 }
510
511 /** push `node` away from the nodes in `quad`, as one when the square is far enough */
512 #push(node: Point, quad: Quad, size: number, alpha: number): void {
513 let x = quad.x - node.x;
514 let y = quad.y - node.y;
515 let distance = x * x + y * y;
516
517 if (quad.children) {
518 if (size * size / (theta * theta) < distance) {
519 if (distance < 1) distance = Math.sqrt(distance);
520 node.vx += x * quad.charge * alpha / distance;
521 node.vy += y * quad.charge * alpha / distance;
522 return;
523 }
524 for (const child of quad.children) if (child) this.#push(node, child, size / 2, alpha);
525 return;
526 }
527
528 for (const other of UNWRAP(quad.nodes)) {
529 if (other === node) continue;
530 // nodes in one place are pushed apart in some direction
531 if (x === 0) x = jiggle(), distance += x * x;
532 if (y === 0) y = jiggle(), distance += y * y;
533 // very close nodes push like they are a pixel apart, which keeps them from flying off
534 if (distance < 1) distance = Math.sqrt(distance);
535 node.vx += x * pointCharge * alpha / distance;
536 node.vy += y * pointCharge * alpha / distance;
537 }
538 }
539}
540
541/** from a step to what it read or wrote */
542interface Link {
543 source: Point;
544 target: Point;
545}
546
547/** a square of the quadtree, with the charge of the points in it and their center */
548interface Quad {
549 /** the points of a leaf, which share a position or are alone in their square */
550 nodes: Point[] | null;
551 children: Array<Quad | null> | null;
552 charge: number;
553 x: number;
554 y: number;
555}
556
557/** below this, nothing moves enough to tick */
558const alphaMin = 0.001;
559/** a layout settles in 300 ticks */
560const alphaDecay = 1 - Math.pow(0.001, 1 / 300);
561/** the share of its velocity that a point keeps each tick */
562const friction = 0.6;
563/** the push of each point, negative to repel */
564const pointCharge = -30;
565/** how far apart a link holds its ends */
566const linkDistance = 30;
567/** how strongly everything is pulled to the origin */
568const gravity = 0.05;
569/** how small a square must look from a point to push as one */
570const theta = 0.9;
571const jiggle = () => (Math.random() - 0.5) * 1e-6;
572
573// -- the graph: steps, the code that they run, and the inputs that they read --
574
575const canvas = find<HTMLCanvasElement>("canvas");
576const context = UNWRAP(canvas.getContext("2d"));
577const pointLabel = find(".point");
578const simulation = new Simulation();
579const points = new Map<string, Point>();
580/** what kind each input was, which decides whether it is drawn */
581const inputKinds = new Map<string, string>();
582/** what more than half of the steps read, like the build script, which is drawn without its links */
583const shared = new Set<string>();
584/** the project's url, which the paths of its files are relative to */
585let projectUrl = "";
586let loading: Promise<void> | null = null;
587/** what happened to steps, shown a little at a time, so a quick build still sweeps across */
588const sweep: incremental.StepEvent[] = [];
589/** data on its way along a link, from what was read to the step that read it, or from a step to what it wrote */
590let pulses: Array<{ link: Link; at: number }> = [];
591/** what a step stopped reading, and what the graph lost, which fade out in red */
592let goneLinks: Array<{ link: Link; at: number }> = [];
593let gonePoints: Array<{ point: Point; at: number }> = [];
594/** the pan, in pixels from the center, and the zoom */
595const view = { x: 0, y: 0, k: 1 };
596let hovered: Point | null = null;
597let dragging: { point: Point | null; x: number; y: number } | null = null;
598let frame = 0;
599let drawn = 0;
600
601if (saved) {
602 const restored = saved.points.map(([id, kind, label, x, y, vx, vy]) =>
603 Object.assign(place(id, kind, label), { x, y, vx, vy, born: -Infinity })
604 );
605 simulation.set(
606 restored,
607 saved.links.map(([source, target]) => ({ source: restored[source]!, target: restored[target]! })),
608 );
609 simulation.alpha = saved.alpha;
610 Object.assign(view, saved.view);
611}
612addEventListener("pagehide", () => {
613 const index = new Map(simulation.nodes.map((point, i) => [point, i]));
614 const links = simulation.links.flatMap(({ source, target }) => {
615 const ends = [index.get(source), index.get(target)];
616 return ends.every((end) => end !== undefined) ? [ends as [number, number]] : [];
617 });
618 sessionStorage.setItem(
619 savedKey,
620 JSON.stringify(
621 {
622 ...(!home && { open, maximized, tab }),
623 folded,
624 view,
625 alpha: simulation.alpha,
626 points: simulation.nodes.map(({ id, kind, label, x, y, vx, vy }) => [id, kind, label, x, y, vx, vy]),
627 links,
628 } satisfies Saved,
629 ),
630 );
631});
632
633/** a glow in the color of each kind, which dims exponentially away from its center */
634const halos = Object.fromEntries(
635 Object.entries(kinds).map(([kind, color]) => {
636 const sprite = document.createElement("canvas");
637 sprite.width = sprite.height = 64;
638 const sprites = UNWRAP(sprite.getContext("2d"));
639 const falloff = sprites.createRadialGradient(32, 32, 0, 32, 32, 32);
640 for (let i = 0; i <= 16; i++) falloff.addColorStop(i / 16, `rgb(255 255 255 / ${Math.exp(-6 * i / 16)})`);
641 sprites.fillStyle = falloff;
642 sprites.fillRect(0, 0, 64, 64);
643 sprites.globalCompositeOperation = "source-in";
644 sprites.fillStyle = color;
645 sprites.fillRect(0, 0, 64, 64);
646 return [kind, sprite];
647 }),
648) as Record<Kind, HTMLCanvasElement>;
649/** where the far glows add up, at a quarter of the size, in floats so that the faint ones still add */
650const glow = new OffscreenCanvas(1, 1);
651const glows = UNWRAP(glow.getContext("2d", { colorType: "float16" }));
652
653/** the steps that find, load, and read modules fold into the code that they close over */
654function stepKind(key: string, lazy: boolean): Kind | null {
655 if (key.startsWith("closure:")) return "code";
656 if (/^(load|resolve|package):/.test(key)) return null;
657 return lazy ? "lazy" : "step";
658}
659
660/** a probe for a file that is not there, and the graph's own options, are not drawn */
661function inputKind(url: string): Kind | null {
662 const kind = inputKinds.get(url) ?? "file";
663 return kind === "file" || kind === "directory" || kind === "sqlite" ? kind : null;
664}
665
666/** a file's path in the project, or the query of a row */
667function labelOf(url: string, kind: Kind): string {
668 const decoded = url.startsWith(projectUrl) ? decodeURI(url.slice(projectUrl.length)) : decodeURIComponent(url);
669 if (kind !== "sqlite") return decoded;
670 try {
671 const { sql, args } = JSON.parse(decoded.slice(decoded.indexOf(":") + 1)) as { sql: string; args: unknown[] };
672 return `${sql} ${JSON.stringify(args)}`;
673 } catch {
674 return decoded;
675 }
676}
677
678function groupOf(kind: Kind, label: string): string {
679 if (kind === "step" || kind === "lazy") return label.split(" at ")[0]!;
680 if (kind === "sqlite") return /from "?(\w+)/i.exec(label)?.[1] ?? "sqlite";
681 const file = kind === "code" ? label.slice(label.indexOf(" ") + 1) : label;
682 return file.split("/").slice(0, 2).join("/");
683}
684
685/** `previous` is the point it takes over, whose place it keeps */
686function place(id: string, kind: Kind, label: string, previous = points.get(id)): Point {
687 const point = previous ?? {
688 id,
689 kind,
690 label,
691 group: "",
692 x: NaN,
693 y: NaN,
694 vx: 0,
695 vy: 0,
696 fx: null,
697 fy: null,
698 state: null,
699 changed: 0,
700 born: performance.now(),
701 };
702 Object.assign(point, { id, kind, label, group: groupOf(kind, label) });
703 points.set(id, point);
704 return point;
705}
706
707/** links from a step to the steps and inputs that it read, and to the files that it wrote */
708function wire(
709 point: Point,
710 steps: string[],
711 inputs: string[],
712 outputs: string[],
713 reuse?: Map<string, Point>,
714): Link[] {
715 const links: Link[] = [];
716 for (const key of steps) {
717 const target = points.get(key);
718 if (target) links.push({ source: point, target });
719 }
720 // a module's code reads its source, which the step that loads it read
721 const source = point.kind === "code" ? point.id.slice("closure:".length) : "";
722 for (const url of inputKinds.has(source) && !inputs.includes(source) ? [...inputs, source] : inputs) {
723 const kind = inputKind(url);
724 if (kind) {
725 links.push({ source: point, target: points.get(url) ?? place(url, kind, labelOf(url, kind), reuse?.get(url)) });
726 }
727 }
728 for (const url of outputs) {
729 links.push({
730 source: point,
731 target: points.get(url) ?? place(url, "output", labelOf(url, "output"), reuse?.get(url)),
732 });
733 }
734 return links;
735}
736
737/** links to what more than half of the steps read, which would pull everything into one knot, are not drawn */
738function prune(links: Link[]): Link[] {
739 let steps = 0;
740 for (const point of points.values()) if (point.kind === "step" || point.kind === "lazy") steps++;
741 const readers = new Map<string, number>();
742 for (const { target } of links) readers.set(target.id, (readers.get(target.id) ?? 0) + 1);
743 for (const [id, count] of readers) if (steps >= 20 && count > steps / 2) shared.add(id);
744 return links.filter((link) => !shared.has(link.target.id));
745}
746
747/**
748 * lay out `links`, starting each new point about where they will hold it, so it
749 * barely moves to get there: on the ring that its kind makes around the busiest
750 * point it links to, facing the other points it links to
751 */
752function settle(links: Link[]) {
753 const kept = prune(links);
754 const placed = (point: Point) => !Number.isNaN(point.x);
755 const near = new Map<Point, Point[]>();
756 const mean = (list: Point[], of: (point: Point) => number) =>
757 list.reduce((sum, point) => sum + of(point), 0) / list.length;
758 const fresh = links.some(({ source, target }) => !placed(source) || !placed(target));
759 for (const { source, target } of fresh ? links : []) {
760 if (!near.has(source)) near.set(source, []);
761 if (!near.has(target)) near.set(target, []);
762 near.get(source)!.push(target);
763 near.get(target)!.push(source);
764 }
765 for (let more = fresh; more;) {
766 more = false;
767 for (const [point, around] of near) {
768 // what most steps read has no links to hold anything
769 const anchors = placed(point) ? [] : around.filter((other) => placed(other) && !shared.has(other.id));
770 if (anchors.length === 0) continue;
771 more = true;
772 const hub = anchors.reduce((busiest, anchor) =>
773 near.get(anchor)!.length > near.get(busiest)!.length ? anchor : busiest
774 );
775 const others = anchors.filter((anchor) => anchor !== hub);
776 const ring = near.get(hub)!.filter((other) => other.kind === point.kind && placed(other));
777 const reach = ring.length > 0
778 ? mean(ring, (other) => Math.hypot(other.x - hub.x, other.y - hub.y))
779 : linkDistance;
780 const angle = others.length > 0
781 ? Math.atan2(mean(others, (other) => other.y) - hub.y, mean(others, (other) => other.x) - hub.x)
782 + Math.random() - 0.5
783 : Math.random() * 2 * Math.PI;
784 point.x = hub.x + reach * Math.cos(angle);
785 point.y = hub.y + reach * Math.sin(angle);
786 }
787 }
788 // a point that links to nothing yet waits for its links, rather than starting out in the middle
789 simulation.set(Array.from(points.values()).filter((point) => placed(point) || near.has(point)), kept);
790 simulation.alpha = Math.max(simulation.alpha, 0.3);
791}
792
793const linkId = ({ source, target }: Link) => `${source.id} ${target.id}`;
794
795/**
796 * the point of a new step, which is the point of the step it replaces when
797 * just one of `lost` is from the same code and read what it reads, `links`
798 * being theirs. the files the new step writes take the places of as many
799 * that one wrote.
800 */
801function succeed(
802 point: Point,
803 reads: Link[],
804 outputs: string[],
805 lost: Point[],
806 links: Link[],
807): Point {
808 const read = (from: Point, links: Link[]) =>
809 links.filter(({ source, target }) => source === from && target.kind !== "output" && !shared.has(target.id))
810 .map(({ target }) => target.id).sort().join(" ");
811 const wanted = read(point, reads);
812 const same = lost.filter((other) =>
813 other !== point && other.kind === point.kind && other.label === point.label && read(other, links) === wanted
814 );
815 if (same.length !== 1) return point;
816 const heir = same[0]!;
817 const wrote = links.filter((link) => link.source === heir && link.target.kind === "output").map((link) =>
818 link.target
819 );
820 points.delete(heir.id);
821 place(point.id, point.kind, point.label, heir);
822 if (wrote.length === outputs.length && !outputs.some((url) => points.has(url))) {
823 wrote.forEach((file, i) => {
824 points.delete(file.id);
825 place(outputs[i]!, "output", labelOf(outputs[i]!, "output"), file);
826 });
827 }
828 return heir;
829}
830
831async function loadGraph() {
832 const response = await fetch("/.clover/graph", { cache: "no-store" });
833 const snapshot = await response.json() as incremental.GraphSnapshot;
834 projectUrl = snapshot.root;
835 for (const input of snapshot.inputs) inputKinds.set(input.url, input.kind);
836 const previous = new Map(points);
837 points.clear();
838 for (const step of snapshot.steps) {
839 const kind = stepKind(step.key, step.lazy);
840 if (kind) place(step.key, kind, step.label, previous.get(step.key));
841 }
842 const before = simulation.links;
843 // a step new to the graph may replace one that the graph lost
844 const lost = Array.from(previous.values()).filter((point) => !points.has(point.id));
845 for (const step of snapshot.steps) {
846 const point = points.get(step.key);
847 if (!point || !Number.isNaN(point.x) || step.lazy) continue;
848 const reads = wire(point, step.steps, step.inputs, [], previous);
849 succeed(point, reads, step.outputs, lost.filter((other) => !points.has(other.id)), before);
850 }
851 // what most steps read is counted again from the whole graph, as a build sees only what it ran
852 shared.clear();
853 settle(snapshot.steps.flatMap((step) => {
854 const point = points.get(step.key);
855 return point ? wire(point, step.steps, step.inputs, step.outputs, previous) : [];
856 }));
857 // a graph carried over from the last page moves on as it was
858 if (saved) simulation.alpha = saved.alpha, saved = null;
859 // a graph mostly unseen is laid out before it shows, rather than unfolding
860 if (points.size > 2 * previous.size) {
861 const end = performance.now() + 150;
862 while (performance.now() < end && simulation.tick()) {}
863 }
864
865 // what the graph lost since it was last read
866 const now = performance.now();
867 for (const point of previous.values()) if (!points.has(point.id)) gonePoints.push({ point, at: now });
868 const kept = new Set(simulation.links.map(linkId));
869 for (const link of before) {
870 if (!kept.has(linkId(link)) && !shared.has(link.target.id)) goneLinks.push({ link, at: now });
871 }
872}
873
874function showGraph() {
875 loading ??= loadGraph().catch(() => void (loading = null));
876 frame ||= requestAnimationFrame(draw);
877}
878
879function draw() {
880 frame = 0;
881 if (panel.hidden || tab !== "graph") return void sweep.splice(0);
882 const now = performance.now();
883 // the same share of the queue drains in the same time, however often frames come
884 const share = 1 - Math.exp(-(now - drawn) / 300);
885 drawn = now;
886 for (const event of sweep.splice(0, Math.ceil(sweep.length * share))) {
887 // what the steps that load modules read are the sources of the code they fold into
888 if ("inputs" in event) { for (const { url, kind } of event.inputs) inputKinds.set(url, kind); }
889 const kind = stepKind(event.key, event.lazy);
890 if (!kind) continue;
891 let point = points.get(event.key) ?? place(event.key, kind, event.label);
892 if ("steps" in event) {
893 const inputs = event.inputs.map((input) => input.url);
894 if (Number.isNaN(point.x) && !event.lazy) {
895 // what the build has not reached is what it may not use
896 const unused = simulation.nodes.filter((other) => other.changed < began);
897 point = succeed(point, wire(point, event.steps, inputs, []), event.outputs, unused, simulation.links);
898 }
899
900 // what a step read when it ran replaces what it read before, and flows into it, and out into what it wrote
901 const reads = wire(point, event.steps, inputs, event.outputs);
902 const still = new Set(reads.map(linkId));
903 for (const link of simulation.links) {
904 if (link.source === point && !still.has(linkId(link))) goneLinks.push({ link, at: now });
905 }
906 settle(simulation.links.filter((link) => link.source !== point).concat(reads));
907 for (const link of reads) {
908 if (shared.has(link.target.id)) continue;
909 pulses.push({ link, at: now });
910 if (link.target.kind === "output") Object.assign(link.target, { state: event.state, changed: now });
911 }
912 }
913 // a rebuild reuses or keeps nearly every step, often right after one ran, so that only ends a check
914 if (event.state !== "reused" && event.state !== "kept") {
915 point.state = event.state;
916 point.changed = now;
917 } else if (point.state === "checking" || point.state === "running" || now - point.changed > 5 * fade) {
918 point.state = null;
919 point.changed = now;
920 }
921 }
922 simulation.tick();
923 paint(now);
924 frame = requestAnimationFrame(draw);
925}
926
927function paint(now: number) {
928 const { width, height } = canvas.getBoundingClientRect();
929 // an empty glow canvas throws when drawn, and an empty canvas shows nothing anyway
930 if (!width || !height) return;
931 const scale = devicePixelRatio;
932 if (canvas.width !== Math.round(width * scale) || canvas.height !== Math.round(height * scale)) {
933 canvas.width = Math.round(width * scale);
934 canvas.height = Math.round(height * scale);
935 glow.width = Math.ceil(width / 4);
936 glow.height = Math.ceil(height / 4);
937 }
938
939 // each point glows faintly far off, so a kind tints where it gathers, and a little brighter beside it
940 glows.setTransform(1, 0, 0, 1, 0, 0);
941 glows.clearRect(0, 0, glow.width, glow.height);
942 glows.setTransform(view.k / 4, 0, 0, view.k / 4, (width / 2 + view.x) / 4, (height / 2 + view.y) / 4);
943 glows.globalCompositeOperation = "lighter";
944 glows.globalAlpha = 0.01;
945 for (const { kind, x, y } of simulation.nodes) glows.drawImage(halos[kind], x - 250, y - 250, 500, 500);
946 context.setTransform(1, 0, 0, 1, 0, 0);
947 context.fillStyle = "#000";
948 context.fillRect(0, 0, canvas.width, canvas.height);
949 context.drawImage(glow, 0, 0, canvas.width, canvas.height);
950 const zoom = scale * view.k;
951 context.setTransform(zoom, 0, 0, zoom, scale * (width / 2 + view.x), scale * (height / 2 + view.y));
952 context.globalCompositeOperation = "lighter";
953 context.globalAlpha = 0.1;
954 for (const { kind, x, y } of simulation.nodes) context.drawImage(halos[kind], x - 30, y - 30, 60, 60);
955 context.globalCompositeOperation = "source-over";
956 context.globalAlpha = 1;
957
958 const stroke = (links: Iterable<Link>, color: string, width: number) => {
959 context.lineWidth = width / view.k;
960 context.strokeStyle = color;
961 context.beginPath();
962 for (const { source, target } of links) {
963 context.moveTo(source.x, source.y);
964 context.lineTo(target.x, target.y);
965 }
966 context.stroke();
967 };
968 const touching = simulation.links.filter((link) => link.source === hovered || link.target === hovered);
969 const near = new Set(touching.flatMap((link) => [link.source, link.target]));
970 stroke(simulation.links, hovered ? "#ffffff18" : "#ffffff45", 1);
971 stroke(touching, "#fffc", 1.5);
972
973 // what is gone flashes red as it fades
974 goneLinks = goneLinks.filter(({ at }) => now - at < 5 * fade);
975 for (const { link, at } of goneLinks) {
976 context.globalAlpha = Math.exp(-(now - at) / fade);
977 stroke([link], states.failed, 2.5);
978 }
979 // data runs from what was read to the step that read it, each end easing in as if lerped there each frame
980 pulses = pulses.filter(({ at }) => now - at < 5 * fade);
981 context.lineWidth = 2.5 / view.k;
982 context.strokeStyle = "#fff";
983 for (const { link: { source, target }, at } of pulses) {
984 const [from, to] = target.kind === "output" ? [source, target] : [target, source];
985 const head = 1 - Math.exp(-(now - at) / 80);
986 const tail = 1 - Math.exp(-(now - at) / 240);
987 const dx = to.x - from.x;
988 const dy = to.y - from.y;
989 context.globalAlpha = Math.exp(-(now - at) / fade);
990 context.beginPath();
991 context.moveTo(from.x + dx * tail, from.y + dy * tail);
992 context.lineTo(from.x + dx * head, from.y + dy * head);
993 context.stroke();
994 }
995
996 for (const point of simulation.nodes) {
997 const grown = 1 - Math.exp(-(now - point.born) / 60);
998 const size = Math.max(sizes[point.kind], 1.5 / view.k) * grown;
999 context.globalAlpha = hovered && !near.has(point) && point !== hovered ? 0.35 : 1;
1000 context.fillStyle = kinds[point.kind];
1001 context.fillRect(point.x - size, point.y - size, size * 2, size * 2);
1002 // a ring holds while a step is checked or run, and for a while after a change, and any other ring dims away
1003 const age = point.state === "checking" || point.state === "running" ? 0 : now - point.changed;
1004 const dimming = point.state === "changed" ? Math.max(0, age - hold) : age;
1005 if (!point.state || dimming > 5 * fade) continue;
1006 const flash = Math.exp(-dimming / fade);
1007 const ring = size + (2 + 6 * (1 - flash)) / view.k;
1008 context.globalAlpha = flash;
1009 context.lineWidth = (point.state === "changed" ? 3 : 2) / view.k;
1010 context.strokeStyle = states[point.state];
1011 context.strokeRect(point.x - ring, point.y - ring, ring * 2, ring * 2);
1012 if (point.state !== "changed") continue;
1013 // a change also ripples out twice, which shows however far out the view is
1014 for (const t of [age, age - 220]) {
1015 if (t < 0 || t > 5 * fade) continue;
1016 const wave = size + (4 + 56 * (1 - Math.exp(-t / 260))) / view.k;
1017 context.globalAlpha = Math.exp(-t / 200);
1018 context.lineWidth = 2 / view.k;
1019 context.strokeRect(point.x - wave, point.y - wave, wave * 2, wave * 2);
1020 }
1021 }
1022 gonePoints = gonePoints.filter(({ at }) => now - at < 5 * fade);
1023 for (const { point, at } of gonePoints) {
1024 const left = Math.exp(-(now - at) / fade);
1025 const size = sizes[point.kind] * left;
1026 const ring = sizes[point.kind] + (10 * (1 - left)) / view.k;
1027 context.globalAlpha = left;
1028 context.fillStyle = states.failed;
1029 context.fillRect(point.x - size, point.y - size, size * 2, size * 2);
1030 context.lineWidth = 2 / view.k;
1031 context.strokeStyle = states.failed;
1032 context.strokeRect(point.x - ring, point.y - ring, ring * 2, ring * 2);
1033 }
1034 context.globalAlpha = 1;
1035
1036 // names go where no other is yet, in screen pixels
1037 context.setTransform(scale, 0, 0, scale, 0, 0);
1038 context.textBaseline = "middle";
1039 context.lineWidth = 3;
1040 context.strokeStyle = "#000";
1041 const named: Array<{ left: number; top: number; right: number; bottom: number }> = [];
1042 const name = (text: string, x: number, y: number, size: number, color: string, align: "left" | "center") => {
1043 context.font = `${size}px Arial, sans-serif`;
1044 const width = context.measureText(text).width;
1045 const left = align === "left" ? x : x - width / 2;
1046 const box = { left, top: y - size / 2, right: left + width, bottom: y + size / 2 };
1047 if (
1048 named.some((other) =>
1049 box.left < other.right && other.left < box.right && box.top < other.bottom && other.top < box.bottom
1050 )
1051 ) return;
1052 named.push(box);
1053 context.textAlign = align;
1054 context.strokeText(text, x, y);
1055 context.fillStyle = color;
1056 context.fillText(text, x, y);
1057 };
1058 const screen = (x: number, y: number) => [width / 2 + view.x + x * view.k, height / 2 + view.y + y * view.k] as const;
1059
1060 // what flashes is named beside it while its ring is bright, a change first, unless the build just made
1061 // it, or ran so much that names would only cover it
1062 const dimming = (point: Point) => now - point.changed - (point.state === "changed" ? hold : 0);
1063 const flashing = simulation.nodes.filter((point) =>
1064 (point.state === "changed" || ran <= 32 && (point.state === "ran" || point.state === "failed"))
1065 && point.born < began && dimming(point) < 3 * fade
1066 );
1067 flashing.sort((a, b) => Number(b.state === "changed") - Number(a.state === "changed") || a.changed - b.changed);
1068 for (const point of flashing) {
1069 const [x, y] = screen(point.x, point.y);
1070 name(nameOf(point), x + sizes[point.kind] * view.k + 8, y, 12, states[point.state!], "left");
1071 }
1072
1073 // then each group that gathers, its points about a link apart for their number, over where it does
1074 const groups = new Map<string, { kind: Kind; name: string; x: number; y: number; squares: number; count: number }>();
1075 for (const { kind, group, x, y } of simulation.nodes) {
1076 const key = `${kind} ${group}`;
1077 const sum = groups.get(key) ?? { kind, name: group, x: 0, y: 0, squares: 0, count: 0 };
1078 groups.set(key, { ...sum, x: sum.x + x, y: sum.y + y, squares: sum.squares + x * x + y * y, count: sum.count + 1 });
1079 }
1080 for (const { kind, name: group, x, y, squares, count } of [...groups.values()].sort((a, b) => b.count - a.count)) {
1081 if (count < 3) break;
1082 const spread = squares / count - (x / count) ** 2 - (y / count) ** 2;
1083 if (spread > linkDistance ** 2 * count) continue;
1084 name(group, ...screen(x / count, y / count), (6 + Math.sqrt(count)) * view.k, kinds[kind], "center");
1085 }
1086}
1087
1088/** a step's function, a row's table, or a file's path, with hashes and long paths cut short */
1089function nameOf(point: Point): string {
1090 const text = point.kind === "step" || point.kind === "lazy" || point.kind === "sqlite" ? point.group : point.label;
1091 const short = text.replace(/[0-9a-f]{16,}/g, (hash) => hash.slice(0, 8) + "…");
1092 return short.length > 44 ? short.slice(0, 26) + "…" + short.slice(-14) : short;
1093}
1094
1095/** where a pointer is, in the graph's coordinates */
1096function toGraph(event: MouseEvent): [number, number] {
1097 const { left, top, width, height } = canvas.getBoundingClientRect();
1098 return [
1099 (event.clientX - left - width / 2 - view.x) / view.k,
1100 (event.clientY - top - height / 2 - view.y) / view.k,
1101 ];
1102}
1103
1104canvas.addEventListener("pointerdown", (event) => {
1105 const [x, y] = toGraph(event);
1106 const point = simulation.find(x, y, 8 / view.k);
1107 dragging = { point, x: event.clientX, y: event.clientY };
1108 canvas.setPointerCapture(event.pointerId);
1109 if (!point) return;
1110 point.fx = x;
1111 point.fy = y;
1112 simulation.alphaTarget = 0.3;
1113});
1114canvas.addEventListener("pointermove", (event) => {
1115 const [x, y] = toGraph(event);
1116 if (dragging?.point) {
1117 dragging.point.fx = x;
1118 dragging.point.fy = y;
1119 } else if (dragging) {
1120 view.x += event.clientX - dragging.x;
1121 view.y += event.clientY - dragging.y;
1122 dragging.x = event.clientX;
1123 dragging.y = event.clientY;
1124 }
1125 hovered = dragging?.point ?? simulation.find(x, y, 8 / view.k);
1126 pointLabel.hidden = !hovered;
1127 if (!hovered) return;
1128 pointLabel.textContent = `${hovered.kind}: ${hovered.label}`;
1129 const { left, top, width, height } = canvas.getBoundingClientRect();
1130 // a label that would run past the canvas goes on the other side of the pointer
1131 const place = (at: number, size: number, room: number) =>
1132 at + 22 + size > room ? Math.max(0, at - 12 - size) : at + 22;
1133 pointLabel.style.left = `${place(event.clientX - left, pointLabel.offsetWidth, width)}px`;
1134 pointLabel.style.top = `${place(event.clientY - top, pointLabel.offsetHeight, height)}px`;
1135});
1136canvas.addEventListener("pointerup", () => {
1137 if (dragging?.point) dragging.point.fx = dragging.point.fy = null;
1138 simulation.alphaTarget = 0;
1139 dragging = null;
1140});
1141canvas.addEventListener("pointerleave", () => {
1142 hovered = null;
1143 pointLabel.hidden = true;
1144});
1145canvas.addEventListener("wheel", (event) => {
1146 event.preventDefault();
1147 const [x, y] = toGraph(event);
1148 const { left, top, width, height } = canvas.getBoundingClientRect();
1149 view.k = Math.min(8, Math.max(0.1, view.k * Math.exp(-event.deltaY * 0.002)));
1150 // the point under the pointer stays under it
1151 view.x = event.clientX - left - width / 2 - x * view.k;
1152 view.y = event.clientY - top - height / 2 - y * view.k;
1153}, { passive: false });
1154
1155function legend() {
1156 const swatch = (color: string, name: string, ring = false) =>
1157 `<span><i style="${ring ? "border:2px solid " : "background:"}${color}"></i>${name}</span>`;
1158 return Object.entries(kinds).map(([name, color]) => swatch(color, name)).join("")
1159 + Object.entries(states).map(([name, color]) => swatch(color, name, true)).join("");
1160}
1161
1162function connect() {
1163 const socket = new WebSocket(socketUrl);
1164 const root = new progress.Root();
1165 let push!: ReadableStreamDefaultController<progress.StreamEvent>;
1166 const decoded = progress.decodeEventStream(
1167 new ReadableStream({ start: (controller) => void (push = controller) }),
1168 root,
1169 );
1170 const detach = progress.attachToDocument(root, tree);
1171
1172 socket.onopen = () => {
1173 connected = true;
1174 retry = 100;
1175 render();
1176 };
1177 socket.onmessage = (event) => {
1178 const message = JSON.parse(event.data) as incremental.DebuggerMessage;
1179 if (message[0] === "progress") return push.enqueue(message[1]);
1180 if (message[0] === "steps") {
1181 ran += message[1].filter((event) => event.state === "ran").length;
1182 return void (frame && sweep.push(...message[1]));
1183 }
1184 // what was asked for by a change is announced, unlike what a page hears when it connects
1185 if (message[0] === "building") {
1186 if (building === null) live.textContent = titles.building, ran = 0;
1187 building ??= performance.now();
1188 began = building;
1189 } else {
1190 status = message[1];
1191 if (building !== null) live.textContent = title(status.state);
1192 loading = null;
1193 for (const file of status.changed) {
1194 const point = points.get(new URL(file, projectUrl || location.href).href);
1195 if (point) point.state = "changed", point.changed = performance.now();
1196 }
1197 building = null;
1198 void check().catch(() => {});
1199 }
1200 // the clover shows once there is something to say, rather than as offline while the page connects
1201 if (!host.isConnected) document.documentElement.append(host);
1202 render();
1203 };
1204 socket.onclose = () => {
1205 decoded.cancel();
1206 push.close();
1207 detach();
1208 connected = false;
1209 building = null;
1210 if (!host.isConnected) document.documentElement.append(host);
1211 render();
1212 setTimeout(connect, retry);
1213 retry = Math.min(retry * 2, 2000);
1214 };
1215}
1216
1217/** reload when what this url serves changed, unless that would lose something */
1218async function check() {
1219 if (shown === null) return;
1220 const page = await serving();
1221 if (page === null) return;
1222 stale = page !== shown;
1223 const media = document.querySelectorAll<HTMLMediaElement>("audio, video");
1224 if (stale && !edited && Array.from(media).every((element) => element.paused)) return location.reload();
1225 render();
1226}
1227
1228/** the hash of what this url serves now */
1229async function serving() {
1230 const response = await fetch(location.href, { headers: { "clover-page": "" }, cache: "no-store" });
1231 return response.headers.get("clover-page");
1232}
1233
1234button.addEventListener("click", () => {
1235 if (open || showing !== null) {
1236 open = false;
1237 showing = null;
1238 quiet = building;
1239 } else {
1240 open = true;
1241 }
1242 render();
1243});
1244// how long ago the last build was ticks while the tip shows, each time the rounded second turns
1245function tick() {
1246 clearTimeout(ticking);
1247 render();
1248 const since = Date.now() - (status?.at ?? Date.now());
1249 ticking = setTimeout(() => button.matches(":hover, :focus-visible") && tick(), 1000 - (since + 500) % 1000);
1250}
1251button.addEventListener("pointerenter", tick);
1252button.addEventListener("focus", tick);
1253// choosing a view keeps the panel open, even one that a build opened
1254maximize.addEventListener("click", () => {
1255 maximized = !maximized;
1256 open = true;
1257 render();
1258});
1259for (const name of tabs) {
1260 find(`#tab-${name}`).addEventListener("click", () => {
1261 tab = name;
1262 open = true;
1263 render();
1264 });
1265}
1266find("[role=tablist]").addEventListener("keydown", (event) => {
1267 const step = { ArrowLeft: -1, ArrowRight: 1 }[(event as KeyboardEvent).key];
1268 if (!step) return;
1269 tab = UNWRAP(tabs[(tabs.indexOf(tab) + step + tabs.length) % tabs.length]);
1270 open = true;
1271 render();
1272 find(`#tab-${tab}`).focus();
1273});
1274find(".reload").addEventListener("click", () => location.reload());
1275const dismiss = () => {
1276 dismissed = status;
1277 render();
1278};
1279find(".dismiss").addEventListener("click", dismiss);
1280dialog.addEventListener("cancel", (event) => {
1281 event.preventDefault();
1282 dismiss();
1283});
1284shadow.addEventListener("click", (event) => {
1285 if (!(event.target as Element).closest(".fold")) return;
1286 folded = shadow.querySelector("details.diagnostic[open]") !== null;
1287 for (const problem of shadow.querySelectorAll<HTMLDetailsElement>("details.diagnostic")) problem.open = !folded;
1288 showFold();
1289});
1290// a problem folded by hand can change what the button does
1291shadow.addEventListener("toggle", showFold, true);
1292shadow.addEventListener("keydown", (event) => {
1293 if ((event as KeyboardEvent).key !== "Escape" || panel.hidden) return;
1294 open = false;
1295 showing = null;
1296 quiet = building;
1297 button.focus();
1298 render();
1299});
1300for (const type of ["input", "change"]) document.addEventListener(type, () => void (edited = true), true);
1301// a page that moves to another url, like the file viewer, is compared with what that url serves
1302globalThis.navigation?.addEventListener("currententrychange", () => {
1303 if (shown === null) return;
1304 stale = false;
1305 void serving().then((page) => void (shown = page ?? shown), () => {});
1306});
1307
1308// automated browsers, like the one that takes pictures of pages, get the page alone
1309if (!navigator.webdriver) connect();
1310
1311function css() {
1312 return `
1313:host{all:initial}
1314*{box-sizing:border-box}
1315[hidden]{display:none!important}
1316.clover,.panel,.tip{position:fixed;z-index:2147483647}
1317.panel,.tip,dialog{font:13px/1.3 Arial,Helvetica,sans-serif;color:#fff;color-scheme:dark}
1318button{appearance:none;margin:0;min-height:24px;padding:2px 10px;border:2px solid #999;border-radius:0;
1319 background:#444;color:#fff;font:inherit;cursor:pointer}
1320button:hover{background:#555;border-color:#bbb}
1321button:focus-visible{outline:2px solid #0cff10;outline-offset:1px}
1322
1323.clover{top:8px;right:8px;display:grid;place-items:center;width:30px;height:30px;min-height:0;padding:0;
1324 border:1px solid #fffa;background:#0008;--from:#80868f;--to:#cdd2d9}
1325.clover:hover{border-color:#fff;background:#0008}
1326.clover[aria-expanded=true]{border:2px solid #999;background:#222}
1327.clover svg{width:24px;height:24px}
1328.from{stop-color:var(--from)}
1329.to{stop-color:var(--to)}
1330.clover[data-state=ok]{--from:#5bbdfa;--to:#9dffff}
1331.clover[data-state=warnings]{--from:#eda800;--to:#fff27a}
1332.clover[data-state=failed]{--from:#dc2f38;--to:#ff9e8e}
1333.spinner{display:none;width:15px;height:23px}
1334.clover[data-state=building] svg{display:none}
1335.clover[data-state=building] .spinner{display:block}
1336.clover.stale svg{animation:blink 1s step-end infinite}
1337@keyframes blink{50%{opacity:.2}}
1338.live{position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
1339
1340.tip{display:none;top:8px;right:46px;max-width:300px;padding:5px 8px;border:1px solid #000;background:#fff;
1341 color:#000;line-height:1.2}
1342/* one stroke over the border's own, so its sides are as wide as the border and join it cleanly */
1343.tip::before{content:"";position:absolute;top:5px;left:100%;width:8px;height:18px;
1344 background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='18'><rect y='2' width='1' height='14' fill='%23fff'/><path d='M.5 0v2l7 7-7 7v2' fill='%23fff' stroke='%23000'/></svg>")}
1345.clover[aria-expanded=false]:is(:hover,:focus-visible)~.tip{display:block}
1346.tip>span{display:block}
1347
1348.panel{top:8px;right:8px;display:flex;flex-direction:column;width:min(460px,calc(100vw - 16px));
1349 max-height:calc(100vh - 16px)}
1350.panel.max{left:8px;bottom:8px;width:auto;max-height:none}
1351/* the tabs end at the clover, which is the last of them */
1352.tabs{display:flex;justify-content:flex-end;padding-right:34px}
1353/* the clover lies under the panel's box, and under the room the tabs leave it */
1354.panel,.tabs{pointer-events:none}
1355.tabs>*,.body{pointer-events:auto}
1356[role=tablist]{display:flex}
1357.tabs button{height:30px;padding:0 10px;margin-right:-2px;background:#222}
1358.tabs [aria-selected=true]{position:relative;z-index:1;padding-bottom:2px;border-bottom:0;background:#000}
1359.tabs .maximize{display:grid;place-items:center;width:30px;margin:0;padding:0}
1360.maximize svg{width:12px;height:12px;fill:none;stroke:#fff;stroke-width:1.5}
1361.maximize[aria-pressed=true] .max,.maximize[aria-pressed=false] .restore{display:none}
1362.body{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0;margin-top:-2px;padding:10px;overflow:auto;
1363 background:#000;border:2px solid #999}
1364.view{display:flex;flex-direction:column;gap:10px}
1365
1366table{width:100%;border-collapse:collapse}
1367th,td{padding:3px 8px;border:2px solid #999;text-align:left;vertical-align:top;font-weight:400}
1368th{width:1%;white-space:nowrap;background:#444}
1369td{background:#222;font-variant-numeric:tabular-nums}
1370.mono,figcaption,.where,.fields,.excerpt pre{font-family:ui-monospace,Menlo,Consolas,monospace}
1371.mono{font-size:12px;overflow-wrap:anywhere}
1372td [data-state=ok]{color:#0cff10}
1373td [data-state=warnings]{color:#fcd330}
1374td [data-state=failed]{color:#ff3b3b}
1375.notice{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0;padding:4px 4px 4px 10px;
1376 border:2px solid #fcd330;background:color-mix(in srgb,#fcd330 12%,#000)}
1377.empty{margin:0;color:#999}
1378.problems{position:relative}
1379.problems>details+details{margin-top:12px}
1380.diagnostic{min-width:0}
1381.diagnostic>*+*{margin-top:4px}
1382.diagnostic.error{--tone:#ff3b3b}
1383.diagnostic.warning{--tone:#fcd330}
1384.head{margin:0;line-height:1.35;white-space:pre-wrap;overflow-wrap:anywhere}
1385.head b{color:var(--tone)}
1386summary.head{display:block}
1387.note .head b,.note summary b{color:#ddd}
1388.note>summary,.note>.head{color:#bbb}
1389.colon{color:#999}
1390.where{display:none;font-size:12px;line-height:1.4;color:#999;white-space:normal}
1391details:not([open])>summary>.where{display:block}
1392/* the first problem leaves room for the button that folds them all */
1393.problems>details:first-of-type>summary{padding-right:26px}
1394.fold{position:absolute;top:-2px;right:-2px;display:grid;place-items:center;width:22px;height:22px;min-height:0;
1395 padding:0;border:0;background:none;color:#999}
1396.fold:hover{background:#333;border:0;color:#fff}
1397.fold svg{width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:1.5}
1398.fold:not(.folded) .unfold{display:none}
1399.from{color:#999}
1400.excerpt{margin:0;background:#161616}
1401figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere}
1402.pos{color:#888}
1403figcaption a{color:inherit;text-decoration:none}
1404figcaption a:hover{text-decoration:underline}
1405.fn{color:#3ee6ff}
1406.excerpt pre{margin:0;padding:1px 0 3px;overflow-x:auto;font-size:12px;line-height:1.45;color:#e6e6e6;tab-size:2}
1407.line{display:block;padding-right:8px;white-space:pre}
1408.before{mask-image:linear-gradient(#0003,#000b)}
1409.after{mask-image:linear-gradient(#000b,#0003)}
1410.hit{padding-bottom:2px}
1411.n{display:inline-block;box-sizing:content-box;margin-right:10px;padding-left:8px;text-align:right;color:#666;
1412 user-select:none}
1413.mark{color:var(--tone);font-weight:700;text-decoration:underline 2px var(--tone);text-underline-offset:4px;
1414 text-decoration-skip-ink:none}
1415.mark span{color:inherit}
1416.t-k{color:#ff9de2}
1417.t-l,.t-n{color:#ffb86c}
1418.t-s{color:#9ef09e}
1419.t-r{color:#3ee6ff}
1420.t-c{color:#8a8a8a}
1421.internal{opacity:.55}
1422.fields{margin:0;font-size:12px;line-height:1.5}
1423.fields div{display:flex;gap:6px}
1424.fields dt{color:#999}
1425.fields dt::after{content:":"}
1426.fields dd{margin:0;overflow-wrap:anywhere}
1427summary{cursor:pointer;list-style:none;line-height:1.35}
1428summary::-webkit-details-marker{display:none}
1429.note>summary::before{content:"\\25B8";display:inline-block;width:14px;color:#999}
1430.note[open]>summary::before{content:"\\25BE"}
1431.frames{display:flex;flex-direction:column;gap:4px;margin-top:4px}
1432.nested{padding-left:10px;border-left:2px solid #333}
1433.similar{margin:4px 0 0;padding-left:30px;font-size:12px;line-height:1.45}
1434
1435dialog{inset:0;width:auto;height:auto;max-width:none;max-height:none;margin:0;
1436 padding:12px max(16px,calc(50vw - 340px)) 24px;border:0;overflow:auto;background:#000;font-size:14px;line-height:1.4}
1437dialog:focus{outline:0}
1438dialog::backdrop{background:#000}
1439.top{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}
1440.dismiss{min-height:0;padding:0;border:0;background:none;color:#999}
1441.dismiss:hover{background:none;border:0;color:#fff;text-decoration:underline}
1442dialog h2{margin:0;font-size:22px;font-weight:400;color:#ff3b3b}
1443dialog .slot>div{margin-bottom:12px}
1444dialog .excerpt pre,dialog figcaption,dialog .fields{font-size:13px}
1445
1446#view-graph{position:relative}
1447canvas{display:block;width:100%;height:min(60vh,480px);cursor:grab;touch-action:none}
1448.panel.max canvas{height:calc(100vh - 68px)}
1449.legend{position:absolute;left:0;bottom:0;display:flex;flex-wrap:wrap;gap:2px 10px;padding:4px 6px;
1450 background:#000c;font-size:11px;color:#ccc;pointer-events:none}
1451.legend i{display:inline-block;box-sizing:border-box;width:9px;height:9px;margin-right:4px;vertical-align:-1px}
1452.point{position:absolute;width:max-content;max-width:320px;padding:4px 6px;background:#fff;color:#000;font-size:12px;
1453 line-height:1.2;overflow-wrap:anywhere;pointer-events:none}
1454/* a build's progress floats over the graph, which shows through */
1455.panel[data-tab=graph] .body{position:relative}
1456.panel[data-tab=graph] .slot{position:absolute;top:10px;left:10px;right:10px;z-index:1;max-height:45%;
1457 overflow:hidden;padding:8px 10px;background:#000c;border:2px solid #999}
1458
1459@media (prefers-reduced-motion:reduce){.clover.stale svg{animation:none}}
1460`;
1461}
1462
1463/** clover's icon, whose fill is colored by the state of the build */
1464function clover() {
1465 return `<svg viewBox="0 0 128 128" aria-hidden=true>
1466 <linearGradient id=clover-fill x1=27.8421 y1=26.3816 x2=96.4874 y2=102.044 gradientUnits=userSpaceOnUse>
1467 <stop class=from /><stop offset=1 class=to />
1468 </linearGradient>
1469 <path fill=#000 d="M48.4615 61.1835L47.535 65.3309C49.8166 65.8405 52.0814 64.4121 52.6042 62.1339C53.127 59.8557 51.7115 57.5829 49.436 57.047L48.4615 61.1835ZM30.6697 23.8163L33.526 26.9634L30.6697 23.8163ZM52.9493 30.5395L48.7022 30.7057C48.7907 32.9656 50.6341 34.7604 52.8959 34.7888C55.1578 34.8173 57.0457 33.0694 57.1909 30.8124L52.9493 30.5395ZM72.1556 9.8294L73.6118 5.83697L72.1556 9.8294ZM71.3151 55.2579L68.012 52.5834C66.5717 54.3617 66.7982 56.9609 68.5244 58.4633C70.2506 59.9657 72.8567 59.8318 74.4197 58.1604L71.3151 55.2579ZM109.211 62.3987L108.137 58.2869C106.242 58.7817 104.93 60.5083 104.961 62.4671C104.993 64.426 106.36 66.1094 108.271 66.543L109.211 62.3987ZM113.502 84.3619L116.574 87.2997C116.599 87.2727 116.625 87.2454 116.65 87.2178L113.502 84.3619ZM67.5719 77.4622L69.9547 73.9432C68.7858 73.1519 67.3001 72.9925 65.9899 73.5179C64.6797 74.0432 63.7158 75.1846 63.4175 76.5642L67.5719 77.4622ZM89.3757 115.881L93.564 116.604C93.8709 114.828 93.0234 113.051 91.4495 112.172L89.3757 115.881ZM26.6675 93.5918L30.1359 96.0483C31.2567 94.4663 31.1646 92.3264 29.9119 90.8465C28.6592 89.3666 26.5636 88.922 24.8178 89.7657L26.6675 93.5918ZM36.9708 113.188L36.7974 108.942V108.942L36.9708 113.188ZM89.0962 117.499L86.4033 120.787C87.5731 121.745 89.1639 122.009 90.5808 121.481C91.9976 120.953 93.0272 119.712 93.2846 118.222L89.0962 117.499ZM48.4615 61.1835L49.3881 57.036C34.8729 53.7943 28.8667 48.0882 27.2049 43.2149C25.5853 38.4655 27.4464 32.4792 33.526 26.9634L30.6697 23.8163L27.8135 20.6691C20.3416 27.4481 16.0294 36.7798 19.159 45.9575C22.2463 55.0114 31.9116 61.8416 47.535 65.3309L48.4615 61.1835ZM30.6697 23.8163L33.526 26.9634C38.2853 22.6455 42.1719 22.2251 44.3513 22.9573C46.3805 23.6391 48.5129 25.8703 48.7022 30.7057L52.9493 30.5395L57.1964 30.3734C56.9104 23.0677 53.3348 17.0093 47.0589 14.9007C40.9331 12.8426 33.9176 15.1311 27.8135 20.6691L30.6697 23.8163ZM52.9493 30.5395L57.1909 30.8124C57.4228 27.2089 58.1343 21.3687 60.3679 17.2431C61.4444 15.2549 62.6821 14.0267 64.0473 13.4362C65.3409 12.8767 67.3858 12.6136 70.6994 13.8218L72.1556 9.8294L73.6118 5.83697C68.7937 4.08015 64.4308 4.00977 60.6721 5.63551C56.9849 7.2303 54.5257 10.1799 52.8922 13.197C49.7061 19.0818 48.9461 26.5618 48.7077 30.2666L52.9493 30.5395ZM72.1556 9.8294L70.6994 13.8218C74.6175 15.2505 76.5735 17.5698 77.5408 20.303C78.5836 23.2496 78.5942 27.0553 77.7192 31.3586C75.9604 40.0094 70.997 48.898 68.012 52.5834L71.3151 55.2579L74.6182 57.9324C78.3682 53.3026 83.9897 43.1832 86.0496 33.0518C87.084 27.964 87.3125 22.4349 85.5546 17.468C83.7213 12.2877 79.8583 8.11465 73.6118 5.83697L72.1556 9.8294ZM71.3151 55.2579L74.4197 58.1604C79.6116 52.6085 85.5009 45.6475 92.1894 41.2758C95.4581 39.1394 98.6274 37.8332 101.692 37.5654C104.652 37.3067 107.825 37.9855 111.292 40.3746L113.704 36.8757L116.116 33.3768C111.108 29.925 105.982 28.6587 100.951 29.0984C96.0247 29.529 91.5112 31.5648 87.538 34.1617C79.7428 39.2567 72.9124 47.3276 68.2104 52.3555L71.3151 55.2579ZM113.704 36.8757L111.292 40.3746C114.973 42.9118 116.219 45.1099 116.587 46.6386C116.952 48.1516 116.637 49.7621 115.636 51.5142C114.615 53.2998 113.014 54.9694 111.361 56.2762C110.55 56.9172 109.782 57.4275 109.149 57.7887C108.456 58.1843 108.117 58.2922 108.137 58.2869L109.211 62.3987L110.285 66.5105C112.136 66.0273 114.53 64.6064 116.634 62.9428C118.85 61.1906 121.299 58.7355 123.016 55.7321C124.752 52.6951 125.864 48.8524 124.852 44.6491C123.843 40.4615 120.91 36.6804 116.116 33.3768L113.704 36.8757ZM109.211 62.3987L108.271 66.543C108.252 66.5388 108.311 66.5514 108.45 66.6057C108.583 66.6577 108.76 66.7359 108.972 66.8459C109.4 67.0672 109.913 67.3856 110.452 67.7995C111.548 68.6416 112.578 69.7458 113.243 71.0017C113.882 72.2074 114.192 73.5516 113.94 75.0984C113.684 76.6788 112.78 78.8344 110.355 81.5061L113.502 84.3619L116.65 87.2178C119.893 83.6452 121.75 80.0384 122.331 76.4614C122.918 72.8509 122.142 69.6427 120.755 67.024C119.395 64.4554 117.458 62.4631 115.631 61.0595C113.875 59.7108 111.878 58.6461 110.152 58.2544L109.211 62.3987ZM113.502 84.3619L110.431 81.4242C106.849 85.1683 102.872 87.0534 97.0865 86.4584C90.8845 85.8207 82.2948 82.2963 69.9547 73.9432L67.5719 77.4622L65.1891 80.9812C77.8805 89.5722 87.8931 94.0572 96.217 94.9131C104.957 95.8119 111.43 92.6748 116.574 87.2997L113.502 84.3619ZM67.5719 77.4622L63.4231 78.3857C64.5022 83.2313 66.2315 91.1083 69.7102 98.8766C73.1843 106.634 78.5921 114.723 87.3018 119.59L89.3757 115.881L91.4495 112.172C84.9653 108.548 80.5558 102.297 77.4687 95.4034C74.3864 88.5205 72.8108 81.434 71.7207 76.5387L67.5719 77.4622ZM48.4615 61.1835L49.436 57.047C42.1013 55.3196 32.3642 54.2966 23.8216 56.6683C19.4883 57.8713 15.3188 59.9895 11.9906 63.4685C8.63535 66.9759 6.36305 71.6301 5.48403 77.5003L9.68754 78.1296L13.891 78.7589C14.5403 74.4234 16.1249 71.4433 18.1338 69.3434C20.1698 67.2151 22.8718 65.7528 26.0961 64.8577C32.6684 63.033 40.7901 63.7427 47.4871 65.3199L48.4615 61.1835ZM9.68754 78.1296L5.48403 77.5003C4.66493 82.9705 4.84593 87.4473 6.0519 90.9953C7.30706 94.6879 9.64023 97.2429 12.6522 98.631C15.5182 99.9518 18.6053 100.039 21.2496 99.6683C23.921 99.2939 26.4647 98.4099 28.5172 97.4179L26.6675 93.5918L24.8178 89.7657C23.4035 90.4491 21.7093 91.0215 20.0694 91.2513C18.4024 91.485 17.1047 91.3243 16.2104 90.9122C15.462 90.5673 14.6592 89.9042 14.1005 88.2605C13.4926 86.4722 13.1819 83.4945 13.891 78.7589L9.68754 78.1296ZM26.6675 93.5918L23.1992 91.1353C20.6353 94.7542 18.7127 101.061 20.0886 106.743C20.8166 109.75 22.5046 112.675 25.5625 114.725C28.5654 116.738 32.4663 117.625 37.1441 117.434L36.9708 113.188L36.7974 108.942C33.3302 109.083 31.3739 108.388 30.2965 107.666C29.2743 106.981 28.6591 106.017 28.3507 104.744C27.6536 101.864 28.7244 98.0405 30.1359 96.0483L26.6675 93.5918ZM36.9708 113.188L37.1441 117.434C42.1815 117.229 46.7299 115.026 50.5992 112.066C54.4752 109.101 57.9019 105.203 60.7821 101.158C66.4898 93.1433 70.5402 83.8459 71.7263 78.3602L67.5719 77.4622L63.4175 76.5642C62.5243 80.6952 59.045 88.9437 53.8572 96.2288C51.2897 99.8343 48.4273 103.025 45.4335 105.316C42.433 107.611 39.5308 108.83 36.7974 108.942L36.9708 113.188ZM67.5719 77.4622L63.5174 78.7372C67.0304 89.9045 68.8832 106.441 86.4033 120.787L89.0962 117.499L91.7892 114.211C76.7482 101.896 75.5695 88.7218 71.6265 76.1872L67.5719 77.4622ZM89.0962 117.499L93.2846 118.222L93.564 116.604L89.3757 115.881L85.1873 115.158L84.9079 116.776L89.0962 117.499Z" />
1470 <path fill="url(#clover-fill)" d="M47.6525 60.9984C18.3025 54.1218 17.2406 36 30.6697 23.8163C41.5332 13.9602 52.474 18.3985 52.9493 30.5395C53.4196 23.2312 55.8923 3.89927 72.1556 9.8294C92.4847 17.2421 78.0501 46.9427 71.3151 55.2579C81.2089 44.6781 96.7546 25.194 113.704 36.8757C130.653 48.5573 113.243 61.3462 109.211 62.3987C112.922 63.2408 124.837 71.8732 113.502 84.3619C104.777 93.4811 92.6034 94.4063 67.5719 77.4622C69.9446 85.0046 71.4211 93.7638 76.3781 102.572C79.4981 107.874 83.6818 112.699 89.3757 115.881L89.0962 117.499C83.1772 112.652 79.2098 107.604 76.3781 102.572C71.2061 93.7839 68.9573 83.6842 67.5983 77.5808L67.5719 77.4622C65.4927 87.0789 52.5123 112.554 36.9708 113.188C20.6808 113.853 22.6922 99.2029 26.6675 93.5918C19.734 96.9425 6.6311 98.5412 9.68755 78.1296C12.6858 58.1065 33.6114 57.8801 47.6525 60.9984Z" />
1471 <path fill=#fff d="M61.5683 7.70693C64.6721 6.36448 68.4194 6.34704 72.8377 7.95803C78.4655 10.0101 81.822 13.6905 83.4253 18.2209C84.9932 22.6513 84.8283 27.7225 83.8362 32.602C83.1517 35.9688 82.0523 39.3417 80.7608 42.4955C83.2729 40.1358 85.9589 37.891 88.7741 36.051C92.5601 33.5765 96.7168 31.7349 101.149 31.3476C105.629 30.956 110.236 32.0661 114.834 35.2354C119.332 38.3354 121.817 41.6962 122.656 45.1777C123.496 48.6664 122.596 51.9159 121.055 54.6115C119.523 57.2915 117.299 59.5384 115.233 61.1723C114.688 61.6029 114.136 62.0035 113.595 62.3676C113.815 62.5214 114.036 62.6823 114.254 62.8501C115.888 64.1045 117.584 65.8607 118.76 68.0806C119.948 70.3239 120.6 73.0369 120.102 76.0991C119.607 79.1459 118.003 82.3677 114.978 85.7011C114.966 85.714 114.953 85.7269 114.941 85.7395C110.213 90.681 104.403 93.4854 96.4482 92.6675C89.6435 91.9678 81.3977 88.6287 70.9762 82.0674C72.694 88.3469 74.3383 94.8737 78.0952 101.562L78.6679 102.51C81.5916 107.215 85.3783 111.364 90.3471 114.142C91.0848 114.554 91.4828 115.387 91.3391 116.22L91.0589 117.838C90.9383 118.536 90.4561 119.118 89.792 119.366C89.128 119.613 88.3824 119.489 87.8341 119.04C81.7308 114.043 77.609 108.813 74.6607 103.583L74.1679 102.726C70.7723 96.6952 68.6672 90.1784 67.2554 84.7724C65.3529 89.4079 62.4867 94.8711 58.9422 99.8487C56.145 103.777 52.8688 107.488 49.2271 110.273C45.5886 113.057 41.4772 114.998 37.052 115.178C32.6959 115.356 29.3115 114.521 26.8202 112.851C24.3031 111.163 22.9005 108.759 22.2838 106.212C21.5522 103.19 21.9034 99.9029 22.8037 97.0763C22.1908 97.2224 21.5663 97.3438 20.936 97.4322C18.5513 97.7664 15.9393 97.6607 13.5971 96.5813C11.1866 95.4704 9.26032 93.417 8.19015 90.2686C7.14315 87.1881 6.92723 83.1097 7.717 77.835C8.51928 72.4773 10.5455 68.3231 13.4539 65.2075C16.3513 62.1036 20.0243 60.1363 23.9658 58.9746C26.7592 58.1513 29.7185 57.7244 32.6951 57.5863C26.5793 54.1602 22.8016 49.8458 21.2255 45.0158C18.6343 37.0743 22.293 28.7267 29.3312 22.3412C35.0781 17.1273 41.2623 15.3343 46.3395 17.0401C48.7744 17.8582 50.7713 19.4182 52.2279 21.5381C52.8122 19.0746 53.6545 16.5316 54.8779 14.2718C56.3634 11.5281 58.4981 9.03495 61.5683 7.70693ZM71.4732 11.7006C67.76 10.3467 65.0992 10.5217 63.1507 11.3645C61.1686 12.2217 59.6062 13.9071 58.3819 16.1685C55.8954 20.7612 55.1707 27.0364 54.937 30.6669C54.869 31.725 53.9843 32.5448 52.9241 32.5315C51.864 32.518 51.0004 31.6765 50.9589 30.6171C50.7438 25.126 48.2279 21.8778 45.0705 20.817C41.8427 19.7326 37.1246 20.6502 32.0082 25.2921C25.6178 31.0901 23.0931 37.8949 25.0127 43.779C26.9468 49.7068 33.6928 55.677 48.0847 59.0535L48.5008 59.1489C48.6319 59.1789 48.7638 59.2097 48.8961 59.2392H48.8951C48.9027 59.241 48.9103 59.2426 48.9179 59.2444C49.9844 59.4956 50.6487 60.5609 50.4038 61.6288C50.1588 62.6967 49.0969 63.3667 48.0277 63.1281C47.9685 63.1149 47.9092 63.101 47.8502 63.0877C47.642 63.0392 47.4321 62.9904 47.2204 62.9434C40.3491 61.4175 31.9904 60.7642 25.0926 62.7971C21.673 63.8051 18.6745 65.4536 16.3664 67.926C14.0692 70.3869 12.3547 73.7707 11.6578 78.4244C10.9195 83.3551 11.1961 86.7315 11.9629 88.9871C12.7065 91.1746 13.9159 92.3402 15.2655 92.9622C16.6836 93.6156 18.4565 93.7572 20.383 93.4872C22.2968 93.219 24.2165 92.5633 25.8003 91.798C26.6186 91.4025 27.6017 91.6116 28.1888 92.3054C28.7755 92.9992 28.8178 94.0023 28.2926 94.7437C26.5752 97.1681 25.2789 101.651 26.1562 105.274C26.576 107.008 27.4757 108.493 29.0386 109.541C30.6274 110.606 33.1005 111.352 36.8891 111.198C40.2346 111.061 43.5751 109.581 46.8064 107.109C50.0345 104.639 53.0461 101.26 55.6966 97.538C61.0223 90.0591 64.6532 81.5321 65.6243 77.0413L65.6917 76.8047C65.8801 76.2657 66.2928 75.8292 66.83 75.6135C67.444 75.3673 68.1404 75.4419 68.6883 75.8128C81.1217 84.2291 90.0902 88.0082 96.856 88.7039C103.413 89.378 108.046 87.1707 112.037 83.0106C114.672 80.1045 115.827 77.5678 116.17 75.46C116.51 73.3653 116.076 71.526 115.239 69.9451C114.389 68.3406 113.118 67.0008 111.827 66.0095C110.504 64.9928 109.325 64.4669 108.771 64.3411C107.875 64.1379 107.234 63.3491 107.22 62.4309C107.205 61.5126 107.82 60.7028 108.708 60.4708C109.322 60.3106 110.908 59.5132 112.762 58.047C114.565 56.6219 116.391 54.7429 117.597 52.6339C118.793 50.5408 119.319 48.3377 118.782 46.1105C118.244 43.8757 116.55 41.2571 112.574 38.5163C108.698 35.845 105.005 35.0096 101.495 35.3164C97.9361 35.6274 94.4098 37.1273 90.9541 39.3858C83.9718 43.9495 77.8319 51.2056 72.7702 56.6182C72.0376 57.4017 70.8161 57.4647 70.0071 56.7604C69.1981 56.056 69.0923 54.8371 69.7674 54.0035C72.9557 50.067 78.093 40.8519 79.9317 31.8082C80.849 27.2966 80.9019 23.0332 79.6692 19.5501C78.4719 16.167 76.0098 13.3548 71.4732 11.7006Z" />
1472</svg>`;
1473}
1474
1475import { UNWRAP } from "../assert.ts";
1476import type * as incremental from "../incremental.ts";
1477import type * as stack from "../log/stack.ts";
1478import * as progress from "../progress.ts";
1479import * as string from "../string.ts";