| 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 | |
| 15 | const socketUrl = new URL("/.clover/socket", import.meta.url); |
| 16 | socketUrl.protocol = "ws:"; |
| 17 | |
| 18 | const 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 */ |
| 20 | let shown = params.get("page"); |
| 21 | let status: incremental.BuildStatus | null = null; |
| 22 | let connected = false; |
| 23 | /** when the build in progress began */ |
| 24 | let building: number | null = null; |
| 25 | /** when the latest build began, so what it has not reached is what it may not use */ |
| 26 | let began = 0; |
| 27 | /** how many steps ran since, which are named as they flash only while they are few */ |
| 28 | let ran = 0; |
| 29 | /** when a build opened the panel for its progress, which stays long enough to read */ |
| 30 | let showing: number | null = null; |
| 31 | /** the build whose progress was closed, which does not open it again */ |
| 32 | let quiet: number | null = null; |
| 33 | /** the debugger's own page, and a page asked for before a build was served, which show the graph */ |
| 34 | const home = location.pathname.replace(/\/+$/, "") === "/.clover" || params.has("graph"); |
| 35 | const savedKey = "clover-debugger"; |
| 36 | /** what the last page of this tab showed as it went away, which a hard navigation shows again */ |
| 37 | let saved = JSON.parse(sessionStorage.getItem(savedKey) ?? "null") as Saved | null; |
| 38 | /** the panel stays open */ |
| 39 | let open = home || (saved?.open ?? false); |
| 40 | let maximized = home || (saved?.maximized ?? false); |
| 41 | let tab: (typeof tabs)[number] = home ? "graph" : saved?.tab ?? "status"; |
| 42 | /** a build changed this page, which still shows what it was */ |
| 43 | let stale = false; |
| 44 | /** the page has input that a reload would lose */ |
| 45 | let edited = false; |
| 46 | /** the failed build whose errors were dismissed */ |
| 47 | let dismissed: incremental.BuildStatus | null = null; |
| 48 | /** the build whose problems are listed */ |
| 49 | let listed: incremental.BuildStatus | null = null; |
| 50 | /** the problems start folded, as the last fold of every one of them left them */ |
| 51 | let folded = saved?.folded ?? false; |
| 52 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 53 | let ticking: ReturnType<typeof setTimeout> | undefined; |
| 54 | let retry = 100; |
| 55 | |
| 56 | const tabs = ["status", "problems", "graph"] as const; |
| 57 | interface 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 | } |
| 69 | const titles = { |
| 70 | offline: "Reconnecting…", |
| 71 | building: "Building…", |
| 72 | ok: "Up to date", |
| 73 | warnings: "Built with warnings", |
| 74 | failed: "Build failed", |
| 75 | }; |
| 76 | |
| 77 | type Kind = "step" | "lazy" | "code" | "file" | "directory" | "sqlite" | "output"; |
| 78 | interface 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 | |
| 98 | const 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 | }; |
| 107 | const 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 */ |
| 109 | const fade = 300; |
| 110 | /** how long a change holds its ring before it dims, as the edit that a build follows from */ |
| 111 | const hold = 1500; |
| 112 | const 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 | |
| 120 | const tabButtons = tabs.map((name) => |
| 121 | `<button type=button role=tab id=tab-${name} aria-controls=view-${name}>${name}</button>` |
| 122 | ).join(""); |
| 123 | |
| 124 | const host = document.createElement("clover-debugger"); |
| 125 | const shadow = host.attachShadow({ mode: "open" }); |
| 126 | shadow.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 | |
| 163 | const find = <E extends Element = HTMLElement>(selector: string) => UNWRAP(shadow.querySelector<E>(selector)); |
| 164 | const button = find<HTMLButtonElement>(".clover"); |
| 165 | const live = find(".live"); |
| 166 | const panel = find(".panel"); |
| 167 | const maximize = find(".maximize"); |
| 168 | const dialog = find<HTMLDialogElement>("dialog"); |
| 169 | const tree = document.createElement("div"); |
| 170 | |
| 171 | // a node with no text or total draws as the spinner alone |
| 172 | const spinner = new progress.Root(); |
| 173 | spinner.start(""); |
| 174 | progress.attachToDocument(spinner, find(".spinner")); |
| 175 | |
| 176 | function 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 */ |
| 262 | const 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 | |
| 265 | function 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 | |
| 273 | const 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 */ |
| 276 | function 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 | */ |
| 291 | function 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 | |
| 327 | const unknown = "[unknown source]"; |
| 328 | |
| 329 | /** where a place is in its file: `:line:col`, or an anchor in a url */ |
| 330 | function 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 */ |
| 336 | function 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 */ |
| 363 | const widest = 160; |
| 364 | |
| 365 | /** a line of code from `indent`, colored by its tokens, with what `mark` covers underlined */ |
| 366 | function 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 | */ |
| 397 | class 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 */ |
| 542 | interface 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 */ |
| 548 | interface 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 */ |
| 558 | const alphaMin = 0.001; |
| 559 | /** a layout settles in 300 ticks */ |
| 560 | const alphaDecay = 1 - Math.pow(0.001, 1 / 300); |
| 561 | /** the share of its velocity that a point keeps each tick */ |
| 562 | const friction = 0.6; |
| 563 | /** the push of each point, negative to repel */ |
| 564 | const pointCharge = -30; |
| 565 | /** how far apart a link holds its ends */ |
| 566 | const linkDistance = 30; |
| 567 | /** how strongly everything is pulled to the origin */ |
| 568 | const gravity = 0.05; |
| 569 | /** how small a square must look from a point to push as one */ |
| 570 | const theta = 0.9; |
| 571 | const jiggle = () => (Math.random() - 0.5) * 1e-6; |
| 572 | |
| 573 | // -- the graph: steps, the code that they run, and the inputs that they read -- |
| 574 | |
| 575 | const canvas = find<HTMLCanvasElement>("canvas"); |
| 576 | const context = UNWRAP(canvas.getContext("2d")); |
| 577 | const pointLabel = find(".point"); |
| 578 | const simulation = new Simulation(); |
| 579 | const points = new Map<string, Point>(); |
| 580 | /** what kind each input was, which decides whether it is drawn */ |
| 581 | const inputKinds = new Map<string, string>(); |
| 582 | /** what more than half of the steps read, like the build script, which is drawn without its links */ |
| 583 | const shared = new Set<string>(); |
| 584 | /** the project's url, which the paths of its files are relative to */ |
| 585 | let projectUrl = ""; |
| 586 | let loading: Promise<void> | null = null; |
| 587 | /** what happened to steps, shown a little at a time, so a quick build still sweeps across */ |
| 588 | const 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 */ |
| 590 | let pulses: Array<{ link: Link; at: number }> = []; |
| 591 | /** what a step stopped reading, and what the graph lost, which fade out in red */ |
| 592 | let goneLinks: Array<{ link: Link; at: number }> = []; |
| 593 | let gonePoints: Array<{ point: Point; at: number }> = []; |
| 594 | /** the pan, in pixels from the center, and the zoom */ |
| 595 | const view = { x: 0, y: 0, k: 1 }; |
| 596 | let hovered: Point | null = null; |
| 597 | let dragging: { point: Point | null; x: number; y: number } | null = null; |
| 598 | let frame = 0; |
| 599 | let drawn = 0; |
| 600 | |
| 601 | if (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 | } |
| 612 | addEventListener("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 */ |
| 634 | const 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 */ |
| 650 | const glow = new OffscreenCanvas(1, 1); |
| 651 | const 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 */ |
| 654 | function 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 */ |
| 661 | function 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 */ |
| 667 | function 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 | |
| 678 | function 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 */ |
| 686 | function 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 */ |
| 708 | function 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 */ |
| 738 | function 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 | */ |
| 752 | function 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 | |
| 793 | const 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 | */ |
| 801 | function 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 | |
| 831 | async 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 | |
| 874 | function showGraph() { |
| 875 | loading ??= loadGraph().catch(() => void (loading = null)); |
| 876 | frame ||= requestAnimationFrame(draw); |
| 877 | } |
| 878 | |
| 879 | function 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 | |
| 927 | function 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 */ |
| 1089 | function 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 */ |
| 1096 | function 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 | |
| 1104 | canvas.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 | }); |
| 1114 | canvas.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 | }); |
| 1136 | canvas.addEventListener("pointerup", () => { |
| 1137 | if (dragging?.point) dragging.point.fx = dragging.point.fy = null; |
| 1138 | simulation.alphaTarget = 0; |
| 1139 | dragging = null; |
| 1140 | }); |
| 1141 | canvas.addEventListener("pointerleave", () => { |
| 1142 | hovered = null; |
| 1143 | pointLabel.hidden = true; |
| 1144 | }); |
| 1145 | canvas.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 | |
| 1155 | function 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 | |
| 1162 | function 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 */ |
| 1218 | async 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 */ |
| 1229 | async function serving() { |
| 1230 | const response = await fetch(location.href, { headers: { "clover-page": "" }, cache: "no-store" }); |
| 1231 | return response.headers.get("clover-page"); |
| 1232 | } |
| 1233 | |
| 1234 | button.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 |
| 1245 | function 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 | } |
| 1251 | button.addEventListener("pointerenter", tick); |
| 1252 | button.addEventListener("focus", tick); |
| 1253 | // choosing a view keeps the panel open, even one that a build opened |
| 1254 | maximize.addEventListener("click", () => { |
| 1255 | maximized = !maximized; |
| 1256 | open = true; |
| 1257 | render(); |
| 1258 | }); |
| 1259 | for (const name of tabs) { |
| 1260 | find(`#tab-${name}`).addEventListener("click", () => { |
| 1261 | tab = name; |
| 1262 | open = true; |
| 1263 | render(); |
| 1264 | }); |
| 1265 | } |
| 1266 | find("[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 | }); |
| 1274 | find(".reload").addEventListener("click", () => location.reload()); |
| 1275 | const dismiss = () => { |
| 1276 | dismissed = status; |
| 1277 | render(); |
| 1278 | }; |
| 1279 | find(".dismiss").addEventListener("click", dismiss); |
| 1280 | dialog.addEventListener("cancel", (event) => { |
| 1281 | event.preventDefault(); |
| 1282 | dismiss(); |
| 1283 | }); |
| 1284 | shadow.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 |
| 1291 | shadow.addEventListener("toggle", showFold, true); |
| 1292 | shadow.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 | }); |
| 1300 | for (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 |
| 1302 | globalThis.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 |
| 1309 | if (!navigator.webdriver) connect(); |
| 1310 | |
| 1311 | function 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} |
| 1318 | button{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} |
| 1320 | button:hover{background:#555;border-color:#bbb} |
| 1321 | button: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 | |
| 1366 | table{width:100%;border-collapse:collapse} |
| 1367 | th,td{padding:3px 8px;border:2px solid #999;text-align:left;vertical-align:top;font-weight:400} |
| 1368 | th{width:1%;white-space:nowrap;background:#444} |
| 1369 | td{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} |
| 1372 | td [data-state=ok]{color:#0cff10} |
| 1373 | td [data-state=warnings]{color:#fcd330} |
| 1374 | td [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)} |
| 1386 | summary.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} |
| 1391 | details: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} |
| 1401 | figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere} |
| 1402 | .pos{color:#888} |
| 1403 | figcaption a{color:inherit;text-decoration:none} |
| 1404 | figcaption 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} |
| 1427 | summary{cursor:pointer;list-style:none;line-height:1.35} |
| 1428 | summary::-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 | |
| 1435 | dialog{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} |
| 1437 | dialog:focus{outline:0} |
| 1438 | dialog::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} |
| 1442 | dialog h2{margin:0;font-size:22px;font-weight:400;color:#ff3b3b} |
| 1443 | dialog .slot>div{margin-bottom:12px} |
| 1444 | dialog .excerpt pre,dialog figcaption,dialog .fields{font-size:13px} |
| 1445 | |
| 1446 | #view-graph{position:relative} |
| 1447 | canvas{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 */ |
| 1464 | function 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 | |
| 1475 | import { UNWRAP } from "../assert.ts"; |
| 1476 | import type * as incremental from "../incremental.ts"; |
| 1477 | import type * as stack from "../log/stack.ts"; |
| 1478 | import * as progress from "../progress.ts"; |
| 1479 | import * as string from "../string.ts"; |