Files
CableGUI/web/static/main.js
mAi b28fc0c565 fix(ui): even-spacing relayout on every port-set change
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.

- New portEdge(port, dev) — snaps a port's current offsets to the
  nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
  device-edge and PATCHes the ones whose offsets actually change.
  Sort key: x_offset for top/bottom, y_offset for left/right —
  preserves m's "I dropped it roughly here" order.

Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
  centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
  survivors collapse back to even spacing.

snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
2026-05-16 11:19:16 +02:00

2412 lines
91 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.

// mCables 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 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: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
tool: /** @type {string|null} */ (null),
/** Slice-7 transient state for the +Port tool. */
portToolDevice: /** @type {number|null} */ (null),
portToolTypeID: /** @type {number|null} */ (null),
/** 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", 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 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 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`, {});
// ---------- 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());
}
// ---------- 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 gIO = $("#canvas-io");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gCables.innerHTML = "";
gIO.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);
gFrames.append(g);
rect.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 });
const rect = svgEl("rect", {
x: d.x, y: d.y, width: d.width, height: d.height,
class: "device-rect svg-draggable",
stroke: d.color,
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));
g.append(c);
}
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);
});
}
// Cables — straight lines between resolved endpoint anchors.
// Auto-cables render with dashed stroke so m sees which the solver
// placed; manual cables are solid.
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
for (const c of state.cables) {
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
if (!fromAnchor || !toAnchor) continue;
const color = cableTypeColor.get(c.type_id) || "#888";
const line = svgEl("line", {
x1: fromAnchor.x, y1: fromAnchor.y,
x2: toAnchor.x, y2: toAnchor.y,
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
stroke: color,
"data-cable-id": c.id,
});
line.addEventListener("click", (e) => {
e.stopPropagation();
state.selection = { kind: "cable", id: c.id };
render();
});
gCables.append(line);
}
}
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
* the referenced row has gone missing (rare, but possible mid-edit). */
function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) {
if (portID != null) {
const p = portByID.get(portID);
if (!p) return null;
const d = deviceByID.get(p.device_id);
if (!d) return null;
return { x: d.x + p.x_offset, y: d.y + p.y_offset };
}
if (deviceID != null) {
const d = deviceByID.get(deviceID);
if (!d) return null;
return { x: d.x + d.width / 2, y: d.y + d.height / 2 };
}
if (ioID != null) {
const m = ioByID.get(ioID);
if (!m) return null;
return { x: m.x + IO_SIZE / 2, y: m.y + IO_SIZE / 2 };
}
return null;
}
function renderInspector() {
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);
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">
<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();
});
});
// +Port — arms the port-placement gesture. Active cable type comes
// from the legend selection; if none, defaults to the first cable_type.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
armPortTool(d.id, typeID);
});
// 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}`));
});
}
// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
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 ct = state.cableTypes.find((t) => t.id === prt.type_id);
const ctColor = ct?.color || "#888";
const ctName = ct?.name || "?";
const currentEdge = portEdge(prt, dev);
body.innerHTML = `
<p class="section-title">Port</p>
<dl>
<dt>device</dt><dd>${dev.name}</dd>
<dt>type</dt>
<dd><span class="swatch" style="background:${ctColor}"></span>${ctName}</dd>
</dl>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</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>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-edge").value = currentEdge;
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 };
}
}
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();
renderRequirements();
renderCanvas();
renderEmptyHint();
renderInspector();
}
// ---------- requirements sidebar ---------- //
function renderRequirements() {
const ul = $("#requirement-list");
ul.innerHTML = "";
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
const cableTypeById = new Map(state.cableTypes.map((t) => [t.id, t]));
for (const r of state.requirements) {
const a = deviceById.get(r.from_device_id);
const b = deviceById.get(r.to_device_id);
if (!a || !b) continue; // a device delete cascade — UI will rerender soon
const ct = r.preferred_cable_type_id != null ? cableTypeById.get(r.preferred_cable_type_id) : null;
const li = document.createElement("li");
li.className = "requirement-row";
li.dataset.id = String(r.id);
if (state.selection?.kind === "requirement" && state.selection.id === r.id) {
li.setAttribute("aria-current", "true");
}
const cableLabel = ct ? `${ct.name}` : "solver picks";
li.innerHTML = `
<span class="pair">
${escapeHtml(a.name)}${escapeHtml(b.name)}
<span class="type"> · ${escapeHtml(cableLabel)}</span>
</span>
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
`;
li.addEventListener("click", () => {
state.selection = { kind: "requirement", id: r.id };
render();
});
ul.append(li);
}
}
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.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.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 = [];
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-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
if (tool !== "port") {
state.portToolDevice = null;
state.portToolTypeID = null;
}
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
/** Slice 7: device inspector arms +Port for a specific device + type. */
function armPortTool(deviceID, typeID) {
state.portToolDevice = deviceID;
state.portToolTypeID = typeID;
armTool("port");
}
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 === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
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 === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal();
});
// Canvas-level pointerdown handles tool activation + selection clearing.
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
}
let rubberBand = /** @type {SVGRectElement|null} */ (null);
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
function onCanvasPointerDown(e) {
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 === "port") {
e.preventDefault();
placePortAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
return;
}
// No tool armed: clicks that started on a device/frame/io go to their
// own handlers (drag / select). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return;
// Plain canvas click = clear selection.
if (state.selection) { state.selection = null; render(); }
}
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");
}
};
}
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
* y_off) relative to the device's top-left + a debug-friendly edge name. */
function snapToDeviceEdge(device, x, y) {
// Distance from the point to each of the four edges.
const dxLeft = Math.abs(x - device.x);
const dxRight = Math.abs((device.x + device.width) - x);
const dyTop = Math.abs(y - device.y);
const dyBottom = Math.abs((device.y + device.height) - y);
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
// Clamp the perpendicular coordinate so the port sits *on* the rect.
const localX = Math.max(0, Math.min(device.width, x - device.x));
const localY = Math.max(0, Math.min(device.height, y - device.y));
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
return { xOff: localX, yOff: device.height, edge: "bottom" };
}
// 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 using
// the same distance heuristic as snapToDeviceEdge.
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}`);
}
}
}
/** 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();
}
}
async function placePortAt(p) {
if (!state.active) return;
const did = state.portToolDevice;
const tid = state.portToolTypeID;
if (did == null || tid == null) { armTool(null); return; }
const dev = state.devices.find((d) => d.id === did);
if (!dev) { armTool(null); return; }
const snap = snapToDeviceEdge(dev, p.x, p.y);
try {
const port = await createPort(state.active.id, did, {
type_id: tid,
x_offset: snap.xOff,
y_offset: snap.yOff,
});
state.ports.push(port);
// Re-layout all ports on this edge so the new one + existing ones
// are evenly spaced — m's invariant: never let two ports stack.
await relayoutEdge(did, snap.edge);
// Select the freshly-placed port so the inspector switches to the
// port panel (edge dropdown / label / delete) and the .selected halo
// marks it.
state.selection = { kind: "port", id: port.id };
armTool(null);
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
armTool(null);
}
}
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|undefined} */
let obj;
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + IO markers + their
// offsets so they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
trackedDevices.push({ d, sx: d.x, sy: d.y });
}
}
for (const m of state.ioMarkers) {
if (m.frame_id === obj.id) {
trackedIOs.push({ m, sx: m.x, sy: m.y });
}
}
}
// Capture the rect element NOW: by the time onUp fires async, the
// browser has nulled out e.currentTarget on the pointerdown event,
// so `e.currentTarget.classList.remove("dragging")` would throw
// "Cannot read properties of null". Sherlock surfaced this from the
// click-only path that pageerror-spammed every device click.
const dragTarget = /** @type {Element} */ (e.currentTarget);
dragTarget.classList.add("dragging");
svg.setPointerCapture(e.pointerId);
let dragged = false;
const onMove = (ev) => {
const p = svgPoint(ev);
const dx = p.x - start.x;
const dy = p.y - start.y;
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
obj.x = startX + dx;
obj.y = startY + dy;
if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
}
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.releasePointerCapture(e.pointerId);
dragTarget.classList.remove("dragging");
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
if (!state.active) return;
try {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices + IO markers too.
await Promise.all([
...trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
...trackedIOs.map((t) =>
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
]);
} else if (kind === "device") {
const d = /** @type {Device} */ (obj);
// Recompute frame_id from drop point (centre of device).
const cx = d.x + d.width / 2;
const cy = d.y + d.height / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: d.x, y: d.y };
if ((d.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID; // explicit null = clear
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
} else /* io */ {
const m = /** @type {IOMarker} */ (obj);
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: m.x, y: m.y };
if ((m.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
m.frame_id = newFrameID;
}
await patchIOMarker(state.active.id, m.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);
}
render();
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
}
// ---------- modals (project / cable type) ---------- //
function bindCloseButtons(dialog) {
dialog.querySelectorAll("[data-close]").forEach((btn) =>
btn.addEventListener("click", () => dialog.close()),
);
}
function showError(el, msg) {
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
el.textContent = msg;
setHidden(el, false);
}
function openNewProjectModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
const err = $("#np-error");
form.reset();
showError(err, "");
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
drawing_name: String(fd.get("drawing_name") || "").trim(),
description: String(fd.get("description") || ""),
};
if (!body.drawing_name) delete body.drawing_name;
try {
const p = await createProject(body);
state.projects = await listProjects();
dlg.close();
await activateProject(p.id);
} catch (e) {
showError(err, e.message || "Failed to create project");
}
};
}
function openCableTypeModal(existing) {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
const err = $("#ct-error");
const title = $("#ct-title");
form.reset();
showError(err, "");
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
if (existing) {
form.elements.namedItem("name").value = existing.name;
form.elements.namedItem("color").value = existing.color;
} else {
form.elements.namedItem("color").value = "#1971c2";
}
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn btn-danger btn-delete-type";
del.style.marginRight = "auto";
del.textContent = "Delete";
del.addEventListener("click", async () => {
try {
await deleteCableType(existing.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === existing.id) state.activeTypeId = null;
dlg.close();
render();
} catch (e) {
const n = e.details?.in_use_by_cables;
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
}
});
actions.prepend(del);
}
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
color: String(fd.get("color") || "").trim(),
};
try {
if (existing) await patchCableType(existing.id, body);
else await createCableType(body);
state.cableTypes = await listCableTypes();
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Save failed");
}
};
}
function openDeleteProjectModal() {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
const err = $("#dp-error");
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
form.reset();
showError(err, "");
input.placeholder = state.active.name;
dlg.showModal();
input.focus();
form.onsubmit = async (e) => {
e.preventDefault();
const confirm = String(new FormData(form).get("confirm") || "");
try {
await deleteProject(state.active.id, confirm);
state.projects = await listProjects();
dlg.close();
await activateProject(null);
} catch (e) {
showError(err, e.message || "Delete failed");
}
};
}
// ---------- solve flow ---------- //
function openSolveModal() {
if (!state.active) { alert("Pick a project first"); return; }
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve"));
const body = $("#sv-body");
body.innerHTML = `<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;
}
}
// ---------- 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"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-add-requirement").addEventListener("click", () => {
if (!state.active) { alert("Pick a project first"); return; }
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
openRequirementModal(null);
});
$("#btn-solve").addEventListener("click", openSolveModal);
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
$("#btn-export").addEventListener("click", exportCurrentProject);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
activateProject(v ? Number(v) : null);
});
bindTools();
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();