Files
CableGUI/web/static/main.js
mAi 1c234f3f46 feat(ui): bottom-right resize handle on frames
m: 'We should also be able to resize frames, the same way we do with
devices.' Mirrors the device-resize pattern (89686d0).

- 10×10 SVG handle drawn at each frame's bottom-right corner with class
  .frame-resize-handle + cursor: nwse-resize. Appended after the label
  so it sits on top of the rect and wins the pointerdown.
- startFrameResize captures the pointer, stops propagation so the
  rect's pointerdown (= startDrag 'frame') doesn't also fire, and
  updates f.width / f.height on every pointermove using svgPoint
  deltas — works at any zoom level via the same world-coord conversion
  the rest of the canvas uses.
- Clamps to 200×150 minimum during the drag (frames need more room
  than devices since they host devices + IO markers + clamps).
- On pointerup: PATCH /api/projects/:pid/frames/:id with the new width
  + height. Contained children stay at their absolute positions — the
  frame body drag is what moves them; resize only changes the frame's
  own bounds, so devices/IO markers/clamps inside don't shift.
2026-05-17 17:19:53 +02:00

3861 lines
150 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// CableGUI frontend entry — vanilla ES module, no build step.
//
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one
// snapshot from the server, then individual PATCHes on each mutation.
/**
* @typedef {{ id: number, name: string, drawing_name: string,
* description: string, created_at: string, updated_at: string }} Project
* @typedef {{ id: number, name: string, color: string,
* created_at: string, updated_at: string }} CableType
* @typedef {{ id: number, project_id: number, name: string,
* x: number, y: number, width: number, height: number }} Frame
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* type_id: number|null, name: string, color: string,
* x: number, y: number, width: number, height: number }} Device
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* label: string, x: number, y: number }} IOMarker
* @typedef {{ id: number, project_id: number, device_id: number,
* type_id: number, label: string|null,
* x_offset: number, y_offset: number }} Port
* @typedef {{ id: number, device_type_id: number, cable_type_id: number,
* label_prefix: string, count: number, edge: string,
* sort_order: number }} DeviceTypePort
* @typedef {{ id: number, project_id: number|null, name: string,
* kind: string, icon: string|null, description: string,
* built_in: boolean, ports: DeviceTypePort[] }} DeviceType
* @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";
const SVG_NS = "http://www.w3.org/2000/svg";
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
const CLAMP_SIZE = 12; // small rounded square for routing clamps (v5 §11)
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {DeviceType[]} */ deviceTypes: [],
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
/** @type {Port[]} */ ports: [],
/** @type {IOMarker[]} */ ioMarkers: [],
/** @type {ConnectionRequirement[]} */ requirements: [],
/** @type {Cable[]} */ cables: [],
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
/** v5 — routing anchors. */
/** @type {Clamp[]} */ clamps: [],
/** @type {CableClamp[]} */ cableClamps: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "cable" | "clamp" | null */
tool: /** @type {string|null} */ (null),
/** Canvas viewport — drives the SVG viewBox. */
view: { x: 0, y: 0, zoom: 1 },
/** Space-key held → next pointerdown anywhere on canvas starts a pan. */
spaceHeld: false,
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port"|"clamp", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(json?.error || res.statusText);
err.status = res.status;
err.details = json?.details;
throw err;
}
return json;
}
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
const listCableTypes = () => api("GET", "/cable-types");
const createCableType = (body) => api("POST", "/cable-types", body);
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers`, body);
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body);
const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body);
const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
const createDeviceType = (pid, body) => api("POST", `/projects/${pid}/device-types`, body);
const patchDeviceType = (pid, id, body) => api("PATCH", `/projects/${pid}/device-types/${id}`, body);
const deleteDeviceType = (pid, id) => api("DELETE", `/projects/${pid}/device-types/${id}`);
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
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);
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
// v5 — clamps + cable_clamps.
const listClamps = (pid) => api("GET", `/projects/${pid}/clamps`);
const createClamp = (pid, body) => api("POST", `/projects/${pid}/clamps`, body);
const patchClamp = (pid, id, body) => api("PATCH", `/projects/${pid}/clamps/${id}`, body);
const deleteClamp = (pid, id) => api("DELETE", `/projects/${pid}/clamps/${id}`);
const attachClampToCable = (pid, cid, body) => api("POST", `/projects/${pid}/cables/${cid}/clamps`, body);
const detachClampFromCable = (pid, cid, cmid) => api("DELETE", `/projects/${pid}/cables/${cid}/clamps/${cmid}`);
const reorderCableClamps = (pid, cid, body) => api("PUT", `/projects/${pid}/cables/${cid}/clamps`, body);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
function setHidden(el, hidden) {
if (hidden) el.setAttribute("hidden", "");
else el.removeAttribute("hidden");
}
function svgEl(name, attrs = {}) {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
el.setAttribute(k, String(v));
}
return el;
}
// ---------- URL state ---------- //
function activeProjectIdFromURL() {
const raw = new URLSearchParams(location.search).get("project");
const id = raw && Number.parseInt(raw, 10);
return Number.isFinite(id) && id > 0 ? id : null;
}
function setActiveInURL(id) {
const url = new URL(location.href);
if (id == null) url.searchParams.delete("project");
else url.searchParams.set("project", String(id));
history.replaceState(null, "", url.toString());
}
// ---------- canvas view (zoom + pan) ---------- //
const BASE_W = 2000, BASE_H = 1500;
const ZOOM_MIN = 0.2, ZOOM_MAX = 5;
function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); }
function applyViewBox() {
const z = state.view.zoom;
const vw = BASE_W / z;
const vh = BASE_H / z;
$("#canvas").setAttribute("viewBox", `${state.view.x} ${state.view.y} ${vw} ${vh}`);
}
function updateZoomUI() {
const el = $("#zoom-pct");
if (el) el.textContent = `${Math.round(state.view.zoom * 100)}%`;
}
function viewFromURL() {
const p = new URLSearchParams(location.search);
const z = parseFloat(p.get("z"));
const px = parseFloat(p.get("px"));
const py = parseFloat(p.get("py"));
if (Number.isFinite(z) && z > 0) state.view.zoom = clampZoom(z);
if (Number.isFinite(px)) state.view.x = px;
if (Number.isFinite(py)) state.view.y = py;
}
function setViewInURL() {
const url = new URL(location.href);
const isDefault = state.view.zoom === 1 && state.view.x === 0 && state.view.y === 0;
if (isDefault) {
url.searchParams.delete("z");
url.searchParams.delete("px");
url.searchParams.delete("py");
} else {
url.searchParams.set("z", state.view.zoom.toFixed(3));
url.searchParams.set("px", state.view.x.toFixed(1));
url.searchParams.set("py", state.view.y.toFixed(1));
}
history.replaceState(null, "", url.toString());
}
function wheelZoom(e) {
e.preventDefault();
const before = svgPoint(e);
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = clampZoom(state.view.zoom * factor);
if (newZoom === state.view.zoom) return;
state.view.zoom = newZoom;
applyViewBox();
const after = svgPoint(e); // recomputed against the new viewBox
state.view.x += before.x - after.x;
state.view.y += before.y - after.y;
applyViewBox();
updateZoomUI();
setViewInURL();
}
function startPan(e) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const ctm = svg.getScreenCTM();
if (!ctm) return;
e.preventDefault();
e.stopPropagation();
$(".canvas-wrap").classList.add("panning");
// ctm.a / ctm.d are the world→screen scales. world delta = screen delta / scale.
const scaleX = ctm.a, scaleY = ctm.d;
const startClientX = e.clientX, startClientY = e.clientY;
const startViewX = state.view.x, startViewY = state.view.y;
try { svg.setPointerCapture(e.pointerId); } catch {}
const onMove = (ev) => {
state.view.x = startViewX - (ev.clientX - startClientX) / scaleX;
state.view.y = startViewY - (ev.clientY - startClientY) / scaleY;
applyViewBox();
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
$(".canvas-wrap").classList.remove("panning");
setViewInURL();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Left-click on empty canvas: ambiguous between "deselect" and "pan".
// We resolve by movement — under the drag threshold m gets the historic
// "click empties the selection" behaviour; past the threshold the gesture
// promotes to a pan (Excalidraw / Figma standard). 3px screen-space dead
// zone is enough that a steady click doesn't accidentally nudge the view.
const EMPTY_CANVAS_PAN_THRESHOLD_PX = 3;
function startEmptyCanvasGesture(e) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const ctm = svg.getScreenCTM();
if (!ctm) return;
const scaleX = ctm.a, scaleY = ctm.d;
const startClientX = e.clientX, startClientY = e.clientY;
const startViewX = state.view.x, startViewY = state.view.y;
let panning = false;
try { svg.setPointerCapture(e.pointerId); } catch {}
const onMove = (ev) => {
const dx = ev.clientX - startClientX;
const dy = ev.clientY - startClientY;
if (!panning) {
if (Math.hypot(dx, dy) <= EMPTY_CANVAS_PAN_THRESHOLD_PX) return;
panning = true;
$(".canvas-wrap").classList.add("panning");
}
state.view.x = startViewX - dx / scaleX;
state.view.y = startViewY - dy / scaleY;
applyViewBox();
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (panning) {
$(".canvas-wrap").classList.remove("panning");
setViewInURL();
} else if (state.selection) {
state.selection = null;
render();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
function resetView() {
state.view.zoom = 1;
state.view.x = 0;
state.view.y = 0;
applyViewBox();
updateZoomUI();
setViewInURL();
}
// Compute the bbox of every frame + device + IO marker in the current
// project and frame it into the view with a small padding. Falls back
// to reset when the project is empty.
function fitToContent() {
if (!state.active) return resetView();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let any = false;
const cover = (x, y, w, h) => {
any = true;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
};
for (const f of state.frames) cover(f.x, f.y, f.width, f.height);
for (const d of state.devices) cover(d.x, d.y, d.width, d.height);
for (const m of state.ioMarkers) cover(m.x, m.y, IO_SIZE, IO_SIZE);
if (!any) return resetView();
const pad = 40;
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
const bw = maxX - minX, bh = maxY - minY;
const zoom = clampZoom(Math.min(BASE_W / bw, BASE_H / bh));
const vw = BASE_W / zoom, vh = BASE_H / zoom;
// Centre the bbox inside the (potentially larger) viewBox.
state.view.zoom = zoom;
state.view.x = minX - (vw - bw) / 2;
state.view.y = minY - (vh - bh) / 2;
applyViewBox();
updateZoomUI();
setViewInURL();
}
// ---------- geometry ---------- //
/** Returns the smallest frame whose bbox contains (x, y), or null. */
function frameAt(x, y) {
/** @type {Frame|null} */ let best = null;
let bestArea = Infinity;
for (const f of state.frames) {
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
const a = f.width * f.height;
if (a < bestArea) { best = f; bestArea = a; }
}
return best;
}
/** Convert a pointer event to SVG-canvas coordinates. */
function svgPoint(evt) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
// ---------- render ---------- //
function renderProjectPicker() {
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
const current = state.active?.id ?? "";
sel.innerHTML = "";
sel.append(new Option("— pick a project —", ""));
for (const p of state.projects) {
const opt = new Option(p.name, String(p.id));
if (p.id === current) opt.selected = true;
sel.append(opt);
}
setHidden($("#btn-delete-project"), !state.active);
}
function renderLegend() {
const ul = $("#legend-list");
ul.innerHTML = "";
for (const t of state.cableTypes) {
const li = document.createElement("li");
li.className = "legend-row";
li.dataset.id = String(t.id);
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
li.innerHTML = `
<span class="legend-swatch" style="background:${t.color}"></span>
<span class="legend-name"></span>
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
`;
li.querySelector(".legend-name").textContent = t.name;
li.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
openCableTypeModal(t);
e.stopPropagation();
return;
}
// Click toggles activeTypeId AND moves the inspector to show the
// cable type's details. If m clicks the already-active type the
// active is cleared but the inspector still shows it (so m can
// edit name/colour without an active draw mode getting in the way).
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
state.selection = { kind: "cable_type", id: t.id };
render();
});
ul.append(li);
}
}
function renderEmptyHint() {
const hint = $("#empty-hint");
if (!state.active) {
hint.textContent = state.projects.length
? "Pick a project from the dropdown to start drawing."
: "Create your first project to get started.";
setHidden(hint, false);
return;
}
if (state.frames.length === 0 && state.devices.length === 0) {
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
setHidden(hint, false);
} else {
setHidden(hint, true);
}
}
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
const gCables = $("#canvas-cables");
const gBundles = $("#canvas-bundles");
const gClamps = $("#canvas-clamps");
const gIO = $("#canvas-io");
const gDefs = $("#canvas-defs");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gCables.innerHTML = "";
gBundles.innerHTML = "";
gClamps.innerHTML = "";
gIO.innerHTML = "";
gDefs.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
const rect = svgEl("rect", {
x: f.x, y: f.y, width: f.width, height: f.height,
class: "frame-rect svg-draggable",
rx: 6, ry: 6,
});
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: f.x + 8, y: f.y + 18,
class: "frame-label",
});
label.textContent = f.name;
g.append(rect, label);
// Bottom-right resize handle. Mirrors the device pattern — sits on
// top of the rect so its pointerdown wins, with stopPropagation in
// startFrameResize blocking the rect's startDrag underneath.
const FHSZ = 10;
const fHandle = svgEl("rect", {
x: f.x + f.width - FHSZ,
y: f.y + f.height - FHSZ,
width: FHSZ, height: FHSZ,
class: "frame-resize-handle",
"data-frame-id": f.id,
});
fHandle.addEventListener("pointerdown", (e) => startFrameResize(e, f.id));
g.append(fHandle);
gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
label.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
}
const portsByDevice = new Map();
for (const prt of state.ports) {
const arr = portsByDevice.get(prt.device_id) || [];
arr.push(prt);
portsByDevice.set(prt.device_id, arr);
}
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
for (const d of state.devices) {
const g = svgEl("g", { "data-device-id": d.id });
// Stroke = the user-picked colour; fill = a 12% tint of it via
// color-mix so the device "reads" coloured without becoming garish.
// Inline style beats the .device-rect class CSS, which is why CSS
// no longer hard-codes stroke/fill on that class.
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`,
rx: 3, ry: 3,
});
if (state.selection?.kind === "device" && state.selection.id === d.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: d.x + d.width / 2, y: d.y + d.height / 2,
class: "device-label",
});
label.textContent = d.name;
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
// Both fill and stroke = cable_type colour so the port is obviously coloured
// against the device rect.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const isCableFrom = state.cableDrawFromPortID === prt.id;
const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
const cls = "port-circle"
+ (isCableFrom ? " cable-from" : "")
+ (isSelected ? " selected" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: cls,
fill: color,
stroke: color,
"data-port-id": prt.id,
});
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
// Double-click activates cable-draw mode from this port without arming
// the cable tool first. armTool("cable") gives the crosshair cursor;
// the next port-click is then caught by onPortPointerDown's
// cable-draw-in-progress branch and commits the cable.
c.addEventListener("dblclick", (e) => {
e.stopPropagation();
e.preventDefault();
if (state.tool !== "cable") armTool("cable");
state.cableDrawFromPortID = prt.id;
state.selection = null;
render();
});
g.append(c);
}
// Bottom-right resize handle. Drawn last so it sits on top of the rect
// and any port circles that might overlap the corner. Visible always
// but subtle; cursor signals resize affordance.
const HSZ = 10;
const handle = svgEl("rect", {
x: d.x + d.width - HSZ,
y: d.y + d.height - HSZ,
width: HSZ, height: HSZ,
class: "device-resize-handle",
"data-device-id": d.id,
});
handle.addEventListener("pointerdown", (e) => startResize(e, d.id));
g.append(handle);
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
for (const m of state.ioMarkers) {
const g = svgEl("g", { "data-io-id": m.id });
// Diamond = a square rotated 45° around its centre. Using a <rect>
// with rotate(45 cx cy) is the easiest hit-shape that still respects
// x/y as the rotated bounding box.
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
const rect = svgEl("rect", {
x: m.x, y: m.y, width: IO_SIZE, height: IO_SIZE,
class: "io-marker svg-draggable",
transform: `rotate(45 ${cx} ${cy})`,
});
if (state.selection?.kind === "io" && state.selection.id === m.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: cx, y: cy + IO_SIZE * 0.85,
class: "io-marker-label",
});
label.textContent = m.label;
g.append(rect, label);
gIO.append(g);
rect.addEventListener("pointerdown", (e) => {
// Slice 7: if a cable draw is in progress, terminate the cable on
// this IO marker instead of starting a drag.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
finishCableDrawAtIO(m);
return;
}
startDrag(e, "io", m.id);
});
}
// Clamps — small grey rounded squares (per design v5 §11.9 q1).
// Slice 4 wires them into cable polylines; for slice 3 they just
// render + drag + select. Slice 5 adds a ×N count badge for clamps
// with ≥2 cables through them.
const cablesPerClamp = new Map();
for (const cc of state.cableClamps) {
cablesPerClamp.set(cc.clamp_id, (cablesPerClamp.get(cc.clamp_id) || 0) + 1);
}
for (const cl of state.clamps) {
const g = svgEl("g", { "data-clamp-id": cl.id });
const sz = CLAMP_SIZE;
const rect = svgEl("rect", {
x: cl.x - sz / 2, y: cl.y - sz / 2, width: sz, height: sz,
rx: 2, ry: 2,
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
});
g.append(rect);
const n = cablesPerClamp.get(cl.id) || 0;
if (n >= 2) {
const badge = svgEl("text", {
x: cl.x + sz / 2 + 2, y: cl.y - sz / 2 - 1,
class: "clamp-badge",
});
badge.textContent = `×${n}`;
g.append(badge);
}
if (cl.label) {
const label = svgEl("text", {
x: cl.x + sz / 2 + 4, y: cl.y + 3,
class: "clamp-label",
});
label.textContent = cl.label;
g.append(label);
}
gClamps.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id));
}
// Cables — polyline through endpoint(from) → clamps in ord sequence
// → endpoint(to). With zero clamps this collapses to a v0 straight
// line. Auto-cables render dashed; manual 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]));
const clampByID = new Map(state.clamps.map((cl) => [cl.id, cl]));
// Pre-group cable_clamps by cable, sorted by ord.
const clampsByCable = new Map();
for (const cc of state.cableClamps) {
let arr = clampsByCable.get(cc.cable_id);
if (!arr) { arr = []; clampsByCable.set(cc.cable_id, arr); }
arr.push(cc);
}
for (const arr of clampsByCable.values()) arr.sort((a, b) => a.ord - b.ord);
// sharedSegments: segmentKey → { a, b, cables:[Cable] }. Built up
// during the per-cable loop, then walked in a second pass for the
// bundle overlay layer (v5 §11.3).
const sharedSegments = new Map();
for (const c of state.cables) {
const built = cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
if (!built) continue;
const { vertices, keys } = built;
// Bundle accumulator — record this cable on every segment of its
// resolved polyline keyed by an undirected pair of vertex IDs.
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i], b = keys[i + 1];
const segKey = a < b ? `${a}|${b}` : `${b}|${a}`;
let bucket = sharedSegments.get(segKey);
if (!bucket) {
bucket = { a: vertices[i], b: vertices[i + 1], cables: [] };
sharedSegments.set(segKey, bucket);
}
bucket.cables.push(c);
}
// Replug preview: while m drags an endpoint handle, override the
// affected end with the live cursor world position so the line
// tracks the pointer. Mid-vertices (clamps) are unchanged.
if (cableReplug && cableReplug.cableID === c.id) {
const idx = cableReplug.end === "from" ? 0 : vertices.length - 1;
vertices[idx] = { x: cableReplug.x, y: cableReplug.y };
}
// Mid-segment drag preview: while m is bending a segment, insert
// a temp vertex at the cursor so the line tracks. On release this
// becomes a real clamp (or snaps to a nearby existing one).
if (cableMidDrag && cableMidDrag.cableID === c.id) {
const at = cableMidDrag.segmentIdx + 1;
vertices.splice(at, 0, { x: cableMidDrag.x, y: cableMidDrag.y });
}
const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id;
const color = cableTypeColor.get(c.type_id) || "#888";
const pointsStr = vertices.map((v) => `${v.x},${v.y}`).join(" ");
const line = svgEl("polyline", {
points: pointsStr,
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
stroke: color,
fill: "none",
"data-cable-id": c.id,
});
line.addEventListener("click", (e) => {
e.stopPropagation();
state.selection = { kind: "cable", id: c.id };
render();
});
line.addEventListener("pointerdown", (e) => {
// Selected cable + non-endpoint click → start a mid-segment drag
// that inserts (or snaps to) a clamp on release. Bypasses the
// canvas-level handler so panning / device drag don't fire.
if (isSelected && e.button === 0 && !state.spaceHeld) {
startCableMidDrag(e, c, vertices);
}
});
gCables.append(line);
// Endpoint handles — first + last vertex when selected.
if (isSelected) {
const first = vertices[0];
const last = vertices[vertices.length - 1];
for (const [end, a] of [["from", first], ["to", last]]) {
const h = svgEl("circle", {
cx: a.x, cy: a.y, r: 7,
class: "cable-handle",
fill: color,
stroke: "#fff",
"data-cable-id": c.id,
"data-end": end,
});
h.addEventListener("pointerdown", (e) => startCableReplug(e, c.id, end));
gCables.append(h);
}
}
}
// ---- bundle viz: shared segments + clamp count badges (v5 §11.3) ----
let gradSeq = 0;
for (const [segKey, bucket] of sharedSegments) {
if (bucket.cables.length < 2) continue;
// Distinct cable type IDs in this bundle, ordered by count desc
// (ties by id asc) per design v5 §11.9 q4.
const counts = new Map();
for (const c of bucket.cables) {
counts.set(c.type_id, (counts.get(c.type_id) || 0) + 1);
}
const distinctTypes = [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
.map(([id]) => id);
// Build a linearGradient perpendicular to the segment so the stripes
// run ACROSS the segment's thickness (visually: stripes parallel to
// the cable direction).
const { a, b } = bucket;
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
// Perpendicular unit vector — gradient runs along this so the stops
// become bands along the segment's direction.
const px = -dy / len, py = dx / len;
const thickness = Math.min(12, 2 + bucket.cables.length);
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
// Stops: hard-edged segments, one band per type.
const gradID = `bundle-grad-${gradSeq++}-${segKey.replace(/[^a-z0-9-]/gi, "_")}`;
const grad = svgEl("linearGradient", {
id: gradID,
gradientUnits: "userSpaceOnUse",
x1: mx + px * thickness / 2,
y1: my + py * thickness / 2,
x2: mx - px * thickness / 2,
y2: my - py * thickness / 2,
});
const n = distinctTypes.length;
for (let i = 0; i < n; i++) {
const color = cableTypeColor.get(distinctTypes[i]) || "#888";
const startStop = svgEl("stop", { offset: `${(i / n) * 100}%`, "stop-color": color });
const endStop = svgEl("stop", { offset: `${((i + 1) / n) * 100}%`, "stop-color": color });
grad.append(startStop, endStop);
}
gDefs.append(grad);
// Tooltip listing the bundled cable types.
const titleText = distinctTypes
.map((id) => cableTypeColor.has(id) ? state.cableTypes.find((t) => t.id === id)?.name ?? `#${id}` : `#${id}`)
.join(" · ") + ` (${bucket.cables.length} cables)`;
const overlay = svgEl("line", {
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
class: "bundle-line",
stroke: `url(#${gradID})`,
"stroke-width": thickness,
});
const title = svgEl("title", {});
title.textContent = titleText;
overlay.append(title);
gBundles.append(overlay);
}
}
// Compute the resolved polyline vertices for a cable plus a stable
// vertex-key per vertex used to detect shared segments for bundle viz.
// Vertex keys:
// - port:<id> for a port-anchored endpoint
// - device:<id> for a device-anchored endpoint (no port)
// - io:<id> for an IO-anchored endpoint
// - clamp:<id> for a mid-vertex
// Returns null if either endpoint can't be resolved.
function cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
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) return null;
function endpointKey(portID, deviceID, ioID) {
if (portID != null) return `port:${portID}`;
if (deviceID != null) return `device:${deviceID}`;
return `io:${ioID}`;
}
const vertices = [fromAnchor];
const keys = [endpointKey(c.from_port_id, c.from_device_id, c.from_io_id)];
const clamps = clampsByCable.get(c.id) || [];
for (const cc of clamps) {
const cl = clampByID.get(cc.clamp_id);
if (cl) {
vertices.push({ x: cl.x, y: cl.y });
keys.push(`clamp:${cl.id}`);
}
}
vertices.push(toAnchor);
keys.push(endpointKey(c.to_port_id, c.to_device_id, c.to_io_id));
return { vertices, keys };
}
/** 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() {
const body = $("#inspector-body");
if (!state.selection) {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
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);
case "clamp": return renderInspectorClamp(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;
const driverCell = body.querySelector("#cab-driver");
if (drivingReq) {
const deviceByID2 = new Map(state.devices.map((d) => [d.id, d]));
const an = deviceByID2.get(drivingReq.from_device_id)?.name ?? "?";
const bn = deviceByID2.get(drivingReq.to_device_id)?.name ?? "?";
const link = document.createElement("button");
link.type = "button";
link.className = "btn-link";
link.style.padding = "0";
link.textContent = `${an}${bn}`;
link.title = "Jump to this requirement";
link.addEventListener("click", () => {
state.selection = { kind: "requirement", id: drivingReq.id };
render();
});
driverCell.append(link);
} else {
driverCell.textContent = 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; }
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
const ioCount = state.ioMarkers.filter((m) => m.frame_id === f.id).length;
body.innerHTML = `
<p class="section-title">Frame</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="frm-name" value="" />
</label>
<dl>
<dt>x</dt><dd id="frm-x"></dd>
<dt>y</dt><dd id="frm-y"></dd>
<dt>w</dt><dd id="frm-w"></dd>
<dt>h</dt><dd id="frm-h"></dd>
<dt>devices</dt><dd id="frm-count"></dd>
<dt>IO</dt><dd id="frm-io-count"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
</div>
`;
body.querySelector("#frm-name").value = f.name;
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
body.querySelector("#frm-count").textContent = String(deviceCount);
body.querySelector("#frm-io-count").textContent = String(ioCount);
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
if (!state.active) return;
const updated = await patchFrame(state.active.id, f.id, { name });
Object.assign(f, updated);
renderCanvas();
});
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices and IO markers stay but lose their frame.`)) return;
deleteFrame(state.active.id, f.id).then(() => {
state.frames = state.frames.filter((x) => x.id !== f.id);
for (const m of state.ioMarkers) if (m.frame_id === f.id) m.frame_id = null;
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function renderInspectorDevice(body, id) {
const d = state.devices.find((x) => x.id === id);
if (!d) { body.innerHTML = ""; return; }
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
const type = d.type_id ? state.deviceTypes.find((t) => t.id === d.type_id) : null;
const ports = state.ports.filter((p) => p.device_id === d.id);
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 portsHtml = ports.length
? ports.map((p) => `
<div class="port-row" data-port-id="${p.id}">
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
<span class="label">${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
<span class="conn">
<button type="button" class="btn-link port-del" data-port-id="${p.id}" title="Delete port">×</button>
</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
// 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 `
<div class="requirement-row" data-req-id="${r.id}">
<span class="pair">↔ ${escapeHtml(otherName)}
<span class="type"> · ${escapeHtml(ct ?? "solver picks")}</span>
</span>
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
</div>`;
}).join("")
: `<p class="muted" style="font-size:12px">No requirements yet.</p>`;
body.innerHTML = `
<p class="section-title">Device</p>
<label class="field">
<span>Name</span>
<input class="inline-input" id="dev-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="dev-color" />
</label>
<dl>
<dt>type</dt><dd id="dev-type"></dd>
<dt>x</dt><dd id="dev-x"></dd>
<dt>y</dt><dd id="dev-y"></dd>
<dt>w</dt><dd id="dev-w"></dd>
<dt>h</dt><dd id="dev-h"></dd>
<dt>frame</dt><dd id="dev-frame"></dd>
</dl>
<p class="section-title">Ports</p>
<div id="dev-ports">${portsHtml}</div>
<div class="inspector-actions" style="margin-top: 4px;">
<button type="button" class="btn btn-tiny" id="dev-add-port">+ Port</button>
</div>
<p class="section-title">Requirements</p>
<div id="dev-reqs">${reqsHtml}</div>
<div class="inspector-actions" style="margin-top: 4px;">
<button type="button" class="btn btn-tiny" id="dev-add-req">+ Requirement</button>
</div>
<div class="inspector-actions" style="margin-top: 12px;">
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
</div>
`;
body.querySelector("#dev-name").value = d.name;
body.querySelector("#dev-color").value = d.color;
body.querySelector("#dev-type").textContent = type
? `${type.name}${type.built_in ? "" : " (custom)"}`
: "Custom (no type)";
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
if (!state.active) return;
const updated = await patchDevice(state.active.id, d.id, { name });
Object.assign(d, updated);
renderCanvas();
});
// Colour changes need no debounce — the native colour picker only fires
// `change` on commit.
body.querySelector("#dev-color").addEventListener("change", async (e) => {
if (!state.active) return;
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchDevice(state.active.id, d.id, { color });
Object.assign(d, updated);
renderCanvas();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#dev-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete device "${d.name}"?`)) return;
deleteDevice(state.active.id, d.id).then(() => {
state.devices = state.devices.filter((x) => x.id !== d.id);
state.ports = state.ports.filter((p) => p.device_id !== d.id);
// Server cascaded the requirements; drop them locally too.
state.requirements = state.requirements.filter(
(r) => r.from_device_id !== d.id && r.to_device_id !== d.id,
);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
// Clicking a requirement row in the device inspector jumps to that
// requirement's own inspector pane.
body.querySelectorAll("[data-req-id]").forEach((el) => {
el.addEventListener("click", () => {
const rid = Number(el.getAttribute("data-req-id"));
state.selection = { kind: "requirement", id: rid };
render();
});
});
// + Requirement — open the modal pre-filled with this device as the
// "from" endpoint. Refuses if the project has fewer than 2 devices
// (a requirement needs two distinct endpoints).
body.querySelector("#dev-add-req").addEventListener("click", () => {
if (!state.active) return;
if (state.devices.length < 2) {
alert("Add a second device before declaring a requirement.");
return;
}
openRequirementModal(null, { from: d.id });
});
// +Port — switch the inspector to the new-port form. m fills in
// type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
state.selection = { kind: "port_new", device_id: d.id };
render();
});
// Clicking a port row in the device's port list selects that port
// and opens its editor in the inspector pane.
body.querySelectorAll(".port-row[data-port-id]").forEach((row) => {
row.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return;
const pid = Number(row.getAttribute("data-port-id"));
if (!pid) return;
state.selection = { kind: "port", id: pid };
render();
});
});
// Per-port delete.
body.querySelectorAll(".port-del").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!state.active) return;
const pid = Number(btn.getAttribute("data-port-id"));
if (!pid) return;
if (!confirm("Delete this port?")) return;
try {
await deletePort(state.active.id, pid);
state.ports = state.ports.filter((p) => p.id !== pid);
// Cables that referenced the port get from_port_id/to_port_id
// set to NULL by the schema — refresh from snapshot.
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
render();
} catch (ex) { alert(`Delete failed: ${ex.message}`); }
});
});
}
function renderInspectorRequirement(body, id) {
const r = state.requirements.find((x) => x.id === id);
if (!r) { body.innerHTML = ""; return; }
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
const ctName = r.preferred_cable_type_id != null
? state.cableTypes.find((t) => t.id === r.preferred_cable_type_id)?.name
: null;
body.innerHTML = `
<p class="section-title">Connection requirement</p>
<dl>
<dt>from</dt><dd id="rq-from-name"></dd>
<dt>to</dt><dd id="rq-to-name"></dd>
<dt>cable</dt><dd id="rq-ct"></dd>
<dt>type</dt><dd id="rq-must">${r.must_connect ? "must connect" : "nice to have"}</dd>
</dl>
<label class="field">
<span>Notes</span>
<textarea class="inline-input" id="rq-notes" rows="2"></textarea>
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="rq-edit">Edit</button>
<button type="button" class="btn btn-tiny" id="rq-toggle">${r.must_connect ? "Make nice" : "Make must"}</button>
<button type="button" class="btn btn-danger btn-tiny" id="rq-del">Delete</button>
</div>
`;
body.querySelector("#rq-from-name").textContent = a ? a.name : `#${r.from_device_id}`;
body.querySelector("#rq-to-name").textContent = b ? b.name : `#${r.to_device_id}`;
body.querySelector("#rq-ct").textContent = ctName ?? "solver picks";
body.querySelector("#rq-notes").value = r.notes ?? "";
bindDebouncedRename(body.querySelector("#rq-notes"), async (notes) => {
if (!state.active) return;
const updated = await patchRequirement(state.active.id, r.id, { notes });
Object.assign(r, updated);
renderRequirements();
});
body.querySelector("#rq-edit").addEventListener("click", () => openRequirementModal(r));
body.querySelector("#rq-toggle").addEventListener("click", async () => {
if (!state.active) return;
try {
const updated = await patchRequirement(state.active.id, r.id, { must_connect: !r.must_connect });
Object.assign(r, updated);
render();
} catch (e) { alert(`Update failed: ${e.message}`); }
});
body.querySelector("#rq-del").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this requirement?")) return;
try {
await deleteRequirement(state.active.id, r.id);
state.requirements = state.requirements.filter((x) => x.id !== r.id);
state.selection = null;
render();
} catch (e) { alert(`Delete failed: ${e.message}`); }
});
}
// ---------- requirement drag gesture ---------- //
/** Pointerdown on a device with `req` tool armed → draw a dashed line to
* the pointer position. Pointerup on another device opens the modal
* with from/to pre-filled. Anywhere else cancels. */
function startRequirementDrag(e, fromDeviceID) {
if (!state.active) return;
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const fromDev = state.devices.find((d) => d.id === fromDeviceID);
if (!fromDev) return;
const sx = fromDev.x + fromDev.width / 2;
const sy = fromDev.y + fromDev.height / 2;
const line = svgEl("line", {
x1: sx, y1: sy, x2: sx, y2: sy,
class: "req-drag-line",
});
svg.append(line);
svg.setPointerCapture(e.pointerId);
const onMove = (ev) => {
const p = svgPoint(ev);
line.setAttribute("x2", String(p.x));
line.setAttribute("y2", String(p.y));
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
line.remove();
// Hit-test: which device did the pointer land on?
let toDeviceID = null;
if (ev.target instanceof Element) {
const g = ev.target.closest("[data-device-id]");
if (g) toDeviceID = Number(g.getAttribute("data-device-id"));
}
armTool(null);
if (!toDeviceID || toDeviceID === fromDeviceID) return; // cancel
openRequirementModal(null, { from: fromDeviceID, to: toDeviceID });
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- requirement modal ---------- //
/**
* Open the +Requirement / edit modal. Pass `existing` to edit an existing
* row; pass `{from, to}` (device ids, both optional) to pre-fill a new row.
*/
function openRequirementModal(existing, prefill = {}) {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-requirement"));
const form = /** @type {HTMLFormElement} */ ($("#form-requirement"));
const selFrom = /** @type {HTMLSelectElement} */ ($("#rq-from"));
const selTo = /** @type {HTMLSelectElement} */ ($("#rq-to"));
const selCt = /** @type {HTMLSelectElement} */ ($("#rq-cable"));
const mustCb = /** @type {HTMLInputElement} */ ($("#rq-must"));
const err = $("#rq-error");
const title = $("#rq-title");
showError(err, "");
title.textContent = existing ? "Edit requirement" : "New requirement";
// Populate the device pickers.
for (const sel of [selFrom, selTo]) {
sel.innerHTML = "";
for (const d of state.devices) {
sel.append(new Option(d.name, String(d.id)));
}
}
// Cable-type picker: "solver picks" + every cable type.
selCt.innerHTML = "";
selCt.append(new Option("— solver picks —", ""));
for (const ct of state.cableTypes) {
selCt.append(new Option(ct.name, String(ct.id)));
}
if (existing) {
selFrom.value = String(existing.from_device_id);
selTo.value = String(existing.to_device_id);
selCt.value = existing.preferred_cable_type_id != null ? String(existing.preferred_cable_type_id) : "";
mustCb.checked = existing.must_connect;
form.elements.namedItem("notes").value = existing.notes || "";
} else {
if (prefill.from != null) selFrom.value = String(prefill.from);
if (prefill.to != null) selTo.value = String(prefill.to);
if (selFrom.value === selTo.value && state.devices.length >= 2) {
// Pick a different "to" so the form starts valid.
const other = state.devices.find((d) => String(d.id) !== selFrom.value);
if (other) selTo.value = String(other.id);
}
selCt.value = "";
mustCb.checked = true;
form.elements.namedItem("notes").value = "";
}
dlg.showModal();
form.onsubmit = async (e) => {
e.preventDefault();
const fromID = Number(selFrom.value);
const toID = Number(selTo.value);
if (!fromID || !toID || fromID === toID) {
showError(err, "from and to must be two different devices");
return;
}
const ctRaw = selCt.value;
const notes = String(form.elements.namedItem("notes").value || "");
const must = mustCb.checked;
try {
if (existing) {
const body = {
must_connect: must,
notes,
// tri-state: empty string → null on the wire (= clear)
preferred_cable_type_id: ctRaw === "" ? null : Number(ctRaw),
};
const updated = await patchRequirement(state.active.id, existing.id, body);
Object.assign(existing, updated);
} else {
const body = {
from_device_id: fromID,
to_device_id: toID,
must_connect: must,
notes,
};
if (ctRaw !== "") body.preferred_cable_type_id = Number(ctRaw);
const created = await createRequirement(state.active.id, body);
state.requirements.push(created);
state.selection = { kind: "requirement", id: created.id };
}
dlg.close();
render();
} catch (ex) {
showError(err, ex.message || "Save failed");
}
};
}
function renderInspectorIO(body, id) {
const m = state.ioMarkers.find((x) => x.id === id);
if (!m) { body.innerHTML = ""; return; }
const frame = m.frame_id ? state.frames.find((f) => f.id === m.frame_id) : null;
body.innerHTML = `
<p class="section-title">IO marker</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="io-label" value="" />
</label>
<dl>
<dt>x</dt><dd id="io-x"></dd>
<dt>y</dt><dd id="io-y"></dd>
<dt>frame</dt><dd id="io-frame"></dd>
</dl>
<p class="muted" style="font-size:12px">
Wall-outlet terminator. Power-by-convention; a future cable terminating
here means "plugged into a socket outside the diagram".
</p>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="io-delete">Delete</button>
</div>
`;
body.querySelector("#io-label").value = m.label;
body.querySelector("#io-x").textContent = m.x.toFixed(0);
body.querySelector("#io-y").textContent = m.y.toFixed(0);
body.querySelector("#io-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#io-label"), async (label) => {
if (!state.active) return;
const updated = await patchIOMarker(state.active.id, m.id, { label });
Object.assign(m, updated);
renderCanvas();
});
body.querySelector("#io-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete IO marker "${m.label}"?`)) return;
deleteIOMarker(state.active.id, m.id).then(() => {
state.ioMarkers = state.ioMarkers.filter((x) => x.id !== m.id);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
// Clamp inspector — label + position + cables-through list + delete.
// Slice 4 wires the cables-through list to actual data; for slice 3 it
// reads whatever's already on state.cableClamps (initially empty for a
// freshly-placed clamp).
function renderInspectorClamp(body, id) {
const cl = state.clamps.find((x) => x.id === id);
if (!cl) { body.innerHTML = ""; return; }
const frame = cl.frame_id ? state.frames.find((f) => f.id === cl.frame_id) : null;
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
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 cablesThrough = state.cableClamps
.filter((cc) => cc.clamp_id === id)
.map((cc) => state.cables.find((c) => c.id === cc.cable_id))
.filter(Boolean);
function endpointLabel(c, end) {
const portID = end === "from" ? c.from_port_id : c.to_port_id;
const devID = end === "from" ? c.from_device_id : c.to_device_id;
const ioID = end === "from" ? c.from_io_id : c.to_io_id;
if (portID != null) {
const p = portByID.get(portID);
const d = p && deviceByID.get(p.device_id);
return `${d?.name ?? "?"} · ${p?.label ?? "port"}`;
}
if (devID != null) return deviceByID.get(devID)?.name ?? "(missing device)";
if (ioID != null) return ioByID.get(ioID)?.label ?? "(missing IO)";
return "?";
}
const cablesHtml = cablesThrough.length
? cablesThrough.map((c) => `
<div class="port-row" data-cable-id="${c.id}">
<span class="swatch" style="background:${cableTypeColor.get(c.type_id) || "#888"}"></span>
<span class="label">${escapeHtml(endpointLabel(c, "from"))}${escapeHtml(endpointLabel(c, "to"))}</span>
<span class="conn">
<button type="button" class="btn-link clamp-detach" data-cable-id="${c.id}" title="Remove this clamp from the cable">×</button>
</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">Not on any cable yet.</p>`;
body.innerHTML = `
<p class="section-title">Clamp</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="clamp-label" value="" />
</label>
<dl>
<dt>x</dt><dd id="clamp-x"></dd>
<dt>y</dt><dd id="clamp-y"></dd>
<dt>frame</dt><dd id="clamp-frame"></dd>
</dl>
<p class="section-title">Cables through</p>
${cablesHtml}
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="clamp-delete">Delete clamp</button>
</div>
`;
body.querySelector("#clamp-label").value = cl.label;
body.querySelector("#clamp-x").textContent = cl.x.toFixed(0);
body.querySelector("#clamp-y").textContent = cl.y.toFixed(0);
body.querySelector("#clamp-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#clamp-label"), async (label) => {
if (!state.active) return;
const updated = await patchClamp(state.active.id, cl.id, { label });
Object.assign(cl, updated);
renderCanvas();
});
// Per-cable detach in the cables-through list.
body.querySelectorAll(".clamp-detach").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!state.active) return;
const cableID = Number(btn.getAttribute("data-cable-id"));
try {
await detachClampFromCable(state.active.id, cableID, cl.id);
// Re-fetch snapshot fragment to keep ord contiguous.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
} catch (ex) {
alert(`Detach failed: ${ex.message}`);
}
});
});
body.querySelector("#clamp-delete").addEventListener("click", async () => {
if (!state.active) return;
const n = cablesThrough.length;
const prompt = n > 0
? `This clamp is on ${n} cable(s). Delete it and remove from all of them?`
: "Delete this clamp?";
if (!confirm(prompt)) return;
try {
await deleteClamp(state.active.id, cl.id);
state.clamps = state.clamps.filter((c) => c.id !== id);
state.cableClamps = state.cableClamps.filter((cc) => cc.clamp_id !== id);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort(body, id) {
const prt = state.ports.find((p) => p.id === id);
if (!prt) { body.innerHTML = ""; return; }
const dev = state.devices.find((d) => d.id === prt.device_id);
if (!dev) { body.innerHTML = ""; return; }
const currentEdge = portEdge(prt, dev);
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-back-device" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-type").value = String(prt.type_id);
body.querySelector("#port-edge").value = currentEdge;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-back-device").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-type").addEventListener("change", async (e) => {
if (!state.active) return;
const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value);
if (newTypeID === prt.type_id) return;
try {
const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID });
Object.assign(prt, updated);
renderCanvas();
} catch (ex) {
alert(`Type change failed: ${ex.message}`);
}
});
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
const updated = await patchPort(state.active.id, prt.id, { label });
Object.assign(prt, updated);
renderCanvas();
});
body.querySelector("#port-edge").addEventListener("change", async (e) => {
if (!state.active) return;
const newEdge = /** @type {HTMLSelectElement} */ (e.target).value;
const oldEdge = portEdge(prt, dev);
if (newEdge === oldEdge) return;
// PATCH to a temp position on the new edge so portEdge() classifies
// this port onto newEdge in the upcoming relayouts. The temp position
// gets overwritten by relayoutEdge(newEdge); the only thing that
// matters is that the port is unambiguously on the right edge.
const tmp = edgeCentre(dev, newEdge);
try {
const updated = await patchPort(state.active.id, prt.id, {
x_offset: tmp.xOff, y_offset: tmp.yOff,
});
Object.assign(prt, updated);
// Re-space both affected edges: the one the port left and the one
// it landed on. Order doesn't matter — they operate on disjoint
// port sets.
await Promise.all([
relayoutEdge(dev.id, oldEdge),
relayoutEdge(dev.id, newEdge),
]);
renderCanvas();
} catch (ex) {
alert(`Move port failed: ${ex.message}`);
}
});
body.querySelector("#port-delete").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this port?")) return;
const wasEdge = portEdge(prt, dev);
try {
await deletePort(state.active.id, prt.id);
state.ports = state.ports.filter((p) => p.id !== prt.id);
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
// Re-space the edge the deleted port was on so the survivors
// shift back to even spacing.
await relayoutEdge(dev.id, wasEdge);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Centre of the named edge, expressed as (x_offset, y_offset) relative
// to the device origin. Used as a temp anchor when moving a port between
// edges — the precise centre value is immediately overwritten by
// relayoutEdge, but it has to land on the right edge.
function edgeCentre(dev, edge) {
switch (edge) {
case "top": return { xOff: dev.width / 2, yOff: 0 };
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
case "left": return { xOff: 0, yOff: dev.height / 2 };
default: return { xOff: dev.width / 2, yOff: dev.height };
}
}
// Compute the next available default label for a new port of `typeID`
// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
// HDMI port gets "HDMI 3".
function nextDefaultPortLabel(deviceID, typeID) {
const ct = state.cableTypes.find((t) => t.id === typeID);
const prefix = ct?.name || "Port";
const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID);
let max = 0;
for (const p of sibs) {
const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$"));
if (m) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// "Add port" form. Submit → POST → switch inspector to the new port's
// editor. m can cancel back to the device inspector.
function renderInspectorPortNew(body, deviceID) {
const dev = state.devices.find((d) => d.id === deviceID);
if (!dev) { body.innerHTML = ""; return; }
if (state.cableTypes.length === 0) {
body.innerHTML = `
<p class="section-title">Add port</p>
<p class="muted">No cable types defined. Add one from the legend first.</p>
<div class="inspector-actions">
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>`;
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
return;
}
const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id;
const typeOptions = state.cableTypes
.map((t) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
.join("");
body.innerHTML = `
<p class="section-title">Add port</p>
<p style="font-size:12px;margin:0 0 8px 0;">
<a href="#" id="port-new-back" class="btn-link">← ${escapeHtml(dev.name)}</a>
</p>
<label class="field">
<span>Type</span>
<select id="port-new-type">${typeOptions}</select>
</label>
<label class="field">
<span>Edge</span>
<select id="port-new-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom" selected>Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-new-label" value="" />
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-primary btn-tiny" id="port-new-create">Create</button>
<button type="button" class="btn btn-tiny" id="port-new-cancel">Cancel</button>
</div>
`;
const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type"));
const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge"));
const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label"));
typeSel.value = String(defaultTypeID);
labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID);
labelInp.placeholder = labelInp.value;
// Recompute default label whenever the type changes (only if m hasn't
// edited the field).
let labelUserEdited = false;
labelInp.addEventListener("input", () => { labelUserEdited = true; });
typeSel.addEventListener("change", () => {
if (labelUserEdited) return;
const tid = Number(typeSel.value);
const next = nextDefaultPortLabel(dev.id, tid);
labelInp.value = next;
labelInp.placeholder = next;
});
body.querySelector("#port-new-back").addEventListener("click", (e) => {
e.preventDefault();
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-cancel").addEventListener("click", () => {
state.selection = { kind: "device", id: dev.id };
render();
});
body.querySelector("#port-new-create").addEventListener("click", async () => {
const tid = Number(typeSel.value);
const edge = edgeSel.value;
const label = labelInp.value.trim();
await createPortFromForm(dev.id, tid, edge, label);
});
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
// The "used by N cables" counter is purely informational in slice 3.
// Slice 7+ will populate state.cables; until then we surface 0.
const usedBy = 0;
const banner = `
<p class="banner" style="margin: 0 0 12px 0">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
`;
body.innerHTML = `
<p class="section-title">Cable type</p>
${banner}
<label class="field">
<span>Name</span>
<input class="inline-input" id="ct-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="ct-color" />
</label>
<dl>
<dt>used by</dt><dd id="ct-used"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="ct-delete">Delete</button>
</div>
`;
body.querySelector("#ct-name").value = t.name;
body.querySelector("#ct-color").value = t.color;
body.querySelector("#ct-used").textContent = `${usedBy} cable${usedBy === 1 ? "" : "s"}`;
bindDebouncedRename(body.querySelector("#ct-name"), async (name) => {
const updated = await patchCableType(t.id, { name });
Object.assign(t, updated);
render();
});
body.querySelector("#ct-color").addEventListener("change", async (e) => {
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchCableType(t.id, { color });
Object.assign(t, updated);
render();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#ct-delete").addEventListener("click", async () => {
if (!confirm(`Delete cable type "${t.name}"? Blocked if any cable uses it.`)) return;
try {
await deleteCableType(t.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === t.id) state.activeTypeId = null;
state.selection = null;
render();
} catch (err) {
const n = err.details?.in_use_by_cables;
alert(n != null
? `Cannot delete "${t.name}" — in use by ${n} cable${n === 1 ? "" : "s"}.`
: `Delete failed: ${err.message}`);
}
});
}
function bindDebouncedRename(input, persist) {
let timer = null;
input.addEventListener("input", () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const v = input.value.trim();
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
}, 400);
});
input.addEventListener("blur", () => {
if (timer) { clearTimeout(timer); timer = null; }
const v = input.value.trim();
if (v && v !== input.dataset.last) {
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
input.dataset.last = v;
}
});
}
function render() {
renderProjectPicker();
renderLegend();
renderCanvas();
renderEmptyHint();
renderInspector();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// ---------- active project ---------- //
async function activateProject(id) {
if (id == null) {
state.active = null;
state.frames = [];
state.devices = [];
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
state.selection = null;
setActiveInURL(null);
render();
return;
}
try {
const snap = await getSnapshot(id);
state.active = snap.project;
state.frames = snap.frames || [];
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.clamps = snap.clamps || [];
state.cableClamps = snap.cable_clamps || [];
state.selection = null;
setActiveInURL(id);
// Hydrate the device-type catalog for this project — used by the
// +Dev modal's dropdown + the device inspector's "Type" row. Done in
// parallel after snapshot loads (small response, doesn't gate render).
try {
state.deviceTypes = await listDeviceTypesForProject(id) || [];
} catch (_) {
// Don't fail the whole load if catalog fetch fails — the +Dev
// modal can show a degraded "Custom only" mode.
state.deviceTypes = [];
}
render();
} catch (err) {
if (err.status === 404) {
state.active = null;
state.frames = [];
state.devices = [];
state.ports = [];
state.ioMarkers = [];
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
setActiveInURL(null);
render();
} else {
alert(`Failed to load project: ${err.message}`);
}
}
}
// ---------- tools ---------- //
function armTool(tool) {
if (state.tool === tool) tool = null; // toggle off
state.tool = tool;
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-cable", tool === "cable");
wrap.classList.toggle("tool-clamp", tool === "clamp");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
}
document.addEventListener("keydown", (e) => {
// Avoid stealing keys while user is typing into an input.
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === " " && !state.spaceHeld) {
// Hold Space to enable click-and-drag pan. Don't preventDefault here
// so pressing Space in unrelated focusable elements still works; the
// canvas pointerdown handler reads state.spaceHeld to gate the pan.
state.spaceHeld = true;
$(".canvas-wrap").classList.add("space-pan-ready");
e.preventDefault();
return;
}
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
else if (e.key === "0" || e.key === "Home") resetView();
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io");
else if (e.key === "c" || e.key === "C") armTool("clamp");
else if (e.key === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal();
});
document.addEventListener("keyup", (e) => {
if (e.key === " ") {
state.spaceHeld = false;
$(".canvas-wrap").classList.remove("space-pan-ready");
}
});
// Canvas-level pointerdown handles tool activation + selection clearing.
const svg = $("#canvas");
svg.addEventListener("pointerdown", onCanvasPointerDown);
// Wheel zooms around the cursor — `passive: false` so we can
// preventDefault and stop the page from scrolling.
svg.addEventListener("wheel", wheelZoom, { passive: false });
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
// Live state for a cable-endpoint replug drag. Captured at pointerdown
// on a .cable-handle, used by renderCanvas to anchor the dragged end
// at the cursor; cleared on pointerup (commit or cancel).
let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null);
// Mid-segment drag — m grabs a point on a cable's polyline (not on an
// endpoint handle, not on an existing clamp vertex) and drags. On
// release, either snap to a nearby clamp or create a fresh one at the
// drop point and insert at the right `ord`.
let cableMidDrag = /** @type {{cableID: number, segmentIdx: number, x: number, y: number}|null} */ (null);
function onCanvasPointerDown(e) {
// Pan gestures win over every tool. Middle-click and Space+drag both
// route here regardless of project state — m can pan an empty canvas
// without selecting a project first.
if (e.button === 1 || state.spaceHeld) {
startPan(e);
return;
}
if (!state.active) return;
const p = svgPoint(e);
// Armed tool wins: a click anywhere on the canvas — including on top
// of an existing frame or device — fires the tool. The +Dev tool needs
// this so m can drop a device inside a frame; without it the frame's
// own pointerdown handler would steal the click and start a drag.
//
// e.preventDefault() suppresses the compatibility mousedown's default
// focus-shift. Without it, the freshly-focused inline-namer input gets
// blurred ~6ms later by the browser's "focus nearest focusable ancestor
// or blur active" behaviour (SVG rects are not focusable), and the
// blur handler tears the namer down before m can type. Root cause +
// verified fix from sherlock's Playwright shift; see docs/sherlock-+dev-bug.md
// for the full trace.
if (state.tool === "frame") {
e.preventDefault();
startFrameRubberBand(e, p);
return;
}
if (state.tool === "device") {
e.preventDefault();
placeDeviceAt(p);
return;
}
if (state.tool === "clamp") {
e.preventDefault();
placeClampAt(p, e);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
return;
}
// No tool armed: clicks that started on a device/frame/io/clamp/port/cable
// go to their own handlers (drag / select / replug). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
// Empty-canvas left-click without an active cable draw: start a
// maybe-pan gesture. It promotes to a pan once the cursor crosses the
// drag threshold; if m clicks without dragging it falls back to the
// historic "clear selection" UX. Other buttons fall through (middle is
// already handled above, right-click is the browser context menu).
if (e.button === 0 && state.cableDrawFromPortID == null) {
startEmptyCanvasGesture(e);
}
}
function startFrameRubberBand(e, p0) {
if (!state.active) return;
rubberStart = p0;
rubberBand = svgEl("rect", {
x: p0.x, y: p0.y, width: 0, height: 0,
class: "rubber-band", rx: 6, ry: 6,
});
$("#canvas").append(rubberBand);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
svg.setPointerCapture(e.pointerId);
const onMove = (ev) => {
if (!rubberBand || !rubberStart) return;
const p = svgPoint(ev);
const x = Math.min(rubberStart.x, p.x);
const y = Math.min(rubberStart.y, p.y);
rubberBand.setAttribute("x", String(x));
rubberBand.setAttribute("y", String(y));
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
const rect = rubberBand;
const start = rubberStart;
rubberBand = null;
rubberStart = null;
if (!rect || !start) return;
const w = Number(rect.getAttribute("width"));
const h = Number(rect.getAttribute("height"));
const x = Number(rect.getAttribute("x"));
const y = Number(rect.getAttribute("y"));
rect.remove();
if (w < 80 || h < 60) { armTool(null); return; }
armTool(null);
const name = await promptInline("Frame name", x + w / 2, y + 16);
if (!name || !state.active) return;
try {
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
state.frames.push(f);
state.selection = { kind: "frame", id: f.id };
render();
} catch (err) {
alert(`Create frame failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
async function placeDeviceAt(p) {
if (!state.active) return;
armTool(null);
const W = 100, H = 35;
const x = p.x - W / 2;
const y = p.y - H / 2;
const frame = frameAt(p.x, p.y);
// Modal-driven flow (v4 slice 4): pick type + name in one form. Click
// position is captured here and POSTed on submit.
openNewDeviceModal({ x, y, width: W, height: H, frame_id: frame?.id ?? null });
}
function nextNameFor(typeName) {
// Auto-pick a name like "PC" / "PC-2" / "PC-3" against current devices.
const taken = new Set(state.devices.map((d) => d.name));
if (!taken.has(typeName)) return typeName;
for (let i = 2; i < 1000; i++) {
const candidate = `${typeName}-${i}`;
if (!taken.has(candidate)) return candidate;
}
return typeName;
}
function openNewDeviceModal(geom) {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-device"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-device"));
const sel = /** @type {HTMLSelectElement} */ ($("#nd-type"));
const nameInput = /** @type {HTMLInputElement} */ ($("#nd-name"));
const err = $("#nd-error");
showError(err, "");
form.reset();
// Build the dropdown: <optgroup label="kind"> 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);
}
// Frame bottom-right resize gesture. Mirrors startResize for devices,
// but PATCHes /frames/:id and uses a larger minimum (frames host
// devices + IO markers + clamps, so 200×150 is the smallest useful
// canvas). Contained children stay at their absolute positions — the
// frame body drag is what moves them; resize only changes the frame's
// own bounds.
function startFrameResize(e, frameID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const f = state.frames.find((x) => x.id === frameID);
if (!f) return;
const startWidth = f.width, startHeight = f.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_FRAME_W = 200, MIN_FRAME_H = 150;
const onMove = (ev) => {
const p = svgPoint(ev);
f.width = Math.max(MIN_FRAME_W, startWidth + (p.x - startWorld.x));
f.height = Math.max(MIN_FRAME_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 (f.width === startWidth && f.height === startHeight) return;
try {
const updated = await patchFrame(state.active.id, f.id, {
width: f.width, height: f.height,
});
Object.assign(f, updated);
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);
}
// Mid-segment cable drag: m grabs a point on a selected cable's
// polyline (not on an endpoint handle) and drags. On release, snap to
// the nearest clamp within MID_SNAP world-units, or create a fresh one
// at the drop point. Either way, attach it to the cable at the right
// ord so the new vertex sits inside the segment m was bending.
const MID_SNAP_PX = 16; // visual constant — divided by current zoom
function startCableMidDrag(e, cable, vertices) {
if (!state.active) return;
// Refuse if the click target is an endpoint handle — let the replug
// handler own that gesture.
if (e.target instanceof Element && e.target.classList.contains("cable-handle")) return;
e.stopPropagation();
e.preventDefault();
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
// Identify which segment the click landed closest to.
const segIdx = nearestSegmentIndex(vertices, start);
try { svg.setPointerCapture(e.pointerId); } catch {}
$(".canvas-wrap").classList.add("replugging");
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: start.x, y: start.y };
renderCanvas();
const onMove = (ev) => {
const p = svgPoint(ev);
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, 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 dropWorld = svgPoint(ev);
cableMidDrag = null;
// Cancel if the cursor barely moved (≤ a few px in world coords) —
// m probably clicked the cable to select it, not bend it.
if (Math.hypot(dropWorld.x - start.x, dropWorld.y - start.y) < 4) {
renderCanvas();
return;
}
// Snap radius in world coords — visual constant per design v5 §11.9 q2.
const snapRadius = MID_SNAP_PX / state.view.zoom;
let nearest = null;
let bestDist = Infinity;
for (const cl of state.clamps) {
const d = Math.hypot(cl.x - dropWorld.x, cl.y - dropWorld.y);
if (d < bestDist) { bestDist = d; nearest = cl; }
}
try {
let clampID;
if (nearest && bestDist <= snapRadius) {
// Snap onto existing clamp — but only if it's not already on
// this cable (UNIQUE constraint would 409). Skip silently in
// that case rather than spamming an alert.
const already = state.cableClamps.some(
(cc) => cc.cable_id === cable.id && cc.clamp_id === nearest.id,
);
if (already) { renderCanvas(); return; }
clampID = nearest.id;
} else {
// Fresh clamp at the drop point.
const frame = frameAt(dropWorld.x, dropWorld.y);
const newClamp = await createClamp(state.active.id, {
x: dropWorld.x, y: dropWorld.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(newClamp);
clampID = newClamp.id;
}
// Insert at ord = segIdx + 1 (1-based; segmentIdx is the segment
// between vertices[segIdx] and vertices[segIdx + 1]).
const cc = await attachClampToCable(state.active.id, cable.id, {
clamp_id: clampID, ord: segIdx + 1,
});
// Refresh cable_clamps so the new ord + any shifted neighbours
// are reflected without a full snapshot reload.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
// Silence unused-var lint without dropping the result.
void cc;
} catch (err) {
alert(`Insert clamp failed: ${err.message}`);
renderCanvas();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Index of the segment in `vertices` closest to point p. Segment i sits
// between vertices[i] and vertices[i+1].
function nearestSegmentIndex(vertices, p) {
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < vertices.length - 1; i++) {
const a = vertices[i], b = vertices[i + 1];
const d = pointSegmentDistance(p, a, b);
if (d < bestDist) { bestDist = d; best = i; }
}
return best;
}
// Shortest distance from point p to the line segment ab.
function pointSegmentDistance(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/** 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}`);
}
}
// + Clamp tool: drop a standalone routing anchor at the click. If the
// click landed on a cable (slice 4 will detect this), the clamp will
// also be attached to that cable mid-segment. For slice 3 we just
// place it.
async function placeClampAt(p, e) {
if (!state.active) return;
armTool(null);
// Did the click hit a cable? If so, attach the new clamp to that cable.
const cableEl = e && e.target instanceof Element
? e.target.closest("[data-cable-id]")
: null;
const cableID = cableEl ? Number(cableEl.getAttribute("data-cable-id")) : null;
const frame = frameAt(p.x, p.y);
try {
const c = await createClamp(state.active.id, {
x: p.x, y: p.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(c);
if (cableID) {
try {
const cc = await attachClampToCable(state.active.id, cableID, { clamp_id: c.id });
state.cableClamps.push(cc);
} catch (ex) {
alert(`Attach to cable failed: ${ex.message}`);
}
}
state.selection = { kind: "clamp", id: c.id };
render();
} catch (err) {
alert(`Create clamp failed: ${err.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 = `
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
<input type="text" placeholder="${placeholder}" />
</div>
`;
$("#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|Clamp|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);
else if (kind === "clamp") obj = state.clamps.find((c) => c.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + IO markers + clamps
// + 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}[]} */ ([]);
let trackedClamps = /** @type {{c: Clamp, 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 });
}
}
for (const c of state.clamps) {
if (c.frame_id === obj.id) {
trackedClamps.push({ c, sx: c.x, sy: c.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; }
for (const t of trackedClamps) { t.c.x = t.sx + dx; t.c.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 + clamps 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 })),
...trackedClamps.map((t) =>
patchClamp(state.active.id, t.c.id, { x: t.c.x, y: t.c.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 if (kind === "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);
} else /* clamp */ {
const c = /** @type {Clamp} */ (obj);
const targetFrame = frameAt(c.x, c.y);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: c.x, y: c.y };
if ((c.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
c.frame_id = newFrameID;
}
await patchClamp(state.active.id, c.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 = `<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 {
// 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 `
<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>
`;
}
// ---------- 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 → <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`,
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) => `
<div class="admin-row" data-project-id="${p.id}">
<div class="admin-row-title">
<span>${escapeHtml(p.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${p.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(p.name)}" />
</label>
<label class="field"><span>Drawing name</span>
<input class="adm-drawing" type="text" value="${escapeHtml(p.drawing_name)}" />
</label>
<label class="field"><span>Description</span>
<textarea class="adm-desc" rows="2">${escapeHtml(p.description ?? "")}</textarea>
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete…</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No projects.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
Rename, retitle the drawing, or change the description. Delete cascades all frames /
devices / cables / etc. in the project (cable types are global and unaffected).
</p>
${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) => `
<div class="admin-row" data-cable-type-id="${t.id}">
<div class="admin-row-title">
<span><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Colour</span>
<input class="adm-color" type="color" value="${t.color}" />
</label>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No cable types.</p>`;
body.innerHTML = `
<p class="banner" style="margin:0 0 12px 0;">
Cable types are <strong>global</strong> — renaming or recolouring affects every project.
</p>
${rows}
<div class="admin-add-row">
<div class="admin-row-title"><span>New cable type</span></div>
<label class="field"><span>Name</span>
<input id="adm-ct-new-name" type="text" placeholder="e.g. SATA" />
</label>
<label class="field"><span>Colour</span>
<input id="adm-ct-new-color" type="color" value="#1971c2" />
</label>
<div class="actions">
<button type="button" class="btn btn-primary btn-tiny" id="adm-ct-new-create">+ Add</button>
</div>
</div>
`;
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 = `
<p class="admin-empty">
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).
</p>`;
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) =>
`<li><span class="swatch" style="background:${cableTypeColor.get(p.cable_type_id) || "#888"}"></span>` +
`${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} <span class="muted">(${escapeHtml(p.edge)})</span></li>`,
).join("");
const builtIns = state.deviceTypes.filter((t) => t.built_in);
const customs = state.deviceTypes.filter((t) => !t.built_in);
const builtRows = builtIns.map((t) => `
<div class="admin-row locked" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}
<span class="muted" style="font-weight:normal;font-size:11px;">· ${escapeHtml(t.kind || "")}</span>
</span>
<span class="locked-badge">built-in</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<ul class="port-profile-list">${portsLine(t.ports || [])}</ul>
</div>
`).join("");
const customRows = customs.map((t) => `
<div class="admin-row" data-device-type-id="${t.id}">
<div class="admin-row-title">
<span>${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)}</span>
<span style="color: var(--text-muted); font-size: 11px;">#${t.id}</span>
</div>
<label class="field"><span>Name</span>
<input class="adm-name" type="text" value="${escapeHtml(t.name)}" />
</label>
<label class="field"><span>Kind</span>
<input class="adm-kind" type="text" value="${escapeHtml(t.kind || "")}" />
</label>
<label class="field"><span>Icon</span>
<input class="adm-icon" type="text" value="${escapeHtml(t.icon || "")}" />
</label>
<label class="field"><span>Description</span>
<input class="adm-desc" type="text" value="${escapeHtml(t.description || "")}" />
</label>
<ul class="port-profile-list">${portsLine(t.ports || []) || '<li class="muted">no port profile</li>'}</ul>
<div class="actions">
<button type="button" class="btn btn-tiny adm-save">Save</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`).join("") || `<p class="admin-empty">No project-custom types yet.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
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.
</p>
<h3 style="margin:8px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Built-in (${builtIns.length})</h3>
${builtRows}
<h3 style="margin:16px 0 4px 0;font-size:12px;text-transform:uppercase;color:var(--text-muted);">Project-custom (${customs.length})</h3>
${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) =>
`<li>${escapeHtml(d.suggested_name ?? dt(d))} <span class="muted">(${escapeHtml(dt(d))})</span></li>`,
).join("") || `<li class="muted">no devices</li>`;
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 `<li>${escapeHtml(an)}${escapeHtml(bn)} <span class="muted">· ${escapeHtml(ct ?? "solver picks")} · ${tag}</span></li>`;
}).join("") || `<li class="muted">no requirements</li>`;
return `
<div class="admin-row locked" data-template-id="${t.id}">
<div class="admin-row-title">
<span>${escapeHtml(t.name)}</span>
<span class="locked-badge">${t.built_in ? "built-in" : "custom"}</span>
</div>
<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(t.description || "")}</p>
<div class="tmpl-detail">
<strong>Devices (${t.devices.length})</strong>
<ul>${devsHtml}</ul>
</div>
<div class="tmpl-detail">
<strong>Requirements (${t.requirements.length})</strong>
<ul>${reqsHtml}</ul>
</div>
</div>
`;
}).join("") || `<p class="admin-empty">No setup templates.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 8px 0;">
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.
</p>
${rows}
`;
}
// ---------- admin: requirements (all) ---------- //
function renderAdminRequirements(body) {
if (!state.active) {
body.innerHTML = `<p class="admin-empty">Pick a project to see its requirements.</p>`;
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 `
<div class="admin-row" data-req-id="${r.id}">
<div class="admin-row-title">
<span>
${escapeHtml(a?.name ?? "?")}${escapeHtml(b?.name ?? "?")}
<span class="muted" style="font-weight:normal;font-size:11px;"> · ${escapeHtml(ct?.name ?? "solver picks")}</span>
<span class="locked-badge" style="margin-left:6px;">${r.must_connect ? "must" : "nice"}</span>
</span>
<span style="color: var(--text-muted); font-size: 11px;">#${r.id}</span>
</div>
${r.notes ? `<p class="muted" style="font-size:12px;margin:0;">${escapeHtml(r.notes)}</p>` : ""}
<div class="actions">
<button type="button" class="btn btn-tiny adm-edit">Edit</button>
<button type="button" class="btn btn-danger btn-tiny adm-delete">Delete</button>
</div>
</div>
`;
}).join("") || `<p class="admin-empty">No requirements yet.</p>`;
body.innerHTML = `
<p class="muted" style="font-size:12px;margin:0 0 12px 0;">
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.
</p>
${rows}
<div class="admin-add-row">
<button type="button" class="btn btn-tiny" id="adm-req-new"
${state.devices.length < 2 ? "disabled" : ""}>+ Add requirement</button>
${state.devices.length < 2
? '<span class="muted" style="margin-left:8px;">(needs ≥ 2 devices)</span>'
: ""}
</div>
`;
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-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();