761 lines
26 KiB
HTML
761 lines
26 KiB
HTML
<!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(" |