Spooky / fastapi_app /web /demo.html
JavideuS's picture
Deploy a665039
beeea66 verified
Raw
History Blame Contribute Delete
33.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spooky — Planner Demo</title>
<script charset="utf-8" src="https://cdn.plot.ly/plotly-3.5.0.min.js"></script>
<style>
:root {
--bg: #0a0d12;
--surface: #12161f;
--surface-2: #181d29;
--border: #262c3b;
--text: #e7eaf1;
--text-dim: #8791a8;
--accent: #7c8cff;
--accent-soft: rgba(124, 140, 255, 0.16);
--accent-text: #101223;
--success: #4fd1a5;
--error: #f0616b;
--badge-dwave: #e8a33d;
--badge-pennylane: #4fd1a5;
--badge-qiskit: #b98cf2;
--badge-iqm: #4dabf7;
--radius: 8px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f3f4f8;
--surface: #ffffff;
--surface-2: #eef0f6;
--border: #dbdfea;
--text: #171a24;
--text-dim: #5b6479;
--accent: #5462e0;
--accent-soft: rgba(84, 98, 224, 0.12);
--accent-text: #ffffff;
--success: #1f9d73;
--error: #c8394a;
--badge-dwave: #b9791f;
--badge-pennylane: #1f9d73;
--badge-qiskit: #8b5cd6;
--badge-iqm: #1c7ed6;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, "Segoe UI", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.45;
}
code, .mono, input, select, textarea {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
}
header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 14px 22px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
margin: 0;
}
header h1 span { color: var(--accent); }
header .subtitle {
font-size: 12px;
color: var(--text-dim);
}
.layout {
display: grid;
grid-template-columns: 1fr 380px;
min-height: calc(100vh - 49px);
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
.viz-panel {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.viz-toolbar {
display: flex;
align-items: center;
gap: 10px;
}
.viz-toolbar select {
width: auto;
min-width: 170px;
}
.viz-toolbar .info-line { margin: 0; }
.viz-caption {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
#viz {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
min-height: 420px;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
#viz .placeholder { color: var(--text-dim); font-size: 12px; padding: 10px 14px; text-align: center; }
.result-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
min-width: 110px;
}
.stat .label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.stat .value {
font-size: 18px;
font-weight: 600;
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.robot-results { display: flex; flex-direction: column; gap: 6px; }
.robot-result-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
font-size: 12.5px;
}
.robot-result-row .swatch {
width: 10px; height: 10px; border-radius: 50%; flex: none;
}
.robot-result-row .rid { font-weight: 600; min-width: 70px; }
.robot-result-row .path { color: var(--text-dim); overflow-x: auto; white-space: nowrap; }
aside {
background: var(--surface);
border-left: 1px solid var(--border);
padding: 18px;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.field-group { display: flex; flex-direction: column; gap: 6px; }
.eyebrow {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-dim);
font-weight: 600;
}
select, input, textarea {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 7px 9px;
font-size: 13px;
width: 100%;
}
select:focus, input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.info-line {
font-size: 11.5px;
color: var(--text-dim);
line-height: 1.5;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
cursor: pointer;
}
.toggle-row input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 4px; }
.tag {
font-size: 10px;
padding: 2px 7px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-dim);
letter-spacing: 0.02em;
}
.tag.backend-dwave { color: var(--badge-dwave); border-color: var(--badge-dwave); }
.tag.backend-pennylane { color: var(--badge-pennylane); border-color: var(--badge-pennylane); }
.tag.backend-qiskit { color: var(--badge-qiskit); border-color: var(--badge-qiskit); }
.tag.backend-iqm { color: var(--badge-iqm); border-color: var(--badge-iqm); }
.robot-row {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
}
.robot-row-header {
display: flex;
align-items: center;
gap: 6px;
}
.robot-row-header input { flex: 1; }
.robot-row .grid-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.robot-row .grid-inputs.full { grid-template-columns: 1fr 1fr 1fr 1fr; }
.robot-row label {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
display: block;
margin-bottom: 2px;
}
.robot-row .remove-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 6px;
width: 26px;
height: 26px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.robot-row .remove-btn:hover { color: var(--error); border-color: var(--error); }
.pick-row { display: flex; gap: 6px; }
.pick-row .pick-btn { flex: 1; text-align: center; }
.pick-btn.active {
border-color: var(--accent);
border-style: solid;
color: var(--accent);
background: var(--accent-soft);
}
#viz.picking { cursor: crosshair; }
button.secondary {
background: none;
border: 1px dashed var(--border);
color: var(--text-dim);
border-radius: 6px;
padding: 7px 10px;
font-size: 12.5px;
cursor: pointer;
}
button.secondary:hover { color: var(--text); border-color: var(--accent); }
.tabs { display: flex; gap: 2px; background: var(--surface-2); border-radius: 6px; padding: 2px; }
.tabs button {
flex: 1;
background: none;
border: none;
color: var(--text-dim);
padding: 6px 8px;
border-radius: 5px;
font-size: 12px;
cursor: pointer;
}
.tabs button.active { background: var(--accent); color: var(--accent-text); }
#jsonEditor { min-height: 220px; resize: vertical; }
.plan-btn {
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: 6px;
padding: 11px 14px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.plan-btn:disabled { opacity: 0.55; cursor: default; }
.error-banner {
background: rgba(240, 97, 107, 0.12);
border: 1px solid var(--error);
color: var(--error);
border-radius: 6px;
padding: 9px 11px;
font-size: 12.5px;
display: none;
}
.error-banner.visible { display: block; }
select[data-recommended="true"] { border-color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>SPOOKY<span>·</span>PLANNER</h1>
<div class="subtitle">Quantum-classical MAPF demo — hits /v1/plan directly</div>
</header>
<div class="layout">
<div class="viz-panel">
<div class="viz-toolbar">
<span class="eyebrow">Map</span>
<select id="mapSelect"></select>
<span class="info-line" id="mapInfo"></span>
<button type="button" class="secondary" id="uploadMapBtn">+ upload .h5</button>
<input type="file" id="uploadMapInput" accept=".h5,.hdf5" style="display:none;" />
</div>
<div class="viz-toolbar">
<span class="eyebrow">Coordinates</span>
<select id="coordFormatSelect">
<option value="matrix">matrix — (row, col), native</option>
<option value="cartesian">cartesian — (x, y), robotics Y-up</option>
</select>
<span class="info-line">Affects the preview/solved plot axes and all robots' start/goal below.</span>
</div>
<div class="viz-caption">Map preview</div>
<div id="viz"></div>
<div class="result-strip" id="resultStrip" style="display:none;">
<div class="stat"><div class="label">Cost</div><div class="value" id="statCost"></div></div>
<div class="stat"><div class="label">Planning time</div><div class="value" id="statTime"></div></div>
<div class="stat"><div class="label">Solver</div><div class="value" id="statSolver" style="font-size:13px;"></div></div>
</div>
<div class="error-banner" id="errorBanner"></div>
<div class="robot-results" id="robotResults"></div>
</div>
<aside>
<div class="field-group">
<div class="eyebrow">Solver</div>
<select id="solverSelect"></select>
<div class="info-line" id="solverInfo"></div>
</div>
<div class="field-group">
<div class="eyebrow">Robots</div>
<div id="robotRows" style="display:flex; flex-direction:column; gap:8px;"></div>
<button class="secondary" id="addRobotBtn" type="button">+ add robot</button>
<div class="info-line" id="pickHint" style="min-height:14px;"></div>
</div>
<div class="field-group">
<div class="eyebrow">Parameters</div>
<div class="robot-row" style="grid-template-columns: 1fr 1fr;">
<div>
<label>Format</label>
<select id="formatSelect">
<option value="grid">grid</option>
<option value="graph">graph</option>
</select>
</div>
<div>
<label>Penalty set</label>
<select id="penaltySelect"></select>
</div>
</div>
<div style="margin-top:6px;">
<label class="eyebrow" style="margin-bottom:2px; display:block;">T (blank = auto)</label>
<input id="tInput" type="number" min="1" placeholder="auto" />
</div>
<label class="toggle-row" style="margin-top:6px;">
<input id="clipAtGoalToggle" type="checkbox" />
<span>Clip paths at goal</span>
</label>
<div class="info-line">Trim each robot's returned path once it's parked at goal, keeping only the first arrival.</div>
</div>
<div class="field-group">
<div class="eyebrow">Request</div>
<div class="tabs">
<button type="button" id="tabForm" class="active">Form</button>
<button type="button" id="tabJson">Raw JSON</button>
</div>
<textarea id="jsonEditor" style="display:none;" spellcheck="false"></textarea>
</div>
<button class="plan-btn" id="planBtn" type="button">Plan path</button>
</aside>
</div>
<script>
(function () {
"use strict";
const state = {
maps: {},
solvers: {},
robots: [
{ id: "", start: [0, 0], goal: [0, 0], start_time: 0, priority: 1.0, safety_radius: 0.5 },
],
activeTab: "form",
picking: null, // { idx, field: "start"|"goal" } while waiting for a map click
previewFigure: null, // last-fetched map preview {data, layout}, re-used to overlay start/goal markers
};
const el = (id) => document.getElementById(id);
const vizEl = el("viz");
function backendOf(solverKey) {
if (solverKey.startsWith("dwave")) return "dwave";
if (solverKey.includes("qiskit")) return "qiskit";
if (solverKey.includes("iqm")) return "iqm";
if (solverKey.startsWith("pennylane")) return "pennylane";
if (solverKey.startsWith("classic")) return "classic";
return "";
}
// ---- Load reference data ----
async function loadMaps() {
const res = await fetch("/v1/maps");
const data = await res.json();
state.maps = data.maps;
const select = el("mapSelect");
select.innerHTML = "";
Object.entries(state.maps).forEach(([id, info]) => {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
});
if (select.options.length) {
select.selectedIndex = 0;
onMapChange();
}
}
async function loadSolvers() {
const res = await fetch("/solvers");
state.solvers = await res.json();
const select = el("solverSelect");
select.innerHTML = "";
const groups = {};
Object.entries(state.solvers).forEach(([key, cfg]) => {
const backend = backendOf(key);
groups[backend] = groups[backend] || [];
groups[backend].push([key, cfg]);
});
let defaultKey = null;
Object.entries(groups).forEach(([backend, entries]) => {
const group = document.createElement("optgroup");
group.label = backend;
entries.forEach(([key, cfg]) => {
const opt = document.createElement("option");
opt.value = key;
const tags = cfg.tags || [];
const isGeneral = tags.includes("general");
opt.textContent = isGeneral ? `${key} ★ recommended` : key;
if (isGeneral && !defaultKey) defaultKey = key;
group.appendChild(opt);
});
select.appendChild(group);
});
if (defaultKey) select.value = defaultKey;
onSolverChange();
}
async function loadPenaltySets() {
const res = await fetch("/v1/penalty-sets");
const data = await res.json();
const select = el("penaltySelect");
select.innerHTML = "";
data.penalty_sets.forEach((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
});
select.value = data.default || "crash";
}
// ---- Map preview ----
async function onMapChange() {
const mapId = el("mapSelect").value;
const info = state.maps[mapId];
el("mapInfo").textContent = info
? `${info.grid_size || "?"}${info.description || "no description"}`
: "";
if (!info || !info.has_grid && info.loaded) {
// still try — has_grid may just be unresolved pre-load
}
try {
const coordinateFormat = el("coordFormatSelect").value;
const res = await fetch(
`/v1/maps/${encodeURIComponent(mapId)}/preview?embed=json&coordinate_format=${coordinateFormat}`
);
if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
const fig = await res.json();
state.previewFigure = fig;
refreshPreviewWithRobots();
} catch (err) {
state.previewFigure = null;
vizEl.innerHTML = `<div class="placeholder">Couldn't preview this map: ${escapeHtml(String(err.message || err))}</div>`;
}
}
// Overlays each robot's current start/goal on top of the last-fetched map
// preview, so picking (or typing) a coordinate is visible immediately
// instead of only updating the sidebar's number inputs. Only touches the
// plain preview — a solved plan's figure (drawn by renderResults) already
// carries its own start/goal markers from the backend and isn't affected.
const GOAL_COLOR = "#4fd1a5"; // fixed, matches --success — goals read as "target", not per-robot
function robotMarkerTraces() {
const isCartesian = el("coordFormatSelect").value === "cartesian";
const toXY = (pos) => (isCartesian ? [pos[0], pos[1]] : [pos[1], pos[0]]);
const starts = { x: [], y: [], hovertext: [], color: [] };
const goals = { x: [], y: [], hovertext: [] };
state.robots.forEach((r, idx) => {
const color = ROBOT_COLORS[idx % ROBOT_COLORS.length];
const label = r.id || `robot_${idx}`;
const [sx, sy] = toXY(r.start);
const [gx, gy] = toXY(r.goal);
starts.x.push(sx); starts.y.push(sy); starts.hovertext.push(`${label} start`); starts.color.push(color);
goals.x.push(gx); goals.y.push(gy); goals.hovertext.push(`${label} goal`);
});
if (!starts.x.length) return [];
return [
{
x: starts.x, y: starts.y, mode: "markers+text", type: "scatter",
marker: { size: 15, color: starts.color, symbol: "circle", line: { color: "#fff", width: 1.5 } },
text: state.robots.map(() => "S"), textfont: { size: 8, color: "#fff" },
hovertext: starts.hovertext, hoverinfo: "text", showlegend: false, name: "start",
},
{
x: goals.x, y: goals.y, mode: "markers+text", type: "scatter",
marker: { size: 15, color: GOAL_COLOR, symbol: "diamond", line: { color: "#fff", width: 1.5 } },
text: state.robots.map(() => "G"), textfont: { size: 8, color: "#fff" },
hovertext: goals.hovertext, hoverinfo: "text", showlegend: false, name: "goal",
},
];
}
function refreshPreviewWithRobots() {
if (!state.previewFigure) return;
const combinedData = [...state.previewFigure.data, ...robotMarkerTraces()];
Plotly.react(vizEl, combinedData, state.previewFigure.layout, { responsive: true });
}
el("uploadMapBtn").addEventListener("click", () => el("uploadMapInput").click());
el("uploadMapInput").addEventListener("change", handleMapUpload);
async function handleMapUpload(evt) {
const file = evt.target.files[0];
if (!file) return;
clearError();
const btn = el("uploadMapBtn");
const prevText = btn.textContent;
btn.disabled = true;
btn.textContent = "Uploading…";
let mapId = file.name.replace(/\.(h5|hdf5)$/i, "");
if (state.maps[mapId]) mapId = `${mapId}_${Date.now().toString(36)}`;
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/v1/maps/${encodeURIComponent(mapId)}`, {
method: "POST",
body: formData,
});
const data = await res.json();
if (!res.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map((d) => d.msg).join("; ")
: data.detail || res.statusText;
throw new Error(detail);
}
state.maps[mapId] = {
description: "uploaded",
loaded: true,
grid_size: data.grid_size,
has_grid: !!data.grid_size,
has_graph: data.has_graph,
source: "uploaded",
};
const opt = document.createElement("option");
opt.value = mapId;
opt.textContent = mapId;
el("mapSelect").appendChild(opt);
el("mapSelect").value = mapId;
onMapChange();
syncJsonFromForm();
} catch (err) {
showError(`Map upload failed: ${String(err.message || err)}`);
} finally {
btn.disabled = false;
btn.textContent = prevText;
evt.target.value = "";
}
}
function onSolverChange() {
const key = el("solverSelect").value;
const cfg = state.solvers[key];
const infoEl = el("solverInfo");
if (!cfg) { infoEl.innerHTML = ""; return; }
const backend = backendOf(key);
const tags = cfg.tags || [];
infoEl.innerHTML =
(cfg.description ? `${escapeHtml(cfg.description)}` : "") +
`<div class="tags">${tags.map((t) => `<span class="tag backend-${backend}">${escapeHtml(t)}</span>`).join("")}</div>`;
}
// ---- Robot rows ----
function coordLabels() {
return el("coordFormatSelect").value === "cartesian"
? { a: "Start x", b: "Start y", c: "Goal x", d: "Goal y" }
: { a: "Start row", b: "Start col", c: "Goal row", d: "Goal col" };
}
function renderRobotRows() {
const container = el("robotRows");
container.innerHTML = "";
const labels = coordLabels();
state.robots.forEach((r, idx) => {
const row = document.createElement("div");
row.className = "robot-row";
row.dataset.idx = String(idx);
row.innerHTML = `
<div class="robot-row-header">
<input type="text" data-field="id" placeholder="robot_${idx} (auto)" value="${r.id}" />
<button type="button" class="remove-btn" title="Remove robot">×</button>
</div>
<div class="grid-inputs full">
<div><label>${labels.a}</label><input type="number" data-field="start0" value="${r.start[0]}" /></div>
<div><label>${labels.b}</label><input type="number" data-field="start1" value="${r.start[1]}" /></div>
<div><label>${labels.c}</label><input type="number" data-field="goal0" value="${r.goal[0]}" /></div>
<div><label>${labels.d}</label><input type="number" data-field="goal1" value="${r.goal[1]}" /></div>
</div>
<div class="pick-row">
<button type="button" class="secondary pick-btn" data-pick="start">pick start on map</button>
<button type="button" class="secondary pick-btn" data-pick="goal">pick goal on map</button>
</div>
`;
row.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.field;
if (field === "id") { r.id = input.value; syncJsonFromForm(); refreshPreviewWithRobots(); return; }
const val = parseInt(input.value, 10) || 0;
if (field === "start0") r.start[0] = val;
if (field === "start1") r.start[1] = val;
if (field === "goal0") r.goal[0] = val;
if (field === "goal1") r.goal[1] = val;
syncJsonFromForm();
refreshPreviewWithRobots();
});
});
row.querySelector(".remove-btn").addEventListener("click", () => {
if (state.robots.length <= 1) return;
state.robots.splice(idx, 1);
if (state.picking) {
if (state.picking.idx === idx) state.picking = null;
else if (state.picking.idx > idx) state.picking.idx -= 1;
}
renderRobotRows();
syncJsonFromForm();
refreshPreviewWithRobots();
});
row.querySelectorAll(".pick-btn").forEach((btn) => {
btn.addEventListener("click", () => setPicking(idx, btn.dataset.pick));
});
container.appendChild(row);
});
updatePickingUI();
}
el("addRobotBtn").addEventListener("click", () => {
state.robots.push({ id: "", start: [0, 0], goal: [0, 0], start_time: 0, priority: 1.0, safety_radius: 0.5 });
renderRobotRows();
syncJsonFromForm();
refreshPreviewWithRobots();
});
// ---- Click-to-place start/goal ----
let previousDragmode = "zoom";
function setPicking(idx, field) {
if (state.picking && state.picking.idx === idx && state.picking.field === field) {
exitPicking();
return;
}
state.picking = { idx, field };
updatePickingUI();
// A plain click in Plotly's default zoom dragmode can still be read as
// (the start of) a zoom-box drag, and its drag layer swallows the click
// before it reaches our handler either way — disable dragging entirely
// for the duration of the pick so the click is free to reach us and
// can't be mistaken for a zoom/pan gesture.
if (vizEl._fullLayout) {
previousDragmode = vizEl._fullLayout.dragmode || "zoom";
Plotly.relayout(vizEl, { dragmode: false });
}
}
function exitPicking() {
state.picking = null;
updatePickingUI();
if (vizEl._fullLayout) Plotly.relayout(vizEl, { dragmode: previousDragmode });
}
function updatePickingUI() {
document.querySelectorAll(".pick-btn").forEach((btn) => btn.classList.remove("active"));
const hintEl = el("pickHint");
if (state.picking) {
const { idx, field } = state.picking;
const robot = state.robots[idx];
const label = robot ? (robot.id || `robot_${idx}`) : "";
const activeBtn = document.querySelector(`.robot-row[data-idx="${idx}"] .pick-btn[data-pick="${field}"]`);
if (activeBtn) activeBtn.classList.add("active");
vizEl.classList.add("picking");
hintEl.textContent = `Click the map to set ${label}'s ${field}.`;
} else {
vizEl.classList.remove("picking");
hintEl.textContent = "";
}
}
function handleVizClick(evt) {
if (!state.picking) return;
const gd = vizEl;
const fullLayout = gd._fullLayout;
if (!fullLayout || !fullLayout.xaxis || !fullLayout.yaxis || !fullLayout._size) return;
const rect = gd.getBoundingClientRect();
const size = fullLayout._size;
const pxX = evt.clientX - rect.left - size.l;
const pxY = evt.clientY - rect.top - size.t;
let dataX, dataY;
try {
dataX = fullLayout.xaxis.p2c(pxX);
dataY = fullLayout.yaxis.p2c(pxY);
} catch (err) {
return; // clicked outside the plotting area, or an incompatible plotly build
}
if (typeof dataX !== "number" || typeof dataY !== "number" || Number.isNaN(dataX) || Number.isNaN(dataY)) return;
// Bounds come from the already-rendered axis range, not state.maps[mapId]
// — that registry snapshot is taken once at page load, before any map has
// actually been loaded server-side, so its grid_size is null until some
// other request (e.g. this very preview) triggers the lazy load. The
// range on the live figure is always correct for what's on screen right now.
let rows = null, cols = null;
const xr = fullLayout.xaxis.range, yr = fullLayout.yaxis.range;
if (Array.isArray(xr) && Array.isArray(yr)) {
cols = Math.round(Math.max(xr[0], xr[1]) + 0.5);
rows = Math.round(Math.max(yr[0], yr[1]) + 0.5);
}
const isCartesian = el("coordFormatSelect").value === "cartesian";
let a = Math.round(isCartesian ? dataX : dataY);
let b = Math.round(isCartesian ? dataY : dataX);
// a/b bounds: matrix -> a is row (bounded by rows), b is col (bounded by cols);
// cartesian -> a is x (bounded by cols), b is y (bounded by rows).
if (rows != null && cols != null) {
const aMax = isCartesian ? cols - 1 : rows - 1;
const bMax = isCartesian ? rows - 1 : cols - 1;
a = Math.min(Math.max(a, 0), aMax);
b = Math.min(Math.max(b, 0), bMax);
}
const { idx, field } = state.picking;
if (!state.robots[idx]) { exitPicking(); return; }
// Plot-space (x, y) for this pick — same mapping robotMarkerTraces()/toXY()
// uses, so it lines up with how the Obstacles trace itself was drawn.
const [plotX, plotY] = isCartesian ? [a, b] : [b, a];
if (isObstacleAt(plotX, plotY)) {
const label = state.robots[idx].id || `robot_${idx}`;
el("pickHint").textContent = `That cell is an obstacle — pick a different cell for ${label}'s ${field}.`;
return; // stay in picking mode so the user can try again
}
state.robots[idx][field] = [a, b];
exitPicking();
renderRobotRows();
syncJsonFromForm();
refreshPreviewWithRobots();
}
function isObstacleAt(x, y) {
if (!state.previewFigure) return false;
const trace = state.previewFigure.data.find((t) => t.name === "Obstacles");
if (!trace || !trace.x) return false;
return trace.x.some((ox, i) => ox === x && trace.y[i] === y);
}
// Capture phase, not bubble: Plotly's own drag/zoom layer sits between us
// and the click target and can swallow the event on its way back up before
// a bubble-phase listener here would ever see it. Capture fires top-down,
// so this runs before Plotly's (bubble-phase) internal handlers do.
vizEl.addEventListener("click", handleVizClick, { capture: true });
// ---- Form <-> JSON ----
function buildRequestBody() {
const tVal = el("tInput").value.trim();
const coordinateFormat = el("coordFormatSelect").value;
return {
map_id: el("mapSelect").value,
solver: el("solverSelect").value,
format: el("formatSelect").value,
robots: state.robots.map((r, i) => ({
id: r.id || `robot_${i}`,
start: r.start,
goal: r.goal,
start_time: r.start_time,
priority: r.priority,
safety_radius: r.safety_radius,
coordinate_format: coordinateFormat,
})),
penalty_set: el("penaltySelect").value,
T: tVal ? parseInt(tVal, 10) : null,
details: false,
render: true,
clip_at_goal: el("clipAtGoalToggle").checked,
};
}
function syncJsonFromForm() {
if (state.activeTab !== "json") {
el("jsonEditor").value = JSON.stringify(buildRequestBody(), null, 2);
}
}
el("tabForm").addEventListener("click", () => {
state.activeTab = "form";
el("tabForm").classList.add("active");
el("tabJson").classList.remove("active");
el("jsonEditor").style.display = "none";
document.querySelectorAll("aside > .field-group").forEach((f) => (f.style.display = ""));
});
el("tabJson").addEventListener("click", () => {
state.activeTab = "json";
el("tabJson").classList.add("active");
el("tabForm").classList.remove("active");
el("jsonEditor").value = JSON.stringify(buildRequestBody(), null, 2);
el("jsonEditor").style.display = "block";
});
["mapSelect", "solverSelect", "formatSelect", "penaltySelect", "tInput", "coordFormatSelect", "clipAtGoalToggle"].forEach((id) => {
el(id).addEventListener("change", syncJsonFromForm);
});
el("mapSelect").addEventListener("change", onMapChange);
el("solverSelect").addEventListener("change", onSolverChange);
el("coordFormatSelect").addEventListener("change", () => {
renderRobotRows();
onMapChange(); // re-fetch the preview so its axes match the new convention
});
el("formatSelect").addEventListener("change", onFormatChange);
function onFormatChange() {
// Graph mode resolves positions straight to node ids server-side — there's
// no coordinate frame to flip, so cartesian isn't accepted there (api.py
// rejects it with a 400). Lock the toggle to matrix while graph is active.
const isGraph = el("formatSelect").value === "graph";
const coordSelect = el("coordFormatSelect");
const wasCartesian = coordSelect.value === "cartesian";
coordSelect.disabled = isGraph;
if (isGraph) coordSelect.value = "matrix";
renderRobotRows();
if (isGraph && wasCartesian) onMapChange(); // refresh preview back to matrix axes
}
// ---- Plan ----
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function showError(msg) {
const banner = el("errorBanner");
banner.textContent = msg;
banner.classList.add("visible");
}
function clearError() {
el("errorBanner").classList.remove("visible");
}
const ROBOT_COLORS = ["#7c8cff", "#f0616b", "#4fd1a5", "#e8a33d", "#b98cf2", "#4fc3f7"];
function renderResults(response) {
el("resultStrip").style.display = "flex";
el("statCost").textContent = response.cost.toFixed(3);
el("statTime").textContent = `${(response.metrics.planning_time * 1000).toFixed(1)} ms`;
el("statSolver").textContent = response.solver_used;
const resultsEl = el("robotResults");
resultsEl.innerHTML = "";
response.paths.forEach((p, idx) => {
const row = document.createElement("div");
row.className = "robot-result-row";
const color = ROBOT_COLORS[idx % ROBOT_COLORS.length];
row.innerHTML = `
<div class="swatch" style="background:${color};"></div>
<div class="rid">${escapeHtml(p.robot_id)}</div>
<span class="tag" title="Coordinate format">${escapeHtml(p.coordinate_format || "matrix")}</span>
<div class="path">${p.path.map((c) => `(${c[0]},${c[1]})`).join(" → ")}</div>
`;
resultsEl.appendChild(row);
});
if (response.figure) {
const { data, layout, frames } = response.figure;
Plotly.newPlot(vizEl, data, layout, { responsive: true }).then(() => {
if (frames && frames.length) Plotly.addFrames(vizEl, frames);
});
}
}
el("planBtn").addEventListener("click", async () => {
clearError();
const btn = el("planBtn");
btn.disabled = true;
btn.textContent = "Planning…";
let body;
if (state.activeTab === "json") {
try {
body = JSON.parse(el("jsonEditor").value);
} catch (err) {
showError(`Invalid JSON: ${err.message}`);
btn.disabled = false;
btn.textContent = "Plan path";
return;
}
} else {
body = buildRequestBody();
}
try {
const res = await fetch("/v1/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map((d) => d.msg).join("; ")
: data.detail || res.statusText;
throw new Error(detail);
}
renderResults(data);
} catch (err) {
showError(String(err.message || err));
} finally {
btn.disabled = false;
btn.textContent = "Plan path";
}
});
// ---- Init ----
renderRobotRows();
Promise.all([loadMaps(), loadSolvers(), loadPenaltySets()]).then(syncJsonFromForm);
})();
</script>
</body>
</html>