2026-06-11 15:50:41 +02:00

761 lines
26 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plan Viewer</title>
<style>
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--border: #d9dde3;
--text: #1f2430;
--muted: #6b7280;
--accent: #2563eb;
--accent-soft: #dbeafe;
--ok: #15803d;
--ok-soft: #dcfce7;
--bad: #b91c1c;
--bad-soft: #fee2e2;
--warn: #92400e;
--warn-soft: #fef3c7;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 16px; margin: 0; }
header .spacer { flex: 1; }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.bad { background: var(--bad-soft); color: var(--bad); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
#scenario-name { font-family: var(--mono); color: var(--muted); }
main {
display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(340px, 480px);
gap: 12px;
padding: 12px 16px;
align-items: start;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.panel > h2 {
font-size: 13px;
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel .body { padding: 12px; }
#dag-panel .body { overflow: auto; }
#right-col { display: flex; flex-direction: column; gap: 12px; }
#drop-hint {
margin: 40px auto;
max-width: 460px;
text-align: center;
color: var(--muted);
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px 24px;
background: var(--panel);
}
#drop-hint code { font-family: var(--mono); background: var(--bg); padding: 1px 5px; border-radius: 4px; }
body.dragging #drop-hint, body.dragging main { outline: 3px dashed var(--accent); outline-offset: -6px; }
table.rel {
border-collapse: collapse;
font-family: var(--mono);
font-size: 12.5px;
margin: 4px 0;
}
table.rel th, table.rel td {
border: 1px solid var(--border);
padding: 3px 10px;
text-align: left;
white-space: nowrap;
}
table.rel th { background: var(--bg); font-weight: 600; }
table.rel td.mark { border: none; background: none; padding-left: 6px; }
.var-wild { color: var(--muted); }
.rowcount { color: var(--muted); font-size: 12px; margin: 2px 0 10px; }
.relname { font-family: var(--mono); font-weight: 600; margin-top: 8px; }
.missing-row { color: var(--bad); }
.explain { color: var(--muted); margin: 6px 0 10px; }
.explain code, #node-detail code { font-family: var(--mono); background: var(--bg); padding: 1px 4px; border-radius: 4px; }
/* DAG SVG */
svg { display: block; }
.node rect {
fill: var(--panel);
stroke: var(--border);
stroke-width: 1.5;
rx: 7;
cursor: pointer;
}
.node:hover rect { stroke: var(--accent); }
.node.selected rect { stroke: var(--accent); stroke-width: 2.5; fill: var(--accent-soft); }
.node.root rect { stroke-dasharray: none; stroke: #94a3b8; }
.node text { font: 12px var(--mono); fill: var(--text); pointer-events: none; }
.node text.sub { font-size: 11px; fill: var(--muted); }
.node text.nid { font-size: 10px; fill: var(--muted); }
.edge { fill: none; stroke: #9ca3af; stroke-width: 1.5; }
.edge-label { font: 10px var(--mono); fill: var(--muted); }
.root-tag { font: 10px system-ui; fill: var(--ok); font-weight: 600; }
input[type="file"] { font-size: 13px; }
</style>
</head>
<body>
<header>
<h1>Plan Viewer</h1>
<span id="scenario-name"></span>
<span id="verify-badge"></span>
<span class="spacer"></span>
<input type="file" id="file-input" accept=".json,application/json">
</header>
<div id="drop-hint">
<p><strong>Drop a fixture JSON here</strong> or use the file picker above.</p>
<p>Fixtures live in <code>crates/plan-runner/fixtures/</code>.</p>
<p>The viewer parses the runner IR, evaluates the plan in the browser with
the same scan, semijoin, and natural join semantics as
<code>plan-runner</code>, and shows the relation computed at every node
of the plan DAG.</p>
</div>
<main id="main" hidden>
<section class="panel" id="dag-panel">
<h2>Plan DAG</h2>
<div class="body" id="dag-body"></div>
</section>
<div id="right-col">
<section class="panel">
<h2>Selected Node</h2>
<div class="body" id="node-detail"></div>
</section>
<section class="panel">
<h2>Result Bindings</h2>
<div class="body" id="result-body"></div>
</section>
<section class="panel">
<h2>Input Facts</h2>
<div class="body" id="facts-body"></div>
</section>
</div>
</main>
<script id="engine">
// Plan evaluation engine. Mirrors the semantics of crates/query-ops
// (scan_atom, semijoin, natural_join) and crates/plan-runner (execute,
// verify). Values are tagged objects: {int: n} or {str: s}. This block has
// no DOM dependencies so it can be extracted and tested under Node.
"use strict";
function valueKey(v) {
return "int" in v ? "i:" + v.int : "s:" + v.str;
}
function valueShow(v) {
return "int" in v ? String(v.int) : v.str;
}
function rowKey(row) {
return row.map(valueKey).join("");
}
// scan_atom: literals filter, a repeated variable forces cell equality, and
// distinct variables project in first-occurrence order.
function scanAtom(table, columns) {
if (columns.length !== table.arity) {
throw new Error(
"pattern arity mismatch: pattern has " + columns.length +
", table has " + table.arity);
}
const outputVars = [];
const outputPositions = [];
const equalityPairs = [];
const literalChecks = [];
const firstPosition = new Map();
columns.forEach((term, i) => {
if ("var" in term) {
if (firstPosition.has(term.var)) {
equalityPairs.push([firstPosition.get(term.var), i]);
} else {
firstPosition.set(term.var, i);
outputVars.push(term.var);
outputPositions.push(i);
}
} else {
literalChecks.push([i, term.lit]);
}
});
const rows = [];
outer: for (const row of table.rows) {
for (const [i, lit] of literalChecks) {
if (valueKey(row[i]) !== valueKey(lit)) continue outer;
}
for (const [j, i] of equalityPairs) {
if (valueKey(row[i]) !== valueKey(row[j])) continue outer;
}
rows.push(outputPositions.map((i) => row[i]));
}
return { columns: outputVars, rows };
}
function sharedColumns(left, right) {
const pairs = [];
left.columns.forEach((name, li) => {
const ri = right.columns.indexOf(name);
if (ri !== -1) pairs.push([li, ri]);
});
return pairs;
}
function projectKey(row, indices) {
return indices.map((i) => valueKey(row[i])).join("");
}
// semijoin: keep rows of left whose shared-column values appear in right.
function semijoin(left, right) {
const shared = sharedColumns(left, right);
const leftKeys = shared.map(([li]) => li);
const rightKeys = shared.map(([, ri]) => ri);
const rightSet = new Set(right.rows.map((r) => projectKey(r, rightKeys)));
return {
columns: left.columns.slice(),
rows: left.rows.filter((r) => rightSet.has(projectKey(r, leftKeys))),
};
}
// natural_join: hash join on shared columns; output columns are
// left.columns followed by right.columns minus the shared ones.
function naturalJoin(left, right) {
const shared = sharedColumns(left, right);
const leftKeys = shared.map(([li]) => li);
const rightKeys = shared.map(([, ri]) => ri);
const sharedRight = new Set(rightKeys);
const rightOnly = [];
right.columns.forEach((_, i) => { if (!sharedRight.has(i)) rightOnly.push(i); });
const columns = left.columns.concat(rightOnly.map((i) => right.columns[i]));
const index = new Map();
for (const row of right.rows) {
const key = projectKey(row, rightKeys);
if (!index.has(key)) index.set(key, []);
index.get(key).push(row);
}
const rows = [];
for (const leftRow of left.rows) {
const matches = index.get(projectKey(leftRow, leftKeys));
if (!matches) continue;
for (const rightRow of matches) {
rows.push(leftRow.concat(rightOnly.map((i) => rightRow[i])));
}
}
return { columns, rows };
}
// build_tables: schema declares every relation; facts fill the rows.
function buildTables(plan) {
const tables = new Map();
for (const [name, arity] of Object.entries(plan.schema)) {
tables.set(name, { arity, rows: [] });
}
for (const [name, rows] of Object.entries(plan.facts || {})) {
const table = tables.get(name);
if (!table) throw new Error("facts reference relation \"" + name + "\" not in schema");
for (const row of rows) {
if (row.length !== table.arity) {
throw new Error(
"relation \"" + name + "\": row arity " + row.length +
" differs from schema arity " + table.arity);
}
table.rows.push(row);
}
}
return tables;
}
// execute: walk nodes in ascending id order, mirroring plan_runner::execute.
// Returns a Map from node id to its computed relation; the root relation is
// results.get(plan.query.root).
function executePlan(plan, tables) {
const nodes = plan.query.nodes.slice().sort((a, b) => a.id - b.id);
const seen = new Set();
for (const node of nodes) {
if (seen.has(node.id)) throw new Error("duplicate node id " + node.id);
seen.add(node.id);
}
if (!seen.has(plan.query.root)) {
throw new Error("plan root id " + plan.query.root + " matches no node");
}
const results = new Map();
for (const node of nodes) {
let computed;
if (node.action.scan) {
const atom = node.action.scan;
const table = tables.get(atom.table);
if (!table) throw new Error("scan references missing table \"" + atom.table + "\"");
computed = scanAtom(table, atom.columns);
} else if (node.action.join) {
const join = node.action.join;
const left = results.get(join.left);
const right = results.get(join.right);
if (!left) throw new Error("node " + node.id + " depends on uncomputed node " + join.left);
if (!right) throw new Error("node " + node.id + " depends on uncomputed node " + join.right);
if (join.op === "left") computed = semijoin(left, right);
else if (join.op === "right") computed = semijoin(right, left);
else if (join.op === "natural") computed = naturalJoin(left, right);
else throw new Error("unknown join op \"" + join.op + "\"");
} else {
throw new Error("node " + node.id + " has an unsupported action");
}
results.set(node.id, computed);
}
return results;
}
// verify: project the root relation to the expected columns and compare row
// multisets, like plan_runner::verify. Returns per-row marks for display.
function verifyBindings(plan, relation) {
const expected = plan.expected_bindings;
if (!expected) return { status: "no-oracle" };
const projection = [];
for (const col of expected.columns) {
const idx = relation.columns.indexOf(col);
if (idx === -1) return { status: "error", message: "expected column \"" + col + "\" not in plan output" };
projection.push(idx);
}
const remaining = new Map();
for (const row of expected.rows) {
const key = rowKey(row);
remaining.set(key, (remaining.get(key) || 0) + 1);
}
const actualRows = relation.rows.map((row) => projection.map((i) => row[i]));
const marks = actualRows.map((row) => {
const key = rowKey(row);
const count = remaining.get(key) || 0;
if (count > 0) {
remaining.set(key, count - 1);
return true;
}
return false;
});
const missing = [];
for (const row of expected.rows) {
const key = rowKey(row);
const count = remaining.get(key) || 0;
if (count > 0) {
remaining.set(key, count - 1);
missing.push(row);
}
}
const matched = marks.every(Boolean) && missing.length === 0;
return {
status: matched ? "match" : "mismatch",
columns: expected.columns,
actualRows,
marks,
missing,
};
}
// Layered DAG layout: scans sit at depth 0, a join sits one past its deepest
// input. Within a depth column, a node's y is the average of its inputs' y
// (one barycenter pass), then collisions are resolved in order.
function layoutQuery(query) {
const byId = new Map(query.nodes.map((n) => [n.id, n]));
const depth = new Map();
const resolveDepth = (id) => {
if (depth.has(id)) return depth.get(id);
const node = byId.get(id);
const d = node.action.join
? Math.max(resolveDepth(node.action.join.left), resolveDepth(node.action.join.right)) + 1
: 0;
depth.set(id, d);
return d;
};
query.nodes.forEach((n) => resolveDepth(n.id));
const columns = new Map();
const sorted = query.nodes.slice().sort((a, b) => a.id - b.id);
for (const node of sorted) {
const d = depth.get(node.id);
if (!columns.has(d)) columns.set(d, []);
columns.get(d).push(node);
}
const slot = new Map();
const maxDepth = Math.max(...columns.keys());
for (let d = 0; d <= maxDepth; d++) {
const nodes = columns.get(d) || [];
const wanted = nodes.map((node) => {
if (!node.action.join || d === 0) return null;
const j = node.action.join;
return ((slot.get(j.left) ?? 0) + (slot.get(j.right) ?? 0)) / 2;
});
let next = 0;
nodes.forEach((node, i) => {
let y = wanted[i] === null ? next : Math.max(wanted[i], next);
slot.set(node.id, y);
next = y + 1;
});
}
return { depth, slot, maxDepth, byId };
}
if (typeof module !== "undefined") {
module.exports = { buildTables, executePlan, verifyBindings, layoutQuery, valueShow };
}
</script>
<script>
"use strict";
const NODE_W = 172;
const NODE_H = 46;
const COL_GAP = 70;
const ROW_GAP = 26;
const PAD = 16;
const ROW_DISPLAY_LIMIT = 200;
let state = null;
function el(tag, attrs, children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") node.className = v;
else node.setAttribute(k, v);
}
for (const child of children || []) {
node.append(child);
}
return node;
}
function svgEl(tag, attrs) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v);
return node;
}
function varSpan(name) {
return el("span", { class: name.startsWith("_") ? "var-wild" : "" }, [name]);
}
function columnList(columns) {
const span = el("span", {}, ["("]);
columns.forEach((c, i) => {
if (i > 0) span.append(", ");
span.append(varSpan(c));
});
span.append(")");
return span;
}
function relationTable(columns, rows, marks) {
const table = el("table", { class: "rel" });
const head = el("tr", {}, columns.map((c) => el("th", {}, [varSpan(c)])));
if (marks) head.append(el("th", {}, [""]));
table.append(head);
const limit = Math.min(rows.length, ROW_DISPLAY_LIMIT);
for (let i = 0; i < limit; i++) {
const tr = el("tr", {}, rows[i].map((v) => el("td", {}, [valueShow(v)])));
if (marks) {
tr.append(el("td", { class: "mark" }, [marks[i] ? "✓" : "✗"]));
if (!marks[i]) tr.style.color = "var(--bad)";
}
table.append(tr);
}
const wrap = el("div", {}, [table]);
if (rows.length === 0) {
wrap.append(el("div", { class: "rowcount" }, ["no rows"]));
} else if (rows.length > limit) {
wrap.append(el("div", { class: "rowcount" }, [
"showing " + limit + " of " + rows.length + " rows",
]));
}
return wrap;
}
function nodeTitle(node) {
if (node.action.scan) return "scan " + node.action.scan.table;
const op = node.action.join.op;
const symbol = op === "left" ? "⋉" : op === "right" ? "⋊" : "⋈";
return symbol + " " + op + " join";
}
function nodeSubtitle(node, results) {
const rel = results.get(node.id);
const shown = rel.columns.slice(0, 4);
let text = "(" + shown.join(", ") + (rel.columns.length > 4 ? ", …" : "") + ")";
return text + " · " + rel.rows.length + " row" + (rel.rows.length === 1 ? "" : "s");
}
function renderDag() {
const { plan, results } = state;
const layout = layoutQuery(plan.query);
const body = document.getElementById("dag-body");
body.innerHTML = "";
const maxSlot = Math.max(...plan.query.nodes.map((n) => layout.slot.get(n.id)));
const width = PAD * 2 + (layout.maxDepth + 1) * NODE_W + layout.maxDepth * COL_GAP;
const height = PAD * 2 + (maxSlot + 1) * NODE_H + maxSlot * ROW_GAP + 14;
const svg = svgEl("svg", { width, height, viewBox: "0 0 " + width + " " + height });
const pos = new Map();
for (const node of plan.query.nodes) {
const x = PAD + layout.depth.get(node.id) * (NODE_W + COL_GAP);
const y = PAD + layout.slot.get(node.id) * (NODE_H + ROW_GAP);
pos.set(node.id, { x, y });
}
for (const node of plan.query.nodes) {
if (!node.action.join) continue;
const target = pos.get(node.id);
for (const [side, sourceId] of [["L", node.action.join.left], ["R", node.action.join.right]]) {
const source = pos.get(sourceId);
const x1 = source.x + NODE_W;
const y1 = source.y + NODE_H / 2;
const x2 = target.x;
const y2 = target.y + NODE_H / 2 + (side === "L" ? -8 : 8);
const mx = (x1 + x2) / 2;
svg.append(svgEl("path", {
class: "edge",
d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`,
}));
const label = svgEl("text", { class: "edge-label", x: x2 - 12, y: y2 - 3 });
label.textContent = side;
svg.append(label);
}
}
for (const node of plan.query.nodes) {
const { x, y } = pos.get(node.id);
const isRoot = node.id === plan.query.root;
const group = svgEl("g", {
class: "node" + (isRoot ? " root" : "") + (state.selected === node.id ? " selected" : ""),
transform: `translate(${x}, ${y})`,
});
group.dataset.id = node.id;
group.append(svgEl("rect", { width: NODE_W, height: NODE_H, rx: 7 }));
const title = svgEl("text", { x: 10, y: 19 });
title.textContent = nodeTitle(node);
group.append(title);
const sub = svgEl("text", { class: "sub", x: 10, y: 36 });
sub.textContent = nodeSubtitle(node, results);
group.append(sub);
const nid = svgEl("text", { class: "nid", x: NODE_W - 8, y: 13, "text-anchor": "end" });
nid.textContent = "#" + node.id;
group.append(nid);
if (isRoot) {
const tag = svgEl("text", { class: "root-tag", x: NODE_W / 2, y: NODE_H + 13, "text-anchor": "middle" });
tag.textContent = "root";
group.append(tag);
}
group.addEventListener("click", () => selectNode(node.id));
svg.append(group);
}
body.append(svg);
}
function joinExplanation(node, results) {
const join = node.action.join;
const left = results.get(join.left);
const right = results.get(join.right);
const shared = left.columns.filter((c) => right.columns.includes(c));
const sharedText = shared.length ? shared.join(", ") : "none, so this is a cartesian product";
const p = el("p", { class: "explain" });
if (join.op === "left") {
p.append("Semijoin: keeps rows of node ", el("code", {}, ["#" + join.left]),
" whose shared columns appear in node ", el("code", {}, ["#" + join.right]),
". Shared columns: ", el("code", {}, [shared.join(", ") || "none"]), ".");
} else if (join.op === "right") {
p.append("Semijoin (reversed): keeps rows of node ", el("code", {}, ["#" + join.right]),
" whose shared columns appear in node ", el("code", {}, ["#" + join.left]),
". Shared columns: ", el("code", {}, [shared.join(", ") || "none"]), ".");
} else {
p.append("Natural join of nodes ", el("code", {}, ["#" + join.left]), " and ",
el("code", {}, ["#" + join.right]), " on ", el("code", {}, [sharedText]), ".");
}
return p;
}
function scanExplanation(node) {
const scan = node.action.scan;
const p = el("p", { class: "explain" });
p.append("Scans table ", el("code", {}, [scan.table]), " with pattern ");
const pattern = scan.columns
.map((t) => ("var" in t ? t.var : JSON.stringify(t.lit)))
.join(", ");
p.append(el("code", {}, ["(" + pattern + ")"]), ". ");
const vars = scan.columns.filter((t) => "var" in t).map((t) => t.var);
const repeated = vars.filter((v, i) => vars.indexOf(v) !== i);
const literals = scan.columns.filter((t) => "lit" in t);
if (repeated.length) {
p.append("Repeated variables (" + [...new Set(repeated)].join(", ") + ") force cell equality. ");
}
if (literals.length) {
p.append("Literal positions filter rows by constant. ");
}
p.append("Columns starting with an underscore are per-atom wildcards.");
return p;
}
function selectNode(id) {
state.selected = id;
renderDag();
const node = state.layoutById.get(id);
const rel = state.results.get(id);
const detail = document.getElementById("node-detail");
detail.innerHTML = "";
const title = el("p", {}, [el("strong", {}, [nodeTitle(node) + " "]), el("code", {}, ["#" + id])]);
detail.append(title);
detail.append(node.action.scan ? scanExplanation(node) : joinExplanation(node, state.results));
const colLine = el("p", {}, ["Output columns: "]);
colLine.append(columnList(rel.columns));
detail.append(colLine);
detail.append(relationTable(rel.columns, rel.rows));
}
function renderResult() {
const { plan, results, verdict } = state;
const body = document.getElementById("result-body");
body.innerHTML = "";
const root = results.get(plan.query.root);
const badge = document.getElementById("verify-badge");
if (verdict.status === "match") {
badge.className = "badge ok";
badge.textContent = "✓ matches expected bindings";
body.append(relationTable(verdict.columns, verdict.actualRows, verdict.marks));
} else if (verdict.status === "mismatch" || verdict.status === "error") {
badge.className = "badge bad";
badge.textContent = "✗ differs from expected bindings";
if (verdict.status === "error") {
body.append(el("p", { class: "missing-row" }, [verdict.message]));
body.append(relationTable(root.columns, root.rows));
} else {
body.append(relationTable(verdict.columns, verdict.actualRows, verdict.marks));
if (verdict.missing.length) {
body.append(el("p", { class: "missing-row" }, ["Expected rows not produced:"]));
body.append(relationTable(verdict.columns, verdict.missing));
}
}
} else {
badge.className = "badge warn";
badge.textContent = "no expected bindings in fixture";
body.append(relationTable(root.columns, root.rows));
}
body.append(el("p", { class: "explain" }, [
"Projection of root node #" + plan.query.root +
" to the fixture's expected columns. The full root relation, including " +
"wildcard columns, is shown by selecting the root node.",
]));
}
function renderFacts() {
const { plan } = state;
const body = document.getElementById("facts-body");
body.innerHTML = "";
const names = Object.keys(plan.schema).sort();
for (const name of names) {
const arity = plan.schema[name];
const rows = (plan.facts || {})[name] || [];
body.append(el("div", { class: "relname" }, [name + " / " + arity]));
const columns = Array.from({ length: arity }, (_, i) => "col" + i);
body.append(relationTable(columns, rows));
}
}
function loadPlan(text, sourceName) {
let plan;
try {
plan = JSON.parse(text);
} catch (err) {
alert("Not valid JSON: " + err.message);
return;
}
if (!plan.query || !plan.schema) {
alert("This JSON has no \"query\" and \"schema\" blocks; expected a plan-runner fixture.");
return;
}
let tables, results, verdict;
try {
tables = buildTables(plan);
results = executePlan(plan, tables);
verdict = verifyBindings(plan, results.get(plan.query.root));
} catch (err) {
alert("Failed to evaluate plan: " + err.message);
return;
}
state = {
plan,
results,
verdict,
selected: plan.query.root,
layoutById: new Map(plan.query.nodes.map((n) => [n.id, n])),
};
document.getElementById("scenario-name").textContent =
plan._scenario || sourceName || "";
document.getElementById("drop-hint").hidden = true;
document.getElementById("main").hidden = false;
renderDag();
renderResult();
renderFacts();
selectNode(plan.query.root);
}
function loadFile(file) {
file.text().then((text) => loadPlan(text, file.name));
}
document.getElementById("file-input").addEventListener("change", (event) => {
if (event.target.files.length) loadFile(event.target.files[0]);
});
document.addEventListener("dragover", (event) => {
event.preventDefault();
document.body.classList.add("dragging");
});
document.addEventListener("dragleave", () => document.body.classList.remove("dragging"));
document.addEventListener("drop", (event) => {
event.preventDefault();
document.body.classList.remove("dragging");
if (event.dataTransfer.files.length) loadFile(event.dataTransfer.files[0]);
});
// When served over HTTP, ?fixture=<path> loads a fixture relative to this
// page, for example ?fixture=../../crates/plan-runner/fixtures/self_loop.json.
const fixtureParam = new URLSearchParams(location.search).get("fixture");
if (fixtureParam) {
fetch(fixtureParam)
.then((response) => response.text())
.then((text) => loadPlan(text, fixtureParam))
.catch((err) => alert("Failed to fetch " + fixtureParam + ": " + err.message));
}
</script>
</body>
</html>