765 lines
26 KiB
HTML
Raw Normal View History

<!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;
2026-06-11 16:52:19 +02:00
const SIBLING_GAP = 28;
const LAYER_GAP = 60;
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);
2026-06-11 16:52:19 +02:00
const shown = rel.columns.slice(0, 3);
let text = "(" + shown.join(", ") + (rel.columns.length > 3 ? ", …" : "") + ")";
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)));
2026-06-11 16:52:19 +02:00
const width = PAD * 2 + (maxSlot + 1) * NODE_W + maxSlot * SIBLING_GAP;
const height = PAD * 2 + (layout.maxDepth + 1) * NODE_H + layout.maxDepth * LAYER_GAP + 14;
const svg = svgEl("svg", { width, height, viewBox: "0 0 " + width + " " + height });
const pos = new Map();
for (const node of plan.query.nodes) {
2026-06-11 16:52:19 +02:00
const x = PAD + layout.slot.get(node.id) * (NODE_W + SIBLING_GAP);
const y = PAD + layout.depth.get(node.id) * (NODE_H + LAYER_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);
2026-06-11 16:52:19 +02:00
const x1 = source.x + NODE_W / 2;
const y1 = source.y + NODE_H;
const x2 = target.x + NODE_W / 2 + (side === "L" ? -16 : 16);
const y2 = target.y;
const my = (y1 + y2) / 2;
svg.append(svgEl("path", {
class: "edge",
2026-06-11 16:52:19 +02:00
d: `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`,
}));
2026-06-11 16:52:19 +02:00
const label = svgEl("text", {
class: "edge-label",
x: x2 + (side === "L" ? -12 : 6),
y: y2 - 4,
});
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>