Nothing selected.
`;
return;
}
switch (state.selection.kind) {
case "frame": return renderInspectorFrame(body, state.selection.id);
case "device": return renderInspectorDevice(body, state.selection.id);
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);
case "port": return renderInspectorPort(body, state.selection.id);
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
default: body.innerHTML = `Nothing selected.
`;
}
}
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 = `
No ports yet.
`;
// Requirements involving this device — sorted as (other-device-name asc).
const involved = state.requirements.filter((r) => r.from_device_id === d.id || r.to_device_id === d.id);
const deviceById = new Map(state.devices.map((x) => [x.id, x]));
involved.sort((a, b) => {
const oa = (a.from_device_id === d.id ? a.to_device_id : a.from_device_id);
const ob = (b.from_device_id === d.id ? b.to_device_id : b.from_device_id);
return (deviceById.get(oa)?.name || "").localeCompare(deviceById.get(ob)?.name || "");
});
const reqsHtml = involved.length
? involved.map((r) => {
const other = (r.from_device_id === d.id ? r.to_device_id : r.from_device_id);
const otherName = deviceById.get(other)?.name ?? `device #${other}`;
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : null;
return `
No requirements yet.
`;
body.innerHTML = `
Wall-outlet terminator. Power-by-convention; a future cable terminating
here means "plugged into a socket outside the diagram".
No cable types defined. Add one from the legend first.
Cable types are shared across all projects. Renaming or recolouring
affects every project.
`;
body.innerHTML = `
for built-ins grouped by
// their `kind`, then project-custom, then a "Custom (no type)" option.
sel.innerHTML = "";
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const byKind = new Map();
for (const t of builtIns) {
const k = t.kind || "generic";
const arr = byKind.get(k) || [];
arr.push(t);
byKind.set(k, arr);
}
for (const [kind, arr] of byKind) {
const og = document.createElement("optgroup");
og.label = kind;
for (const t of arr) {
const opt = new Option(t.name, String(t.id));
og.append(opt);
}
sel.append(og);
}
if (customs.length) {
const og = document.createElement("optgroup");
og.label = "custom";
for (const t of customs) {
og.append(new Option(t.name, String(t.id)));
}
sel.append(og);
}
const customOpt = new Option("Custom (no type)", "");
sel.append(customOpt);
// Default to the first built-in (NAS in m's catalog) so m sees a
// sensible first option. Auto-fill the name to match.
sel.value = builtIns[0] ? String(builtIns[0].id) : "";
syncNameToType();
sel.onchange = syncNameToType;
function syncNameToType() {
const idStr = sel.value;
if (!idStr) { nameInput.value = ""; return; }
const t = state.deviceTypes.find((x) => String(x.id) === idStr);
if (!t) return;
nameInput.value = nextNameFor(t.name);
}
dlg.showModal();
nameInput.focus();
nameInput.select();
form.onsubmit = async (e) => {
e.preventDefault();
const name = nameInput.value.trim();
if (!name) { showError(err, "Name is required"); return; }
const idStr = sel.value;
const body = {
name,
x: geom.x, y: geom.y, width: geom.width, height: geom.height,
};
if (geom.frame_id != null) body.frame_id = geom.frame_id;
if (idStr) body.type_id = Number(idStr);
try {
const d = await createDevice(state.active.id, body);
state.devices.push(d);
// Re-fetch ports for the project — the server seeded them in the
// same transaction, so they're already in the DB.
const snap = await getSnapshot(state.active.id);
state.ports = snap.ports || [];
state.selection = { kind: "device", id: d.id };
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Create failed");
}
};
}
// Which edge does a given port currently sit on? Snaps the port's
// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge(port, device) {
const dL = port.x_offset;
const dR = device.width - port.x_offset;
const dT = port.y_offset;
const dB = device.height - port.y_offset;
const min = Math.min(dL, dR, dT, dB);
if (min === dL) return "left";
if (min === dR) return "right";
if (min === dT) return "top";
return "bottom";
}
// Even-spacing layout invariant for ports on a device edge: m wants
// every port lined up on its edge with no overlap. After any change
// to the set of ports on an edge (add / move / delete), recompute the
// offsets so that for N ports they sit at relative positions
// i/(N+1) along the edge for i=1..N.
//
// Sort key preserves m's intent: top/bottom by current x_offset
// (left→right), left/right by current y_offset (top→bottom). For a
// freshly-placed port, that's the click position projected onto the
// edge, so the port keeps its "I dropped it roughly here" rank.
//
// PATCHes only the ports whose offsets actually change, and updates
// state.ports in place. Returns once every PATCH resolves.
async function relayoutEdge(deviceID, edge) {
if (!state.active) return;
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const isHorizontal = edge === "top" || edge === "bottom";
const axis = isHorizontal ? dev.width : dev.height;
const peers = state.ports
.filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge)
.slice()
.sort((a, b) =>
isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset);
const n = peers.length;
if (n === 0) return;
const patches = [];
for (let i = 0; i < n; i++) {
const parallel = axis * (i + 1) / (n + 1);
let xOff, yOff;
switch (edge) {
case "top": xOff = parallel; yOff = 0; break;
case "bottom": xOff = parallel; yOff = dev.height; break;
case "left": xOff = 0; yOff = parallel; break;
case "right": xOff = dev.width; yOff = parallel; break;
}
const p = peers[i];
if (p.x_offset === xOff && p.y_offset === yOff) continue;
p.x_offset = xOff;
p.y_offset = yOff;
patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff })
.then((updated) => Object.assign(p, updated)));
}
if (patches.length) {
try {
await Promise.all(patches);
} catch (err) {
alert(`Re-layout failed: ${err.message}`);
}
}
}
// Re-space ports on every edge of `deviceID`. Used after the device's
// width / height change so all four edges recompute the i/(N+1)
// positions against the new dimensions.
async function relayoutAllEdges(deviceID) {
await Promise.all([
relayoutEdge(deviceID, "top"),
relayoutEdge(deviceID, "right"),
relayoutEdge(deviceID, "bottom"),
relayoutEdge(deviceID, "left"),
]);
}
// Bottom-right resize handle gesture. Updates width / height in local
// state on each move (renderCanvas redraws the rect + ports), clamps to
// a minimum so the device can't collapse, then PATCHes the new size on
// pointerup and re-spaces every edge's ports.
function startResize(e, deviceID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const d = state.devices.find((x) => x.id === deviceID);
if (!d) return;
const startWidth = d.width, startHeight = d.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_W = 60, MIN_H = 30;
const onMove = (ev) => {
const p = svgPoint(ev);
d.width = Math.max(MIN_W, startWidth + (p.x - startWorld.x));
d.height = Math.max(MIN_H, startHeight + (p.y - startWorld.y));
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (d.width === startWidth && d.height === startHeight) return;
try {
const updated = await patchDevice(state.active.id, d.id, {
width: d.width, height: d.height,
});
Object.assign(d, updated);
// Ports may have been on an edge that just moved (right or bottom)
// — re-distribute everything to the new dims.
await relayoutAllEdges(d.id);
renderCanvas();
} catch (err) {
alert(`Resize failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Find the topmost canvas element under (clientX, clientY) that maps to
// a cable endpoint target. Returns { kind, id } for port / device / IO,
// or null when m dropped on empty canvas.
function hitTestEndpointTarget(clientX, clientY) {
// elementsFromPoint walks the z-order so we can skip the dragged
// cable handle itself (it sits at the top while pointer-captured).
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (!(el instanceof Element)) continue;
if (el.classList?.contains("cable-handle")) continue; // skip self
const portID = el.getAttribute && el.getAttribute("data-port-id");
if (portID) return { kind: "port", id: Number(portID) };
const devEl = el.closest && el.closest("[data-device-id]");
if (devEl) return { kind: "device", id: Number(devEl.getAttribute("data-device-id")) };
const ioEl = el.closest && el.closest("[data-io-id]");
if (ioEl) return { kind: "io", id: Number(ioEl.getAttribute("data-io-id")) };
}
return null;
}
// Endpoint-drag gesture: pointerdown on a .cable-handle starts a replug.
// While held, renderCanvas anchors the affected end at the cursor.
// On pointerup, hit-test the cursor to find the drop target:
// - port → PATCH {from|to: {port_id}}
// - device → PATCH {from|to: {device_id}}
// - IO → PATCH {from|to: {io_id}}
// - empty → cancel (revert)
// When the cable was auto, a successful drop also sends promote=true so
// the server flips it to manual (m took control). Cancel leaves auto alone.
function startCableReplug(e, cableID, end) {
if (!state.active) return;
e.stopPropagation();
e.preventDefault();
const c = state.cables.find((x) => x.id === cableID);
if (!c) return;
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
$(".canvas-wrap").classList.add("replugging");
const startWorld = svgPoint(e);
cableReplug = { cableID, end, x: startWorld.x, y: startWorld.y };
renderCanvas();
const onMove = (ev) => {
const p = svgPoint(ev);
cableReplug = { cableID, end, x: p.x, y: p.y };
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
$(".canvas-wrap").classList.remove("replugging");
const drop = hitTestEndpointTarget(ev.clientX, ev.clientY);
// Clear the preview first so renderCanvas falls back to resolved anchors.
cableReplug = null;
if (!drop) {
renderCanvas();
return; // cancel
}
// Build the patch for the affected endpoint.
const ep =
drop.kind === "port" ? { port_id: drop.id } :
drop.kind === "device" ? { device_id: drop.id } :
drop.kind === "io" ? { io_id: drop.id } : null;
if (!ep) { renderCanvas(); return; }
const body = {};
if (end === "from") body.from = ep; else body.to = ep;
if (c.auto) body.promote = true;
// If m dropped on the same endpoint we already had, treat as cancel.
const sameAsBefore =
(drop.kind === "port" && ((end === "from" ? c.from_port_id : c.to_port_id) === drop.id)) ||
(drop.kind === "device" && ((end === "from" ? c.from_device_id : c.to_device_id) === drop.id)) ||
(drop.kind === "io" && ((end === "from" ? c.from_io_id : c.to_io_id) === drop.id));
if (sameAsBefore) { renderCanvas(); return; }
try {
const updated = await patchCable(state.active.id, c.id, body);
Object.assign(c, updated);
render();
} catch (err) {
alert(`Replug failed: ${err.message}`);
renderCanvas();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
/** Port-click flow:
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
* - Otherwise, no tool armed:
* select the port (inspector shows edge picker + label + delete).
* - Otherwise, any non-cable tool armed:
* bubble so the canvas-level tool handler runs (lets +Port place
* a new port even when the click lands on an existing one). */
function onPortPointerDown(e, port) {
if (!state.active) return;
// Cable-draw flow takes precedence whenever a source is already picked.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID === port.id) {
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
return;
}
// No cable in progress, no tool: select the port → inspector pane.
if (!state.tool) {
e.stopPropagation();
e.preventDefault();
state.selection = { kind: "port", id: port.id };
render();
return;
}
// The cable tool: start a draw from this port.
if (state.tool === "cable") {
e.stopPropagation();
e.preventDefault();
state.cableDrawFromPortID = port.id;
render();
return;
}
// Any other tool (port / frame / device / io / req): let the click
// bubble up so the canvas-level branch fires.
}
async function finishCableDrawAt(targetPort, shiftKey) {
if (!state.active) return;
const fromPortID = state.cableDrawFromPortID;
state.cableDrawFromPortID = null;
armTool(null);
if (fromPortID == null) return;
const sourcePort = state.ports.find((p) => p.id === fromPortID);
if (!sourcePort) { render(); return; }
// Body: shift-click on a port = bind to that port's parent device
// (whole-device cable) instead of the port. Plain click = port-to-port.
const body = {
type_id: sourcePort.type_id,
auto: false,
from: { port_id: fromPortID },
to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id },
};
if (!shiftKey && targetPort.type_id !== sourcePort.type_id) {
if (!confirm(`Target port is a different cable type. Connect anyway?`)) {
render();
return;
}
}
try {
const c = await createCableAPI(state.active.id, body);
state.cables.push(c);
state.selection = { kind: "cable", id: c.id };
render();
} catch (e) {
alert(`Create cable failed: ${e.message}`);
render();
}
}
/** Click on an IO marker while a cable draw is in progress → terminate
* the cable on that IO. Plugged into the IO marker's pointerdown
* handler in renderCanvas. */
async function finishCableDrawAtIO(ioMarker) {
if (!state.active) return;
const fromPortID = state.cableDrawFromPortID;
state.cableDrawFromPortID = null;
armTool(null);
if (fromPortID == null) return;
const sourcePort = state.ports.find((p) => p.id === fromPortID);
if (!sourcePort) { render(); return; }
const body = {
type_id: sourcePort.type_id,
auto: false,
from: { port_id: fromPortID },
to: { io_id: ioMarker.id },
};
try {
const c = await createCableAPI(state.active.id, body);
state.cables.push(c);
state.selection = { kind: "cable", id: c.id };
render();
} catch (e) {
alert(`Create cable failed: ${e.message}`);
render();
}
}
// Create a port from the sidebar "Add port" form and switch the
// inspector to its editor. Used by renderInspectorPortNew on submit.
async function createPortFromForm(deviceID, typeID, edge, label) {
if (!state.active) return;
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) return;
const tmp = edgeCentre(dev, edge);
try {
const port = await createPort(state.active.id, deviceID, {
type_id: typeID,
label: label || undefined,
x_offset: tmp.xOff,
y_offset: tmp.yOff,
});
state.ports.push(port);
// Re-space every port on this edge so the new one slots into the
// even-spacing grid.
await relayoutEdge(deviceID, edge);
state.selection = { kind: "port", id: port.id };
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
}
}
async function placeIOMarkerAt(p) {
if (!state.active) return;
armTool(null);
const x = p.x - IO_SIZE / 2;
const y = p.y - IO_SIZE / 2;
// Label is optional; a blank prompt commits with the default "IO"
// (server-side fallback in CreateIOMarker). Esc cancels.
const label = await promptInline("Outlet label (Enter for 'IO')", p.x, p.y - IO_SIZE);
if (label === null || !state.active) return;
const frame = frameAt(p.x, p.y);
try {
const m = await createIOMarker(state.active.id, {
label: label || undefined,
x, y,
frame_id: frame ? frame.id : undefined,
});
state.ioMarkers.push(m);
state.selection = { kind: "io", id: m.id };
render();
} catch (err) {
alert(`Create IO marker failed: ${err.message}`);
}
}
// ---------- inline namer (foreignObject overlay) ---------- //
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
function cancelInlineNamer() {
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
}
function promptInline(placeholder, cx, cy) {
cancelInlineNamer();
return new Promise((resolve) => {
const fo = document.createElementNS(SVG_NS, "foreignObject");
fo.setAttribute("x", String(cx - 110));
fo.setAttribute("y", String(cy - 14));
fo.setAttribute("width", "220");
fo.setAttribute("height", "28");
fo.innerHTML = `
`;
$("#canvas").append(fo);
activeNamer = fo;
const input = fo.querySelector("input");
input.focus();
const done = (val) => {
// Clear the flag *before* removing the node. Enter-key triggers a
// synchronous blur on the input, which re-enters done() — and if
// fo.remove() ran first, the second call hits a
// "node no longer a child" pageerror. Reordering makes the second
// re-entry a no-op (activeNamer is already null).
if (activeNamer !== fo) return;
activeNamer = null;
fo.remove();
resolve(val);
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") done(input.value.trim());
else if (e.key === "Escape") done(null);
});
input.addEventListener("blur", () => done(input.value.trim() || null));
});
}
// ---------- drag ---------- //
function startDrag(e, kind, id) {
if (!state.active) return;
// Req tool intercepts device-down to start the drag-A-to-B gesture.
if (state.tool === "req" && kind === "device") {
e.stopPropagation();
e.preventDefault();
startRequirementDrag(e, id);
return;
}
if (state.tool) return; // any other tool — let the canvas-level handler run
e.stopPropagation();
state.selection = { kind, id };
// Render immediately so the inspector reflects the new selection from
// pointerdown — independent of whether the drag-completion render at
// the end of onUp runs. (Previously, the inspector only updated if
// pointerup completed cleanly; any throw in onUp left it stale.)
render();
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|IOMarker|undefined} */
let obj;
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + IO markers + their
// offsets so they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
trackedDevices.push({ d, sx: d.x, sy: d.y });
}
}
for (const m of state.ioMarkers) {
if (m.frame_id === obj.id) {
trackedIOs.push({ m, sx: m.x, sy: m.y });
}
}
}
// Capture the rect element NOW: by the time onUp fires async, the
// browser has nulled out e.currentTarget on the pointerdown event,
// so `e.currentTarget.classList.remove("dragging")` would throw
// "Cannot read properties of null". Sherlock surfaced this from the
// click-only path that pageerror-spammed every device click.
const dragTarget = /** @type {Element} */ (e.currentTarget);
dragTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
const onMove = (ev) => {
const p = svgPoint(ev);
const dx = p.x - start.x;
const dy = p.y - start.y;
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
obj.x = startX + dx;
obj.y = startY + dy;
if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
}
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
dragTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;
try {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices + IO markers too.
await Promise.all([
...trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
...trackedIOs.map((t) =>
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
]);
} else if (kind === "device") {
const d = /** @type {Device} */ (obj);
// Recompute frame_id from drop point (centre of device).
const cx = d.x + d.width / 2;
const cy = d.y + d.height / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: d.x, y: d.y };
if ((d.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID; // explicit null = clear
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
} else /* io */ {
const m = /** @type {IOMarker} */ (obj);
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: m.x, y: m.y };
if ((m.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
m.frame_id = newFrameID;
}
await patchIOMarker(state.active.id, m.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);
}
render();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- modals (project / cable type) ---------- //
function bindCloseButtons(dialog) {
dialog.querySelectorAll("[data-close]").forEach((btn) =>
btn.addEventListener("click", () => dialog.close()),
);
}
function showError(el, msg) {
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
el.textContent = msg;
setHidden(el, false);
}
function openNewProjectModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
const err = $("#np-error");
form.reset();
showError(err, "");
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
drawing_name: String(fd.get("drawing_name") || "").trim(),
description: String(fd.get("description") || ""),
};
if (!body.drawing_name) delete body.drawing_name;
try {
const p = await createProject(body);
state.projects = await listProjects();
dlg.close();
await activateProject(p.id);
} catch (e) {
showError(err, e.message || "Failed to create project");
}
};
}
function openCableTypeModal(existing) {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
const err = $("#ct-error");
const title = $("#ct-title");
form.reset();
showError(err, "");
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
if (existing) {
form.elements.namedItem("name").value = existing.name;
form.elements.namedItem("color").value = existing.color;
} else {
form.elements.namedItem("color").value = "#1971c2";
}
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn btn-danger btn-delete-type";
del.style.marginRight = "auto";
del.textContent = "Delete";
del.addEventListener("click", async () => {
try {
await deleteCableType(existing.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === existing.id) state.activeTypeId = null;
dlg.close();
render();
} catch (e) {
const n = e.details?.in_use_by_cables;
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
}
});
actions.prepend(del);
}
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
color: String(fd.get("color") || "").trim(),
};
try {
if (existing) await patchCableType(existing.id, body);
else await createCableType(body);
state.cableTypes = await listCableTypes();
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Save failed");
}
};
}
function openDeleteProjectModal() {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
const err = $("#dp-error");
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
form.reset();
showError(err, "");
input.placeholder = state.active.name;
dlg.showModal();
input.focus();
form.onsubmit = async (e) => {
e.preventDefault();
const confirm = String(new FormData(form).get("confirm") || "");
try {
await deleteProject(state.active.id, confirm);
state.projects = await listProjects();
dlg.close();
await activateProject(null);
} catch (e) {
showError(err, e.message || "Delete failed");
}
};
}
// ---------- 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 = `Computing…
`;
dlg.showModal();
solveProject(state.active.id, true)
.then((preview) => renderSolvePreview(body, preview))
.catch((e) => { body.innerHTML = `${escapeHtml(e.message)}
`; });
$("#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 `+ ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")} `;
}).join("");
const remsHtml = (preview.cables_removed || []).map((id) => `cable #${id} `).join("");
const bunsHtml = (preview.bundles_added || []).map((b) => `bundle: ${escapeHtml(b.name)} `).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 = `+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve `;
} else if ((u.reason || "").startsWith("ambiguous") && r) {
action = `Specify cable type… `;
} 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 = `+ Add port to ${escapeHtml(sideName)} and re-solve `;
}
return `⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action} `;
}).join("");
body.innerHTML = `
${addsHtml ? `Cables to add ` : ""}
${remsHtml ? `Cables to remove ` : ""}
${bunsHtml ? `Bundles to add ` : ""}
${unmetsHtml ? `Unsatisfied ` : ""}
${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `No changes — already solved.
`}
`;
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 {
// The server auto-solves by default since v0c7d165 — the response
// is {template_apply, solve} (or {template_apply, solve_error}).
// We don't need to read the body here; activateProject() below
// pulls a fresh snapshot that includes both the seeded devices
// and any cables the solver placed.
const projID = state.active.id;
await applyTemplate(projID, {
template_id: tid,
name_overrides: overrides,
skip_devices: skip,
});
dlg.close();
// Route through the canonical project-load path. That re-hydrates
// ALL collections (frames, devices, ports, io_markers, cables,
// bundles, requirements, cable_types, device_types) AND clears
// the selection — important because m may have had a stale
// selection from before the apply. Slice 6's bare re-snapshot
// missed the device_types refresh + selection reset.
await activateProject(projID);
} 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 `
${escapeHtml(dtName)}
`;
}).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 `${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")} `;
}).join("");
preview.innerHTML = `
${escapeHtml(t.description)}
Devices
Requirements
`;
}
// ---------- export flow ---------- //
let toastTimer = null;
function showToast(kind, html, holdMs = 5000) {
const t = $("#toast");
t.className = "toast " + (kind || "");
t.innerHTML = html;
setHidden(t, false);
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs);
}
async function exportCurrentProject() {
if (!state.active) { alert("Pick a project first"); return; }
const btn = $("#btn-export");
btn.disabled = true;
showToast("", "Exporting…", 30000);
try {
const res = await syncExport(state.active.id);
const url = res.url ?? "(no url)";
const count = res.element_count ?? 0;
showToast("ok",
`Exported ${count} elements → ${escapeHtml(url)} `,
8000);
} catch (e) {
// Surface mxdrw unreachability or the upstream error verbatim.
const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? "");
showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000);
} finally {
btn.disabled = false;
}
}
// ---------- admin modal ---------- //
const adminState = {
activeTab: /** @type {"projects"|"cable-types"|"device-types"|"setup-templates"} */ ("projects"),
};
async function openAdminModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-admin"));
// Always re-fetch the lists when opening so the modal reflects the
// latest server state (m may have edited things from inspector panes
// while the modal was closed).
try {
state.projects = await listProjects();
state.cableTypes = await listCableTypes();
state.setupTemplates = await listSetupTemplates();
} catch (e) {
alert(`Failed to load admin data: ${e.message}`);
return;
}
for (const btn of dlg.querySelectorAll(".admin-tab")) {
btn.addEventListener("click", () => switchAdminTab(btn.getAttribute("data-admin-tab")));
}
switchAdminTab(adminState.activeTab);
dlg.showModal();
}
function switchAdminTab(name) {
adminState.activeTab = name;
for (const btn of $("#modal-admin").querySelectorAll(".admin-tab")) {
const on = btn.getAttribute("data-admin-tab") === name;
btn.setAttribute("aria-selected", on ? "true" : "false");
}
const body = $("#admin-body");
switch (name) {
case "projects": return renderAdminProjects(body);
case "cable-types": return renderAdminCableTypes(body);
case "device-types": return renderAdminDeviceTypes(body);
case "setup-templates": return renderAdminSetupTemplates(body);
case "requirements": return renderAdminRequirements(body);
}
}
// ---------- admin: projects ---------- //
function renderAdminProjects(body) {
const rows = state.projects.map((p) => `
`).join("") || `No projects.
`;
body.innerHTML = `
Rename, retitle the drawing, or change the description. Delete cascades all frames /
devices / cables / etc. in the project (cable types are global and unaffected).
${rows}
`;
for (const row of body.querySelectorAll(".admin-row[data-project-id]")) {
const pid = Number(row.getAttribute("data-project-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const drawing = row.querySelector(".adm-drawing").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchProject(pid, {
name, drawing_name: drawing, description: desc,
});
const idx = state.projects.findIndex((p) => p.id === pid);
if (idx >= 0) state.projects[idx] = updated;
if (state.active?.id === pid) state.active = updated;
renderProjectPicker();
switchAdminTab("projects");
} catch (e) {
alert(`Save failed: ${e.message}`);
}
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
const p = state.projects.find((x) => x.id === pid);
if (!p) return;
const typed = prompt(`Type "${p.name}" to confirm delete:`);
if (typed !== p.name) return;
try {
await deleteProject(pid, p.name);
state.projects = state.projects.filter((x) => x.id !== pid);
if (state.active?.id === pid) await activateProject(null);
switchAdminTab("projects");
} catch (e) {
alert(`Delete failed: ${e.message}`);
}
});
}
}
// ---------- admin: cable types ---------- //
function renderAdminCableTypes(body) {
const rows = state.cableTypes.map((t) => `
`).join("") || `No cable types.
`;
body.innerHTML = `
Cable types are global — renaming or recolouring affects every project.
${rows}
`;
for (const row of body.querySelectorAll(".admin-row[data-cable-type-id]")) {
const id = Number(row.getAttribute("data-cable-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const color = row.querySelector(".adm-color").value;
try {
const updated = await patchCableType(id, { name, color });
const idx = state.cableTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.cableTypes[idx] = updated;
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this cable type? Requires no ports / cables to reference it.")) return;
try {
await deleteCableType(id);
state.cableTypes = state.cableTypes.filter((t) => t.id !== id);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
body.querySelector("#adm-ct-new-create").addEventListener("click", async () => {
const name = body.querySelector("#adm-ct-new-name").value.trim();
const color = body.querySelector("#adm-ct-new-color").value;
if (!name) { alert("Name required"); return; }
try {
const created = await createCableType({ name, color });
state.cableTypes.push(created);
renderLegend(); renderCanvas();
switchAdminTab("cable-types");
} catch (e) { alert(`Create failed: ${e.message}`); }
});
}
// ---------- admin: device types ---------- //
function renderAdminDeviceTypes(body) {
if (!state.active) {
body.innerHTML = `
Pick a project to manage its custom device types. Built-ins are
listed once a project is active (they're project-agnostic but the
catalog read takes a project context).
`;
return;
}
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
const portsLine = (ports) => ports.map((p) =>
` ` +
`${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} (${escapeHtml(p.edge)}) `,
).join("");
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const builtRows = builtIns.map((t) => `
${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}
· ${escapeHtml(t.kind || "")}
built-in
${escapeHtml(t.description || "")}
${portsLine(t.ports || [])}
`).join("");
const customRows = customs.map((t) => `
`).join("") || `No project-custom types yet.
`;
body.innerHTML = `
Built-in types are seeded by migrations and read-only.
Project-custom types live under the active project ('${escapeHtml(state.active.name)}') and can be edited or deleted.
Port profiles can't be re-shaped here yet — m can still override per device-instance from the device inspector.
Built-in (${builtIns.length})
${builtRows}
Project-custom (${customs.length})
${customRows}
`;
for (const row of body.querySelectorAll(".admin-row:not(.locked)[data-device-type-id]")) {
const id = Number(row.getAttribute("data-device-type-id"));
row.querySelector(".adm-save").addEventListener("click", async () => {
const name = row.querySelector(".adm-name").value.trim();
const kind = row.querySelector(".adm-kind").value.trim();
const icon = row.querySelector(".adm-icon").value.trim();
const desc = row.querySelector(".adm-desc").value;
try {
const updated = await patchDeviceType(state.active.id, id, {
name, kind, icon, description: desc,
});
const idx = state.deviceTypes.findIndex((t) => t.id === id);
if (idx >= 0) state.deviceTypes[idx] = updated;
switchAdminTab("device-types");
} catch (e) { alert(`Save failed: ${e.message}`); }
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this custom device type?")) return;
try {
await deleteDeviceType(state.active.id, id);
state.deviceTypes = state.deviceTypes.filter((t) => t.id !== id);
switchAdminTab("device-types");
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
}
// ---------- admin: setup templates ---------- //
function renderAdminSetupTemplates(body) {
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
const rows = state.setupTemplates.map((t) => {
const dt = (d) => d.device_type?.name ?? `type #${d.device_type_id}`;
const devsById = new Map(t.devices.map((d) => [d.id, d]));
const devsHtml = t.devices.map((d) =>
`${escapeHtml(d.suggested_name ?? dt(d))} (${escapeHtml(dt(d))}) `,
).join("") || `no devices `;
const reqsHtml = t.requirements.map((r) => {
const a = devsById.get(r.from_template_device_id);
const b = devsById.get(r.to_template_device_id);
const an = a ? (a.suggested_name ?? dt(a)) : "?";
const bn = b ? (b.suggested_name ?? dt(b)) : "?";
const ct = r.preferred_cable_type_id != null
? cableTypeName.get(r.preferred_cable_type_id) : null;
const tag = r.must_connect ? "must" : "nice";
return `${escapeHtml(an)} ↔ ${escapeHtml(bn)} · ${escapeHtml(ct ?? "solver picks")} · ${tag} `;
}).join("") || `no requirements `;
return `
${escapeHtml(t.name)}
${t.built_in ? "built-in" : "custom"}
${escapeHtml(t.description || "")}
Devices (${t.devices.length})
Requirements (${t.requirements.length})
`;
}).join("") || `No setup templates.
`;
body.innerHTML = `
Setup templates are stamps for a project — apply one from the header
("Apply template…") to seed a frame + devices + requirements at once.
Built-in templates are read-only.
${rows}
`;
}
// ---------- admin: requirements (all) ---------- //
function renderAdminRequirements(body) {
if (!state.active) {
body.innerHTML = `Pick a project to see its requirements.
`;
return;
}
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const cableTypeBy = new Map(state.cableTypes.map((t) => [t.id, t]));
const rows = state.requirements.map((r) => {
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
const ct = r.preferred_cable_type_id != null ? cableTypeBy.get(r.preferred_cable_type_id) : null;
return `
${escapeHtml(a?.name ?? "?")} ↔ ${escapeHtml(b?.name ?? "?")}
· ${escapeHtml(ct?.name ?? "solver picks")}
${r.must_connect ? "must" : "nice"}
#${r.id}
${r.notes ? `
${escapeHtml(r.notes)}
` : ""}
Edit
Delete
`;
}).join("") || `No requirements yet.
`;
body.innerHTML = `
Requirements are the solver's input — "device A must connect to device B".
Add new ones from the per-device inspector (more contextual); manage them here.
${rows}
+ Add requirement
${state.devices.length < 2
? '(needs ≥ 2 devices) '
: ""}
`;
for (const row of body.querySelectorAll(".admin-row[data-req-id]")) {
const rid = Number(row.getAttribute("data-req-id"));
row.querySelector(".adm-edit").addEventListener("click", () => {
const r = state.requirements.find((x) => x.id === rid);
if (!r) return;
const dlg = $("#modal-admin");
dlg.close();
openRequirementModal(r);
});
row.querySelector(".adm-delete").addEventListener("click", async () => {
if (!confirm("Delete this requirement?")) return;
try {
await deleteRequirement(state.active.id, rid);
state.requirements = state.requirements.filter((r) => r.id !== rid);
switchAdminTab("requirements");
render();
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
const newBtn = body.querySelector("#adm-req-new");
if (newBtn) {
newBtn.addEventListener("click", () => {
$("#modal-admin").close();
openRequirementModal(null);
});
}
}
// ---------- boot ---------- //
async function boot() {
bindCloseButtons($("#modal-new-project"));
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
bindCloseButtons($("#modal-new-device"));
bindCloseButtons($("#modal-requirement"));
bindCloseButtons($("#modal-solve"));
bindCloseButtons($("#modal-template"));
bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
$("#btn-export").addEventListener("click", exportCurrentProject);
$("#btn-fit").addEventListener("click", fitToContent);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
activateProject(v ? Number(v) : null);
});
bindTools();
viewFromURL();
applyViewBox();
updateZoomUI();
try {
[state.projects, state.cableTypes] = await Promise.all([
listProjects(),
listCableTypes(),
]);
} catch (e) {
alert(`Failed to load: ${e.message}`);
return;
}
const wanted = activeProjectIdFromURL();
if (wanted && state.projects.some((p) => p.id === wanted)) {
await activateProject(wanted);
} else {
render();
}
}
boot();