feat(ui): Solve flow + setup-templates apply + cable rendering
Header gains a Solve button (keyboard S) + Apply template button.
Canvas:
- Cables render as straight lines port→port (or device-centre when the
endpoint is a whole device, or io-marker centre). Auto-cables get a
dashed stroke; manual cables (auto=0) solid. Stroke colour = cable_type.
- Click a cable to select it → inspector pane updates.
Solve preview-diff modal:
- Calls POST .../solve?preview=1 on open.
- Renders cables_added, cables_removed, bundles_added in colour-coded
lists. Unsatisfied entries get a class="unmet" badge + one-click
quick-fix:
* "no free <type> port" → "+ Add <type> port to <device> and re-solve"
fires POST .../devices/:id/ports-and-resolve in one round-trip and
re-renders the preview.
* "ambiguous cable type" → "Specify cable type…" re-opens the
requirement modal.
* "no compatible cable type" with a preferred type → "+ Add port…"
quick-fix on the from-side device.
- Apply → POST .../solve (no preview) → re-snapshot to pick up new
cable ids + bundle assignments.
Cable inspector (kind=cable):
- Shows type, from-endpoint, to-endpoint labels.
- For solver-owned cables, shows the driving requirement (best-effort
match by unordered device pair + type) and a "Promote to manual"
button (PATCH with `promote: true` flips auto→0).
- Delete button on both auto and manual cables.
Apply-template flow:
- "Apply template…" header button opens a wide modal with a template
dropdown (Living Room / Home Office / Server Rack) + a preview panel
showing each device row (skip checkbox + editable name input) and
the template's requirements.
- Submit → POST .../apply-template with name_overrides + skip_devices,
then re-snapshot.
State + snapshot:
- state.cables, state.bundles, state.setupTemplates added.
- activateProject pulls them from the snapshot; teardown on switch.
This commit is contained in:
@@ -20,7 +20,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
|
||||
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 8">
|
||||
Export
|
||||
</button>
|
||||
</header>
|
||||
@@ -175,6 +177,35 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Solve preview-diff (slice 6) -->
|
||||
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
|
||||
<div style="padding: 16px;">
|
||||
<h2 id="sv-title">Solve preview</h2>
|
||||
<div id="sv-body" class="sv-body"></div>
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Apply template (slice 6) -->
|
||||
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
|
||||
<form method="dialog" id="form-template">
|
||||
<h2 id="tp-title">Apply setup template</h2>
|
||||
<label class="field">
|
||||
<span>Template</span>
|
||||
<select id="tp-select" required></select>
|
||||
</label>
|
||||
<div id="tp-preview" class="tp-preview"></div>
|
||||
<p class="form-error" id="tp-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Project confirm -->
|
||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||
<form method="dialog" id="form-delete-project">
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
* @typedef {{ id: number, project_id: number, from_device_id: number,
|
||||
* to_device_id: number, preferred_cable_type_id: number|null,
|
||||
* must_connect: boolean, notes: string }} ConnectionRequirement
|
||||
* @typedef {{ id: number, project_id: number, type_id: number,
|
||||
* label: string|null, auto: boolean,
|
||||
* from_port_id: number|null, from_device_id: number|null, from_io_id: number|null,
|
||||
* to_port_id: number|null, to_device_id: number|null, to_io_id: number|null }} Cable
|
||||
* @typedef {{ id: number, project_id: number, name: string, auto: boolean,
|
||||
* cable_ids: number[] }} Bundle
|
||||
* @typedef {{ id: number, name: string, description: string, built_in: boolean,
|
||||
* devices: any[], requirements: any[] }} SetupTemplate
|
||||
*/
|
||||
|
||||
const API = "/api";
|
||||
@@ -44,10 +52,13 @@ const state = {
|
||||
/** @type {Port[]} */ ports: [],
|
||||
/** @type {IOMarker[]} */ ioMarkers: [],
|
||||
/** @type {ConnectionRequirement[]} */ requirements: [],
|
||||
/** @type {Cable[]} */ cables: [],
|
||||
/** @type {Bundle[]} */ bundles: [],
|
||||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | "req" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement", id: number} | null} */ selection: null,
|
||||
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -99,6 +110,14 @@ const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connect
|
||||
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
|
||||
const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`);
|
||||
|
||||
const patchCable = (pid, id, body) => api("PATCH", `/projects/${pid}/cables/${id}`, body);
|
||||
const deleteCable = (pid, id) => api("DELETE", `/projects/${pid}/cables/${id}`);
|
||||
|
||||
const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${preview ? "?preview=1" : ""}`, {});
|
||||
const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body);
|
||||
const listSetupTemplates = () => api("GET", `/setup-templates`);
|
||||
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -225,9 +244,11 @@ function renderEmptyHint() {
|
||||
function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
const gCables = $("#canvas-cables");
|
||||
const gIO = $("#canvas-io");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
gCables.innerHTML = "";
|
||||
gIO.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
@@ -320,6 +341,55 @@ function renderCanvas() {
|
||||
gIO.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
|
||||
}
|
||||
|
||||
// Cables — straight lines between resolved endpoint anchors.
|
||||
// Auto-cables render with dashed stroke so m sees which the solver
|
||||
// placed; manual cables are solid.
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
for (const c of state.cables) {
|
||||
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
||||
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||||
if (!fromAnchor || !toAnchor) continue;
|
||||
const color = cableTypeColor.get(c.type_id) || "#888";
|
||||
const line = svgEl("line", {
|
||||
x1: fromAnchor.x, y1: fromAnchor.y,
|
||||
x2: toAnchor.x, y2: toAnchor.y,
|
||||
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
|
||||
stroke: color,
|
||||
"data-cable-id": c.id,
|
||||
});
|
||||
line.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
});
|
||||
gCables.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
|
||||
* the referenced row has gone missing (rare, but possible mid-edit). */
|
||||
function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) {
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
if (!p) return null;
|
||||
const d = deviceByID.get(p.device_id);
|
||||
if (!d) return null;
|
||||
return { x: d.x + p.x_offset, y: d.y + p.y_offset };
|
||||
}
|
||||
if (deviceID != null) {
|
||||
const d = deviceByID.get(deviceID);
|
||||
if (!d) return null;
|
||||
return { x: d.x + d.width / 2, y: d.y + d.height / 2 };
|
||||
}
|
||||
if (ioID != null) {
|
||||
const m = ioByID.get(ioID);
|
||||
if (!m) return null;
|
||||
return { x: m.x + IO_SIZE / 2, y: m.y + IO_SIZE / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
@@ -334,10 +404,97 @@ function renderInspector() {
|
||||
case "io": return renderInspectorIO(body, state.selection.id);
|
||||
case "cable_type": return renderInspectorCableType(body, state.selection.id);
|
||||
case "requirement": return renderInspectorRequirement(body, state.selection.id);
|
||||
case "cable": return renderInspectorCable(body, state.selection.id);
|
||||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspectorCable(body, id) {
|
||||
const c = state.cables.find((x) => x.id === id);
|
||||
if (!c) { body.innerHTML = ""; return; }
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
const ct = state.cableTypes.find((t) => t.id === c.type_id);
|
||||
function endpointLabel(portID, deviceID, ioID) {
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
if (!p) return "(missing port)";
|
||||
const d = deviceByID.get(p.device_id);
|
||||
return `${d?.name ?? "?"} · ${p.label ?? "port"}`;
|
||||
}
|
||||
if (deviceID != null) {
|
||||
const d = deviceByID.get(deviceID);
|
||||
return d?.name ?? "(missing device)";
|
||||
}
|
||||
if (ioID != null) {
|
||||
const m = ioByID.get(ioID);
|
||||
return m?.label ?? "(missing IO)";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id);
|
||||
const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id);
|
||||
|
||||
// Find the driving requirement (auto cable only) — match by
|
||||
// unordered device pair + (cable type or null).
|
||||
let drivingReq = null;
|
||||
if (c.auto) {
|
||||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||||
if (fromDev != null && toDev != null) {
|
||||
drivingReq = state.requirements.find((r) => {
|
||||
const same = (r.from_device_id === fromDev && r.to_device_id === toDev)
|
||||
|| (r.from_device_id === toDev && r.to_device_id === fromDev);
|
||||
if (!same) return false;
|
||||
if (r.preferred_cable_type_id == null) return true; // solver-picked match
|
||||
return r.preferred_cable_type_id === c.type_id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Cable ${c.auto ? "(solver)" : "(manual)"}</p>
|
||||
<dl>
|
||||
<dt>type</dt><dd id="cab-type"></dd>
|
||||
<dt>from</dt><dd id="cab-from"></dd>
|
||||
<dt>to</dt><dd id="cab-to"></dd>
|
||||
<dt>driver</dt><dd id="cab-driver"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
${c.auto ? `<button type="button" class="btn btn-tiny" id="cab-promote">Promote to manual</button>` : ""}
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="cab-delete">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`;
|
||||
body.querySelector("#cab-from").textContent = fromLabel;
|
||||
body.querySelector("#cab-to").textContent = toLabel;
|
||||
body.querySelector("#cab-driver").textContent = drivingReq
|
||||
? `requirement #${drivingReq.id}`
|
||||
: (c.auto ? "(no matching requirement)" : "—");
|
||||
|
||||
if (c.auto) {
|
||||
body.querySelector("#cab-promote").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
try {
|
||||
const updated = await patchCable(state.active.id, c.id, { promote: true });
|
||||
Object.assign(c, updated);
|
||||
render();
|
||||
} catch (e) { alert(`Promote failed: ${e.message}`); }
|
||||
});
|
||||
}
|
||||
body.querySelector("#cab-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm("Delete this cable?")) return;
|
||||
try {
|
||||
await deleteCable(state.active.id, c.id);
|
||||
state.cables = state.cables.filter((x) => x.id !== c.id);
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (e) { alert(`Delete failed: ${e.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorFrame(body, id) {
|
||||
const f = state.frames.find((x) => x.id === id);
|
||||
if (!f) { body.innerHTML = ""; return; }
|
||||
@@ -906,6 +1063,8 @@ async function activateProject(id) {
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -918,6 +1077,8 @@ async function activateProject(id) {
|
||||
state.devices = snap.devices || [];
|
||||
state.ioMarkers = snap.io_markers || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.cables = snap.cables || [];
|
||||
state.bundles = snap.bundles || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.selection = null;
|
||||
@@ -941,6 +1102,8 @@ async function activateProject(id) {
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -976,6 +1139,7 @@ function bindTools() {
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||||
else if (e.key === "r" || e.key === "R") armTool("req");
|
||||
else if (e.key === "s" || e.key === "S") openSolveModal();
|
||||
});
|
||||
|
||||
// Canvas-level pointerdown handles tool activation + selection clearing.
|
||||
@@ -1509,6 +1673,215 @@ function openDeleteProjectModal() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- solve flow ---------- //
|
||||
|
||||
function openSolveModal() {
|
||||
if (!state.active) { alert("Pick a project first"); return; }
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve"));
|
||||
const body = $("#sv-body");
|
||||
body.innerHTML = `<p class="muted">Computing…</p>`;
|
||||
dlg.showModal();
|
||||
solveProject(state.active.id, true)
|
||||
.then((preview) => renderSolvePreview(body, preview))
|
||||
.catch((e) => { body.innerHTML = `<p class="form-error">${escapeHtml(e.message)}</p>`; });
|
||||
|
||||
$("#sv-apply").onclick = async () => {
|
||||
if (!state.active) return;
|
||||
try {
|
||||
const applied = await solveProject(state.active.id, false);
|
||||
// Refresh from snapshot to pick up new cable ids + bundle assignments.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
state.bundles = snap.bundles || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
dlg.close();
|
||||
render();
|
||||
// Surface a brief summary as an alert (slice 9+ can replace with a toast).
|
||||
const adds = applied.cables_added?.length ?? 0;
|
||||
const rem = applied.cables_removed?.length ?? 0;
|
||||
const bun = applied.bundles_added?.length ?? 0;
|
||||
const un = applied.unsatisfied?.length ?? 0;
|
||||
const lines = [`Solve applied: +${adds} cables / -${rem} cables / +${bun} bundles`];
|
||||
if (un > 0) lines.push(`${un} requirement${un === 1 ? "" : "s"} unsatisfied`);
|
||||
console.log(lines.join("\n"));
|
||||
} catch (e) {
|
||||
alert(`Apply failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderSolvePreview(body, preview) {
|
||||
const reqByID = new Map(state.requirements.map((r) => [r.id, r]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
|
||||
|
||||
const addsHtml = (preview.cables_added || []).map((c) => {
|
||||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||||
const a = deviceByID.get(fromDev)?.name ?? "?";
|
||||
const b = deviceByID.get(toDev)?.name ?? "?";
|
||||
return `<li class="added">+ ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}</li>`;
|
||||
}).join("");
|
||||
const remsHtml = (preview.cables_removed || []).map((id) => `<li class="removed">cable #${id}</li>`).join("");
|
||||
const bunsHtml = (preview.bundles_added || []).map((b) => `<li class="added">bundle: ${escapeHtml(b.name)}</li>`).join("");
|
||||
|
||||
const unmetsHtml = (preview.unsatisfied || []).map((u) => {
|
||||
const r = reqByID.get(u.requirement_id);
|
||||
const a = r ? deviceByID.get(r.from_device_id)?.name : "?";
|
||||
const b = r ? deviceByID.get(r.to_device_id)?.name : "?";
|
||||
const reqDesc = `${escapeHtml(a ?? "?")} ↔ ${escapeHtml(b ?? "?")}`;
|
||||
let action = "";
|
||||
// Quick-fix per design v4.1 §5b.4.
|
||||
if ((u.reason || "").startsWith("no free") && u.cable_type && u.which_side) {
|
||||
const side = u.which_side === "from" ? r.from_device_id : r.to_device_id;
|
||||
const sideName = deviceByID.get(side)?.name ?? "?";
|
||||
action = `<span class="quickfix" data-fix="addport" data-device="${side}" data-cable-type="${escapeHtml(u.cable_type)}">+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||||
} else if ((u.reason || "").startsWith("ambiguous") && r) {
|
||||
action = `<span class="quickfix" data-fix="picktype" data-req="${r.id}">Specify cable type…</span>`;
|
||||
} else if ((u.reason || "").startsWith("no compat") && r && r.preferred_cable_type_id != null) {
|
||||
// No common port type for the preferred — offer to add a port on either device.
|
||||
const sideName = deviceByID.get(r.from_device_id)?.name ?? "?";
|
||||
action = `<span class="quickfix" data-fix="addport" data-device="${r.from_device_id}" data-cable-type-id="${r.preferred_cable_type_id}">+ Add port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||||
}
|
||||
return `<li class="unmet">⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}</li>`;
|
||||
}).join("");
|
||||
|
||||
body.innerHTML = `
|
||||
${addsHtml ? `<h3>Cables to add</h3><ul>${addsHtml}</ul>` : ""}
|
||||
${remsHtml ? `<h3>Cables to remove</h3><ul>${remsHtml}</ul>` : ""}
|
||||
${bunsHtml ? `<h3>Bundles to add</h3><ul>${bunsHtml}</ul>` : ""}
|
||||
${unmetsHtml ? `<h3>Unsatisfied</h3><ul>${unmetsHtml}</ul>` : ""}
|
||||
${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `<p class="muted">No changes — already solved.</p>`}
|
||||
`;
|
||||
|
||||
body.querySelectorAll(".quickfix").forEach((el) => {
|
||||
el.addEventListener("click", async () => {
|
||||
const fix = el.getAttribute("data-fix");
|
||||
if (fix === "addport") {
|
||||
const devID = Number(el.getAttribute("data-device"));
|
||||
let typeID = Number(el.getAttribute("data-cable-type-id"));
|
||||
if (!typeID) {
|
||||
const typeName = el.getAttribute("data-cable-type");
|
||||
const t = state.cableTypes.find((x) => x.name === typeName);
|
||||
typeID = t ? t.id : null;
|
||||
}
|
||||
if (!devID || !typeID) return;
|
||||
try {
|
||||
await portsAndResolve(state.active.id, devID, { type_id: typeID });
|
||||
// Refresh + re-render the preview
|
||||
const refresh = await solveProject(state.active.id, true);
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables; state.bundles = snap.bundles;
|
||||
state.ports = snap.ports; state.requirements = snap.connection_requirements;
|
||||
state.devices = snap.devices;
|
||||
renderSolvePreview(body, refresh);
|
||||
render(); // sidebar updates
|
||||
} catch (e) { alert(`Quick-fix failed: ${e.message}`); }
|
||||
} else if (fix === "picktype") {
|
||||
// Open the requirement modal so m can specify a type.
|
||||
const rid = Number(el.getAttribute("data-req"));
|
||||
const r = state.requirements.find((x) => x.id === rid);
|
||||
if (r) openRequirementModal(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- apply-template flow ---------- //
|
||||
|
||||
async function openApplyTemplateModal() {
|
||||
if (!state.active) { alert("Pick a project first"); return; }
|
||||
if (!state.setupTemplates.length) {
|
||||
state.setupTemplates = await listSetupTemplates();
|
||||
}
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-template"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-template"));
|
||||
const sel = /** @type {HTMLSelectElement} */ ($("#tp-select"));
|
||||
const preview = $("#tp-preview");
|
||||
const err = $("#tp-error");
|
||||
showError(err, "");
|
||||
|
||||
sel.innerHTML = "";
|
||||
for (const t of state.setupTemplates) {
|
||||
sel.append(new Option(t.name, String(t.id)));
|
||||
}
|
||||
sel.onchange = () => renderTemplatePreview(preview, sel.value);
|
||||
renderTemplatePreview(preview, sel.value);
|
||||
|
||||
dlg.showModal();
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.active) return;
|
||||
const tid = Number(sel.value);
|
||||
if (!tid) { showError(err, "Pick a template"); return; }
|
||||
// Collect any per-device name overrides (the preview renders inputs).
|
||||
const overrides = {};
|
||||
preview.querySelectorAll("[data-template-device-id]").forEach((row) => {
|
||||
const did = row.getAttribute("data-template-device-id");
|
||||
const input = row.querySelector("input.tp-name");
|
||||
if (input && input.value.trim()) overrides[did] = input.value.trim();
|
||||
});
|
||||
const skip = [];
|
||||
preview.querySelectorAll("input.tp-skip:checked").forEach((cb) => {
|
||||
const did = Number(cb.getAttribute("data-template-device-id"));
|
||||
if (did) skip.push(did);
|
||||
});
|
||||
try {
|
||||
await applyTemplate(state.active.id, {
|
||||
template_id: tid,
|
||||
name_overrides: overrides,
|
||||
skip_devices: skip,
|
||||
});
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.frames = snap.frames || [];
|
||||
state.devices = snap.devices || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.ioMarkers = snap.io_markers || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
state.cables = snap.cables || [];
|
||||
state.bundles = snap.bundles || [];
|
||||
dlg.close();
|
||||
render();
|
||||
} catch (ex) {
|
||||
showError(err, ex.message || "Apply failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderTemplatePreview(preview, templateIDStr) {
|
||||
if (!templateIDStr) { preview.innerHTML = ""; return; }
|
||||
const t = state.setupTemplates.find((x) => String(x.id) === templateIDStr);
|
||||
if (!t) { preview.innerHTML = ""; return; }
|
||||
const cableTypeName = new Map(state.cableTypes.map((c) => [c.id, c.name]));
|
||||
const devByTplID = new Map(t.devices.map((d) => [d.id, d]));
|
||||
const devsHtml = t.devices.map((d) => {
|
||||
const dtName = d.device_type?.name ?? `type #${d.device_type_id}`;
|
||||
const suggested = d.suggested_name ?? dtName;
|
||||
return `
|
||||
<li data-template-device-id="${d.id}">
|
||||
<input type="checkbox" class="tp-skip" data-template-device-id="${d.id}" title="Skip this device" />
|
||||
<input type="text" class="tp-name inline-input" value="${escapeHtml(suggested)}"
|
||||
style="width: 140px; display: inline-block;" />
|
||||
<span class="muted" style="margin-left: 6px;">${escapeHtml(dtName)}</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
const reqsHtml = t.requirements.map((r) => {
|
||||
const a = devByTplID.get(r.from_template_device_id);
|
||||
const b = devByTplID.get(r.to_template_device_id);
|
||||
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : "solver picks";
|
||||
return `<li>${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}</li>`;
|
||||
}).join("");
|
||||
preview.innerHTML = `
|
||||
<p>${escapeHtml(t.description)}</p>
|
||||
<h4>Devices</h4>
|
||||
<ul>${devsHtml}</ul>
|
||||
<h4>Requirements</h4>
|
||||
<ul>${reqsHtml}</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------- boot ---------- //
|
||||
|
||||
async function boot() {
|
||||
@@ -1517,6 +1890,8 @@ async function boot() {
|
||||
bindCloseButtons($("#modal-delete-project"));
|
||||
bindCloseButtons($("#modal-new-device"));
|
||||
bindCloseButtons($("#modal-requirement"));
|
||||
bindCloseButtons($("#modal-solve"));
|
||||
bindCloseButtons($("#modal-template"));
|
||||
|
||||
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
||||
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
||||
@@ -1526,6 +1901,8 @@ async function boot() {
|
||||
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
|
||||
openRequirementModal(null);
|
||||
});
|
||||
$("#btn-solve").addEventListener("click", openSolveModal);
|
||||
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
|
||||
|
||||
$("#project-select").addEventListener("change", (e) => {
|
||||
const v = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
|
||||
@@ -316,6 +316,76 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Cables on the canvas. Stroke colour comes from the cable_type;
|
||||
solver-owned cables (auto=1) render with a slightly dashed pattern
|
||||
so m can tell at a glance which the solver placed. */
|
||||
.cable-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cable-line.auto { stroke-dasharray: 8 3; }
|
||||
.cable-line:hover { stroke-width: 4; }
|
||||
.cable-line.selected { stroke-width: 4; }
|
||||
|
||||
/* Solve preview-diff modal */
|
||||
.modal-wide { width: 560px; }
|
||||
.sv-body { font-size: 13px; }
|
||||
.sv-body h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.sv-body ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sv-body li {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.sv-body li.added { border-left: 3px solid #2f9e44; }
|
||||
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
|
||||
.sv-body li.unmet { border-left: 3px solid #f59f00; }
|
||||
.sv-body li.unmet .quickfix {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tp-preview {
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tp-preview h4 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
|
||||
.tp-preview li { padding: 2px 0; }
|
||||
.tp-preview .skip {
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user