// 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 = `Nothing selected.
`;
return;
}
switch (state.selection.kind) {
case "frame": return renderInspectorFrame(body, state.selection.id);
case "device": return renderInspectorDevice(body, state.selection.id);
case "io": return renderInspectorIO(body, state.selection.id);
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
default: body.innerHTML = `Nothing selected.
`;
}
}
function renderInspectorCable(body, id) {
const c = state.cables.find((x) => x.id === id);
if (!c) { body.innerHTML = ""; return; }
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
const ct = state.cableTypes.find((t) => t.id === c.type_id);
function endpointLabel(portID, deviceID, ioID) {
if (portID != null) {
const p = portByID.get(portID);
if (!p) return "(missing port)";
const d = deviceByID.get(p.device_id);
return `${d?.name ?? "?"} · ${p.label ?? "port"}`;
}
if (deviceID != null) {
const d = deviceByID.get(deviceID);
return d?.name ?? "(missing device)";
}
if (ioID != null) {
const m = ioByID.get(ioID);
return m?.label ?? "(missing IO)";
}
return "?";
}
const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id);
const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id);
// Find the driving requirement (auto cable only) — match by
// unordered device pair + (cable type or null).
let drivingReq = null;
if (c.auto) {
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
if (fromDev != null && toDev != null) {
drivingReq = state.requirements.find((r) => {
const same = (r.from_device_id === fromDev && r.to_device_id === toDev)
|| (r.from_device_id === toDev && r.to_device_id === fromDev);
if (!same) return false;
if (r.preferred_cable_type_id == null) return true; // solver-picked match
return r.preferred_cable_type_id === c.type_id;
});
}
}
body.innerHTML = `
Cable ${c.auto ? "(solver)" : "(manual)"}
- type
- from
- to
- driver
${c.auto ? `` : ""}
`;
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 = `
Frame
- x
- y
- w
- h
- devices
- IO
`;
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) => `
${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}
`).join("")
: `No ports yet.
`;
// Requirements involving this device — sorted as (other-device-name asc).
const involved = state.requirements.filter((r) => r.from_device_id === d.id || r.to_device_id === d.id);
const deviceById = new Map(state.devices.map((x) => [x.id, x]));
involved.sort((a, b) => {
const oa = (a.from_device_id === d.id ? a.to_device_id : a.from_device_id);
const ob = (b.from_device_id === d.id ? b.to_device_id : b.from_device_id);
return (deviceById.get(oa)?.name || "").localeCompare(deviceById.get(ob)?.name || "");
});
const reqsHtml = involved.length
? involved.map((r) => {
const other = (r.from_device_id === d.id ? r.to_device_id : r.from_device_id);
const otherName = deviceById.get(other)?.name ?? `device #${other}`;
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : null;
return `
↔ ${escapeHtml(otherName)}
· ${escapeHtml(ct ?? "solver picks")}
${r.must_connect ? "must" : "nice"}
`;
}).join("")
: `No requirements yet.
`;
body.innerHTML = `
Device
- type
- x
- y
- w
- h
- frame
Ports
${portsHtml}
Requirements
${reqsHtml}
`;
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 = `
Connection requirement
- from
- to
- cable
- type
- ${r.must_connect ? "must connect" : "nice to have"}
`;
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 = `
IO marker
- x
- y
- frame
Wall-outlet terminator. Power-by-convention; a future cable terminating
here means "plugged into a socket outside the diagram".
`;
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}`));
});
}
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 = `
Cable types are shared across all projects. Renaming or recolouring
affects every project.
`;
body.innerHTML = `
Cable type
${banner}
- used by
`;
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 = `
${escapeHtml(a.name)} ↔ ${escapeHtml(b.name)}
· ${escapeHtml(cableLabel)}
${r.must_connect ? "must" : "nice"}
`;
li.addEventListener("click", () => {
state.selection = { kind: "requirement", id: r.id };
render();
});
ul.append(li);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[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: