| <!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, |
| previewFigure: null, |
| }; |
| |
| 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 ""; |
| } |
| |
| |
| |
| 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"; |
| } |
| |
| |
| |
| 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) { |
| |
| } |
| 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>`; |
| } |
| } |
| |
| |
| |
| |
| |
| |
| const GOAL_COLOR = "#4fd1a5"; |
| |
| 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>`; |
| } |
| |
| |
| |
| 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(); |
| }); |
| |
| |
| |
| 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(); |
| |
| |
| |
| |
| |
| 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; |
| } |
| if (typeof dataX !== "number" || typeof dataY !== "number" || Number.isNaN(dataX) || Number.isNaN(dataY)) return; |
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| 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; } |
| |
| |
| |
| 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; |
| } |
| |
| 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); |
| } |
| |
| |
| |
| |
| |
| vizEl.addEventListener("click", handleVizClick, { capture: true }); |
| |
| |
| |
| 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(); |
| }); |
| el("formatSelect").addEventListener("change", onFormatChange); |
| |
| function onFormatChange() { |
| |
| |
| |
| 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(); |
| } |
| |
| |
| |
| function escapeHtml(s) { |
| return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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"; |
| } |
| }); |
| |
| |
| |
| renderRobotRows(); |
| Promise.all([loadMaps(), loadSolvers(), loadPenaltySets()]).then(syncJsonFromForm); |
| })(); |
| </script> |
|
|
| </body> |
| </html> |
|
|