// 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" | "cable" | null */ tool: /** @type {string|null} */ (null), /** Canvas viewport — drives the SVG viewBox. */ view: { x: 0, y: 0, zoom: 1 }, /** Space-key held → next pointerdown anywhere on canvas starts a pan. */ spaceHeld: false, /** Slice-7: when the user clicked a source port, this is its id. */ cableDrawFromPortID: /** @type {number|null} */ (null), /** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null, }; // ---------- API client ---------- // async function api(method, path, body) { const res = await fetch(API + path, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 204) return null; const text = await res.text(); const json = text ? JSON.parse(text) : null; if (!res.ok) { const err = new Error(json?.error || res.statusText); err.status = res.status; err.details = json?.details; throw err; } return json; } const listProjects = () => api("GET", "/projects"); const createProject = (body) => api("POST", "/projects", body); const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body); const deleteProject = (id, confirm) => api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`); const getSnapshot = (id) => api("GET", `/projects/${id}`); const listCableTypes = () => api("GET", "/cable-types"); const createCableType = (body) => api("POST", "/cable-types", body); const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body); const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`); const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body); const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body); const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`); const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body); const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body); const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`); const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers`, body); const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body); const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`); const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body); const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body); const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`); const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body); const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`); const createDeviceType = (pid, body) => api("POST", `/projects/${pid}/device-types`, body); const patchDeviceType = (pid, id, body) => api("PATCH", `/projects/${pid}/device-types/${id}`, body); const deleteDeviceType = (pid, id) => api("DELETE", `/projects/${pid}/device-types/${id}`); const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body); const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body); const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`); const patchCable = (pid, id, body) => api("PATCH", `/projects/${pid}/cables/${id}`, body); const deleteCable = (pid, id) => api("DELETE", `/projects/${pid}/cables/${id}`); const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${preview ? "?preview=1" : ""}`, {}); const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body); const listSetupTemplates = () => api("GET", `/setup-templates`); const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body); const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {}); // ---------- DOM helpers ---------- // const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); function setHidden(el, hidden) { if (hidden) el.setAttribute("hidden", ""); else el.removeAttribute("hidden"); } function svgEl(name, attrs = {}) { const el = document.createElementNS(SVG_NS, name); for (const [k, v] of Object.entries(attrs)) { if (v == null) continue; el.setAttribute(k, String(v)); } return el; } // ---------- URL state ---------- // function activeProjectIdFromURL() { const raw = new URLSearchParams(location.search).get("project"); const id = raw && Number.parseInt(raw, 10); return Number.isFinite(id) && id > 0 ? id : null; } function setActiveInURL(id) { const url = new URL(location.href); if (id == null) url.searchParams.delete("project"); else url.searchParams.set("project", String(id)); history.replaceState(null, "", url.toString()); } // ---------- canvas view (zoom + pan) ---------- // const BASE_W = 2000, BASE_H = 1500; const ZOOM_MIN = 0.2, ZOOM_MAX = 5; function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); } function applyViewBox() { const z = state.view.zoom; const vw = BASE_W / z; const vh = BASE_H / z; $("#canvas").setAttribute("viewBox", `${state.view.x} ${state.view.y} ${vw} ${vh}`); } function updateZoomUI() { const el = $("#zoom-pct"); if (el) el.textContent = `${Math.round(state.view.zoom * 100)}%`; } function viewFromURL() { const p = new URLSearchParams(location.search); const z = parseFloat(p.get("z")); const px = parseFloat(p.get("px")); const py = parseFloat(p.get("py")); if (Number.isFinite(z) && z > 0) state.view.zoom = clampZoom(z); if (Number.isFinite(px)) state.view.x = px; if (Number.isFinite(py)) state.view.y = py; } function setViewInURL() { const url = new URL(location.href); const isDefault = state.view.zoom === 1 && state.view.x === 0 && state.view.y === 0; if (isDefault) { url.searchParams.delete("z"); url.searchParams.delete("px"); url.searchParams.delete("py"); } else { url.searchParams.set("z", state.view.zoom.toFixed(3)); url.searchParams.set("px", state.view.x.toFixed(1)); url.searchParams.set("py", state.view.y.toFixed(1)); } history.replaceState(null, "", url.toString()); } function wheelZoom(e) { e.preventDefault(); const before = svgPoint(e); const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const newZoom = clampZoom(state.view.zoom * factor); if (newZoom === state.view.zoom) return; state.view.zoom = newZoom; applyViewBox(); const after = svgPoint(e); // recomputed against the new viewBox state.view.x += before.x - after.x; state.view.y += before.y - after.y; applyViewBox(); updateZoomUI(); setViewInURL(); } function startPan(e) { const svg = /** @type {SVGSVGElement} */ ($("#canvas")); const ctm = svg.getScreenCTM(); if (!ctm) return; e.preventDefault(); e.stopPropagation(); $(".canvas-wrap").classList.add("panning"); // ctm.a / ctm.d are the world→screen scales. world delta = screen delta / scale. const scaleX = ctm.a, scaleY = ctm.d; const startClientX = e.clientX, startClientY = e.clientY; const startViewX = state.view.x, startViewY = state.view.y; try { svg.setPointerCapture(e.pointerId); } catch {} const onMove = (ev) => { state.view.x = startViewX - (ev.clientX - startClientX) / scaleX; state.view.y = startViewY - (ev.clientY - startClientY) / scaleY; applyViewBox(); }; const onUp = (ev) => { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.removeEventListener("pointercancel", onUp); try { svg.releasePointerCapture(ev.pointerId); } catch {} $(".canvas-wrap").classList.remove("panning"); setViewInURL(); }; svg.addEventListener("pointermove", onMove); svg.addEventListener("pointerup", onUp); svg.addEventListener("pointercancel", onUp); } function resetView() { state.view.zoom = 1; state.view.x = 0; state.view.y = 0; applyViewBox(); updateZoomUI(); setViewInURL(); } // Compute the bbox of every frame + device + IO marker in the current // project and frame it into the view with a small padding. Falls back // to reset when the project is empty. function fitToContent() { if (!state.active) return resetView(); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let any = false; const cover = (x, y, w, h) => { any = true; if (x < minX) minX = x; if (y < minY) minY = y; if (x + w > maxX) maxX = x + w; if (y + h > maxY) maxY = y + h; }; for (const f of state.frames) cover(f.x, f.y, f.width, f.height); for (const d of state.devices) cover(d.x, d.y, d.width, d.height); for (const m of state.ioMarkers) cover(m.x, m.y, IO_SIZE, IO_SIZE); if (!any) return resetView(); const pad = 40; minX -= pad; minY -= pad; maxX += pad; maxY += pad; const bw = maxX - minX, bh = maxY - minY; const zoom = clampZoom(Math.min(BASE_W / bw, BASE_H / bh)); const vw = BASE_W / zoom, vh = BASE_H / zoom; // Centre the bbox inside the (potentially larger) viewBox. state.view.zoom = zoom; state.view.x = minX - (vw - bw) / 2; state.view.y = minY - (vh - bh) / 2; applyViewBox(); updateZoomUI(); setViewInURL(); } // ---------- geometry ---------- // /** Returns the smallest frame whose bbox contains (x, y), or null. */ function frameAt(x, y) { /** @type {Frame|null} */ let best = null; let bestArea = Infinity; for (const f of state.frames) { if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue; const a = f.width * f.height; if (a < bestArea) { best = f; bestArea = a; } } return best; } /** Convert a pointer event to SVG-canvas coordinates. */ function svgPoint(evt) { const svg = /** @type {SVGSVGElement} */ ($("#canvas")); const pt = svg.createSVGPoint(); pt.x = evt.clientX; pt.y = evt.clientY; const ctm = svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 }; const local = pt.matrixTransform(ctm.inverse()); return { x: local.x, y: local.y }; } // ---------- render ---------- // function renderProjectPicker() { const sel = /** @type {HTMLSelectElement} */ ($("#project-select")); const current = state.active?.id ?? ""; sel.innerHTML = ""; sel.append(new Option("— pick a project —", "")); for (const p of state.projects) { const opt = new Option(p.name, String(p.id)); if (p.id === current) opt.selected = true; sel.append(opt); } setHidden($("#btn-delete-project"), !state.active); } function renderLegend() { const ul = $("#legend-list"); ul.innerHTML = ""; for (const t of state.cableTypes) { const li = document.createElement("li"); li.className = "legend-row"; li.dataset.id = String(t.id); if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true"); li.innerHTML = ` `; 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 }); // Stroke = the user-picked colour; fill = a 12% tint of it via // color-mix so the device "reads" coloured without becoming garish. // Inline style beats the .device-rect class CSS, which is why CSS // no longer hard-codes stroke/fill on that class. const rect = svgEl("rect", { x: d.x, y: d.y, width: d.width, height: d.height, class: "device-rect svg-draggable", style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`, rx: 3, ry: 3, }); if (state.selection?.kind === "device" && state.selection.id === d.id) { rect.classList.add("selected"); } const label = svgEl("text", { x: d.x + d.width / 2, y: d.y + d.height / 2, class: "device-label", }); label.textContent = d.name; g.append(rect, label); // Render ports as small circles at (device.x + x_offset, device.y + y_offset). // Both fill and stroke = cable_type colour so the port is obviously coloured // against the device rect. const ports = portsByDevice.get(d.id) || []; for (const prt of ports) { const cx = d.x + prt.x_offset; const cy = d.y + prt.y_offset; const color = cableTypeColor.get(prt.type_id) || "#888"; const isCableFrom = state.cableDrawFromPortID === prt.id; const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id; const cls = "port-circle" + (isCableFrom ? " cable-from" : "") + (isSelected ? " selected" : ""); const c = svgEl("circle", { cx, cy, r: 5, class: cls, fill: color, stroke: color, "data-port-id": prt.id, }); // Port-click drives both cable-draw (slice 7) and port-select (this fix). c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt)); // Double-click activates cable-draw mode from this port without arming // the cable tool first. armTool("cable") gives the crosshair cursor; // the next port-click is then caught by onPortPointerDown's // cable-draw-in-progress branch and commits the cable. c.addEventListener("dblclick", (e) => { e.stopPropagation(); e.preventDefault(); if (state.tool !== "cable") armTool("cable"); state.cableDrawFromPortID = prt.id; state.selection = null; render(); }); g.append(c); } // Bottom-right resize handle. Drawn last so it sits on top of the rect // and any port circles that might overlap the corner. Visible always // but subtle; cursor signals resize affordance. const HSZ = 10; const handle = svgEl("rect", { x: d.x + d.width - HSZ, y: d.y + d.height - HSZ, width: HSZ, height: HSZ, class: "device-resize-handle", "data-device-id": d.id, }); handle.addEventListener("pointerdown", (e) => startResize(e, d.id)); g.append(handle); gDevices.append(g); rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id)); } for (const m of state.ioMarkers) { const g = svgEl("g", { "data-io-id": m.id }); // Diamond = a square rotated 45° around its centre. Using a // 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) { let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID); let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID); if (!fromAnchor || !toAnchor) continue; // Replug preview: while m drags an endpoint handle, override the // affected end with the live cursor world position so the line // tracks the pointer. if (cableReplug && cableReplug.cableID === c.id) { if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y }; else toAnchor = { x: cableReplug.x, y: cableReplug.y }; } const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id; 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" : "") + (isSelected ? " 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); // Endpoint handles — only on the currently-selected cable. Two small // filled circles m can grab to drag the endpoint onto a new target. if (isSelected) { for (const end of ["from", "to"]) { const a = end === "from" ? fromAnchor : toAnchor; const h = svgEl("circle", { cx: a.x, cy: a.y, r: 7, class: "cable-handle", fill: color, stroke: "#fff", "data-cable-id": c.id, "data-end": end, }); h.addEventListener("pointerdown", (e) => startCableReplug(e, c.id, end)); gCables.append(h); } } } } /** 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); case "port": return renderInspectorPort(body, state.selection.id); case "port_new": return renderInspectorPortNew(body, state.selection.device_id); default: body.innerHTML = `

Nothing selected.

`; } } function renderInspectorCable(body, id) { const c = state.cables.find((x) => x.id === id); if (!c) { body.innerHTML = ""; return; } const deviceByID = new Map(state.devices.map((d) => [d.id, d])); const portByID = new Map(state.ports.map((p) => [p.id, p])); const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m])); const ct = state.cableTypes.find((t) => t.id === c.type_id); function endpointLabel(portID, deviceID, ioID) { if (portID != null) { const p = portByID.get(portID); if (!p) return "(missing port)"; const d = deviceByID.get(p.device_id); return `${d?.name ?? "?"} · ${p.label ?? "port"}`; } if (deviceID != null) { const d = deviceByID.get(deviceID); return d?.name ?? "(missing device)"; } if (ioID != null) { const m = ioByID.get(ioID); return m?.label ?? "(missing IO)"; } return "?"; } const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id); const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id); // Find the driving requirement (auto cable only) — match by // unordered device pair + (cable type or null). let drivingReq = null; if (c.auto) { const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id; const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id; if (fromDev != null && toDev != null) { drivingReq = state.requirements.find((r) => { const same = (r.from_device_id === fromDev && r.to_device_id === toDev) || (r.from_device_id === toDev && r.to_device_id === fromDev); if (!same) return false; if (r.preferred_cable_type_id == null) return true; // solver-picked match return r.preferred_cable_type_id === c.type_id; }); } } body.innerHTML = `

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(); }); }); // + Requirement — open the modal pre-filled with this device as the // "from" endpoint. Refuses if the project has fewer than 2 devices // (a requirement needs two distinct endpoints). body.querySelector("#dev-add-req").addEventListener("click", () => { if (!state.active) return; if (state.devices.length < 2) { alert("Add a second device before declaring a requirement."); return; } openRequirementModal(null, { from: d.id }); }); // +Port — switch the inspector to the new-port form. m fills in // type + edge + label and clicks Create; no canvas click required. body.querySelector("#dev-add-port").addEventListener("click", () => { if (!state.active) return; state.selection = { kind: "port_new", device_id: d.id }; render(); }); // Clicking a port row in the device's port list selects that port // and opens its editor in the inspector pane. body.querySelectorAll(".port-row[data-port-id]").forEach((row) => { row.addEventListener("click", (e) => { if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return; const pid = Number(row.getAttribute("data-port-id")); if (!pid) return; state.selection = { kind: "port", id: pid }; render(); }); }); // Per-port delete. body.querySelectorAll(".port-del").forEach((btn) => { btn.addEventListener("click", async (e) => { e.stopPropagation(); if (!state.active) return; const pid = Number(btn.getAttribute("data-port-id")); if (!pid) return; if (!confirm("Delete this port?")) return; try { await deletePort(state.active.id, pid); state.ports = state.ports.filter((p) => p.id !== pid); // Cables that referenced the port get from_port_id/to_port_id // set to NULL by the schema — refresh from snapshot. const snap = await getSnapshot(state.active.id); state.cables = snap.cables || []; render(); } catch (ex) { alert(`Delete failed: ${ex.message}`); } }); }); } function renderInspectorRequirement(body, id) { const r = state.requirements.find((x) => x.id === id); if (!r) { body.innerHTML = ""; return; } const deviceById = new Map(state.devices.map((d) => [d.id, d])); const a = deviceById.get(r.from_device_id); const b = deviceById.get(r.to_device_id); const ctName = r.preferred_cable_type_id != null ? state.cableTypes.find((t) => t.id === r.preferred_cable_type_id)?.name : null; body.innerHTML = `

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}`)); }); } // Port editor — type / edge / label / delete. m can also navigate back // to the device by clicking "back to device" or anywhere on the device. function renderInspectorPort(body, id) { const prt = state.ports.find((p) => p.id === id); if (!prt) { body.innerHTML = ""; return; } const dev = state.devices.find((d) => d.id === prt.device_id); if (!dev) { body.innerHTML = ""; return; } const currentEdge = portEdge(prt, dev); const typeOptions = state.cableTypes .map((t) => ``) .join(""); body.innerHTML = `

Port

← ${escapeHtml(dev.name)}

`; body.querySelector("#port-type").value = String(prt.type_id); body.querySelector("#port-edge").value = currentEdge; body.querySelector("#port-label").value = prt.label ?? ""; body.querySelector("#port-back-device").addEventListener("click", (e) => { e.preventDefault(); state.selection = { kind: "device", id: dev.id }; render(); }); body.querySelector("#port-type").addEventListener("change", async (e) => { if (!state.active) return; const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value); if (newTypeID === prt.type_id) return; try { const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID }); Object.assign(prt, updated); renderCanvas(); } catch (ex) { alert(`Type change failed: ${ex.message}`); } }); bindDebouncedRename(body.querySelector("#port-label"), async (label) => { if (!state.active) return; const updated = await patchPort(state.active.id, prt.id, { label }); Object.assign(prt, updated); renderCanvas(); }); body.querySelector("#port-edge").addEventListener("change", async (e) => { if (!state.active) return; const newEdge = /** @type {HTMLSelectElement} */ (e.target).value; const oldEdge = portEdge(prt, dev); if (newEdge === oldEdge) return; // PATCH to a temp position on the new edge so portEdge() classifies // this port onto newEdge in the upcoming relayouts. The temp position // gets overwritten by relayoutEdge(newEdge); the only thing that // matters is that the port is unambiguously on the right edge. const tmp = edgeCentre(dev, newEdge); try { const updated = await patchPort(state.active.id, prt.id, { x_offset: tmp.xOff, y_offset: tmp.yOff, }); Object.assign(prt, updated); // Re-space both affected edges: the one the port left and the one // it landed on. Order doesn't matter — they operate on disjoint // port sets. await Promise.all([ relayoutEdge(dev.id, oldEdge), relayoutEdge(dev.id, newEdge), ]); renderCanvas(); } catch (ex) { alert(`Move port failed: ${ex.message}`); } }); body.querySelector("#port-delete").addEventListener("click", async () => { if (!state.active) return; if (!confirm("Delete this port?")) return; const wasEdge = portEdge(prt, dev); try { await deletePort(state.active.id, prt.id); state.ports = state.ports.filter((p) => p.id !== prt.id); const snap = await getSnapshot(state.active.id); state.cables = snap.cables || []; // Re-space the edge the deleted port was on so the survivors // shift back to even spacing. await relayoutEdge(dev.id, wasEdge); state.selection = null; render(); } catch (ex) { alert(`Delete failed: ${ex.message}`); } }); } // Centre of the named edge, expressed as (x_offset, y_offset) relative // to the device origin. Used as a temp anchor when moving a port between // edges — the precise centre value is immediately overwritten by // relayoutEdge, but it has to land on the right edge. function edgeCentre(dev, edge) { switch (edge) { case "top": return { xOff: dev.width / 2, yOff: 0 }; case "right": return { xOff: dev.width, yOff: dev.height / 2 }; case "bottom": return { xOff: dev.width / 2, yOff: dev.height }; case "left": return { xOff: 0, yOff: dev.height / 2 }; default: return { xOff: dev.width / 2, yOff: dev.height }; } } // Compute the next available default label for a new port of `typeID` // on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new // HDMI port gets "HDMI 3". function nextDefaultPortLabel(deviceID, typeID) { const ct = state.cableTypes.find((t) => t.id === typeID); const prefix = ct?.name || "Port"; const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID); let max = 0; for (const p of sibs) { const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$")); if (m) { const n = parseInt(m[1], 10); if (n > max) max = n; } } return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`; } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // "Add port" form. Submit → POST → switch inspector to the new port's // editor. m can cancel back to the device inspector. function renderInspectorPortNew(body, deviceID) { const dev = state.devices.find((d) => d.id === deviceID); if (!dev) { body.innerHTML = ""; return; } if (state.cableTypes.length === 0) { body.innerHTML = `

Add port

No cable types defined. Add one from the legend first.

`; body.querySelector("#port-new-cancel").addEventListener("click", () => { state.selection = { kind: "device", id: dev.id }; render(); }); return; } const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id; const typeOptions = state.cableTypes .map((t) => ``) .join(""); body.innerHTML = `

Add port

← ${escapeHtml(dev.name)}

`; const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type")); const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge")); const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label")); typeSel.value = String(defaultTypeID); labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID); labelInp.placeholder = labelInp.value; // Recompute default label whenever the type changes (only if m hasn't // edited the field). let labelUserEdited = false; labelInp.addEventListener("input", () => { labelUserEdited = true; }); typeSel.addEventListener("change", () => { if (labelUserEdited) return; const tid = Number(typeSel.value); const next = nextDefaultPortLabel(dev.id, tid); labelInp.value = next; labelInp.placeholder = next; }); body.querySelector("#port-new-back").addEventListener("click", (e) => { e.preventDefault(); state.selection = { kind: "device", id: dev.id }; render(); }); body.querySelector("#port-new-cancel").addEventListener("click", () => { state.selection = { kind: "device", id: dev.id }; render(); }); body.querySelector("#port-new-create").addEventListener("click", async () => { const tid = Number(typeSel.value); const edge = edgeSel.value; const label = labelInp.value.trim(); await createPortFromForm(dev.id, tid, edge, label); }); } function renderInspectorCableType(body, id) { const t = state.cableTypes.find((x) => x.id === id); if (!t) { body.innerHTML = ""; return; } // The "used by N cables" counter is purely informational in slice 3. // Slice 7+ will populate state.cables; until then we surface 0. const usedBy = 0; const banner = ` `; 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(); renderCanvas(); renderEmptyHint(); renderInspector(); } 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-cable", tool === "cable"); for (const btn of document.querySelectorAll("[data-tool]")) { btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool); } if (tool !== "cable") { state.cableDrawFromPortID = null; } } function bindTools() { for (const btn of document.querySelectorAll("[data-tool]")) { btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool"))); } document.addEventListener("keydown", (e) => { // Avoid stealing keys while user is typing into an input. const tag = (e.target instanceof HTMLElement) ? e.target.tagName : ""; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; if (e.key === " " && !state.spaceHeld) { // Hold Space to enable click-and-drag pan. Don't preventDefault here // so pressing Space in unrelated focusable elements still works; the // canvas pointerdown handler reads state.spaceHeld to gate the pan. state.spaceHeld = true; $(".canvas-wrap").classList.add("space-pan-ready"); e.preventDefault(); return; } if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); } else if (e.key === "0" || e.key === "Home") resetView(); else if (e.key === "f" || e.key === "F") armTool("frame"); else if (e.key === "d" || e.key === "D") armTool("device"); else if (e.key === "i" || e.key === "I") armTool("io"); else if (e.key === "r" || e.key === "R") armTool("req"); else if (e.key === "s" || e.key === "S") openSolveModal(); }); document.addEventListener("keyup", (e) => { if (e.key === " ") { state.spaceHeld = false; $(".canvas-wrap").classList.remove("space-pan-ready"); } }); // Canvas-level pointerdown handles tool activation + selection clearing. const svg = $("#canvas"); svg.addEventListener("pointerdown", onCanvasPointerDown); // Wheel zooms around the cursor — `passive: false` so we can // preventDefault and stop the page from scrolling. svg.addEventListener("wheel", wheelZoom, { passive: false }); } let rubberBand = /** @type {SVGRectElement|null} */ (null); let rubberStart = /** @type {{x:number,y:number}|null} */ (null); // Live state for a cable-endpoint replug drag. Captured at pointerdown // on a .cable-handle, used by renderCanvas to anchor the dragged end // at the cursor; cleared on pointerup (commit or cancel). let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null); function onCanvasPointerDown(e) { // Pan gestures win over every tool. Middle-click and Space+drag both // route here regardless of project state — m can pan an empty canvas // without selecting a project first. if (e.button === 1 || state.spaceHeld) { startPan(e); return; } if (!state.active) return; const p = svgPoint(e); // Armed tool wins: a click anywhere on the canvas — including on top // of an existing frame or device — fires the tool. The +Dev tool needs // this so m can drop a device inside a frame; without it the frame's // own pointerdown handler would steal the click and start a drag. // // e.preventDefault() suppresses the compatibility mousedown's default // focus-shift. Without it, the freshly-focused inline-namer input gets // blurred ~6ms later by the browser's "focus nearest focusable ancestor // or blur active" behaviour (SVG rects are not focusable), and the // blur handler tears the namer down before m can type. Root cause + // verified fix from sherlock's Playwright shift; see docs/sherlock-+dev-bug.md // for the full trace. if (state.tool === "frame") { e.preventDefault(); startFrameRubberBand(e, p); return; } if (state.tool === "device") { e.preventDefault(); placeDeviceAt(p); return; } if (state.tool === "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: for built-ins grouped by // their `kind`, then project-custom, then a "Custom (no type)" option. sel.innerHTML = ""; const builtIns = state.deviceTypes.filter((t) => t.built_in); const customs = state.deviceTypes.filter((t) => !t.built_in); const byKind = new Map(); for (const t of builtIns) { const k = t.kind || "generic"; const arr = byKind.get(k) || []; arr.push(t); byKind.set(k, arr); } for (const [kind, arr] of byKind) { const og = document.createElement("optgroup"); og.label = kind; for (const t of arr) { const opt = new Option(t.name, String(t.id)); og.append(opt); } sel.append(og); } if (customs.length) { const og = document.createElement("optgroup"); og.label = "custom"; for (const t of customs) { og.append(new Option(t.name, String(t.id))); } sel.append(og); } const customOpt = new Option("Custom (no type)", ""); sel.append(customOpt); // Default to the first built-in (NAS in m's catalog) so m sees a // sensible first option. Auto-fill the name to match. sel.value = builtIns[0] ? String(builtIns[0].id) : ""; syncNameToType(); sel.onchange = syncNameToType; function syncNameToType() { const idStr = sel.value; if (!idStr) { nameInput.value = ""; return; } const t = state.deviceTypes.find((x) => String(x.id) === idStr); if (!t) return; nameInput.value = nextNameFor(t.name); } dlg.showModal(); nameInput.focus(); nameInput.select(); form.onsubmit = async (e) => { e.preventDefault(); const name = nameInput.value.trim(); if (!name) { showError(err, "Name is required"); return; } const idStr = sel.value; const body = { name, x: geom.x, y: geom.y, width: geom.width, height: geom.height, }; if (geom.frame_id != null) body.frame_id = geom.frame_id; if (idStr) body.type_id = Number(idStr); try { const d = await createDevice(state.active.id, body); state.devices.push(d); // Re-fetch ports for the project — the server seeded them in the // same transaction, so they're already in the DB. const snap = await getSnapshot(state.active.id); state.ports = snap.ports || []; state.selection = { kind: "device", id: d.id }; dlg.close(); render(); } catch (e) { showError(err, e.message || "Create failed"); } }; } // Which edge does a given port currently sit on? Snaps the port's // existing (x_offset, y_offset) to the nearest of the four edges. function portEdge(port, device) { const dL = port.x_offset; const dR = device.width - port.x_offset; const dT = port.y_offset; const dB = device.height - port.y_offset; const min = Math.min(dL, dR, dT, dB); if (min === dL) return "left"; if (min === dR) return "right"; if (min === dT) return "top"; return "bottom"; } // Even-spacing layout invariant for ports on a device edge: m wants // every port lined up on its edge with no overlap. After any change // to the set of ports on an edge (add / move / delete), recompute the // offsets so that for N ports they sit at relative positions // i/(N+1) along the edge for i=1..N. // // Sort key preserves m's intent: top/bottom by current x_offset // (left→right), left/right by current y_offset (top→bottom). For a // freshly-placed port, that's the click position projected onto the // edge, so the port keeps its "I dropped it roughly here" rank. // // PATCHes only the ports whose offsets actually change, and updates // state.ports in place. Returns once every PATCH resolves. async function relayoutEdge(deviceID, edge) { if (!state.active) return; const dev = state.devices.find((d) => d.id === deviceID); if (!dev) return; const isHorizontal = edge === "top" || edge === "bottom"; const axis = isHorizontal ? dev.width : dev.height; const peers = state.ports .filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge) .slice() .sort((a, b) => isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset); const n = peers.length; if (n === 0) return; const patches = []; for (let i = 0; i < n; i++) { const parallel = axis * (i + 1) / (n + 1); let xOff, yOff; switch (edge) { case "top": xOff = parallel; yOff = 0; break; case "bottom": xOff = parallel; yOff = dev.height; break; case "left": xOff = 0; yOff = parallel; break; case "right": xOff = dev.width; yOff = parallel; break; } const p = peers[i]; if (p.x_offset === xOff && p.y_offset === yOff) continue; p.x_offset = xOff; p.y_offset = yOff; patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff }) .then((updated) => Object.assign(p, updated))); } if (patches.length) { try { await Promise.all(patches); } catch (err) { alert(`Re-layout failed: ${err.message}`); } } } // Re-space ports on every edge of `deviceID`. Used after the device's // width / height change so all four edges recompute the i/(N+1) // positions against the new dimensions. async function relayoutAllEdges(deviceID) { await Promise.all([ relayoutEdge(deviceID, "top"), relayoutEdge(deviceID, "right"), relayoutEdge(deviceID, "bottom"), relayoutEdge(deviceID, "left"), ]); } // Bottom-right resize handle gesture. Updates width / height in local // state on each move (renderCanvas redraws the rect + ports), clamps to // a minimum so the device can't collapse, then PATCHes the new size on // pointerup and re-spaces every edge's ports. function startResize(e, deviceID) { if (!state.active) return; // Hard-stop so the rect's pointerdown doesn't also fire startDrag. e.stopPropagation(); e.preventDefault(); const d = state.devices.find((x) => x.id === deviceID); if (!d) return; const startWidth = d.width, startHeight = d.height; const startWorld = svgPoint(e); const svg = /** @type {SVGSVGElement} */ ($("#canvas")); try { svg.setPointerCapture(e.pointerId); } catch {} const MIN_W = 60, MIN_H = 30; const onMove = (ev) => { const p = svgPoint(ev); d.width = Math.max(MIN_W, startWidth + (p.x - startWorld.x)); d.height = Math.max(MIN_H, startHeight + (p.y - startWorld.y)); renderCanvas(); }; const onUp = async (ev) => { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.removeEventListener("pointercancel", onUp); try { svg.releasePointerCapture(ev.pointerId); } catch {} if (d.width === startWidth && d.height === startHeight) return; try { const updated = await patchDevice(state.active.id, d.id, { width: d.width, height: d.height, }); Object.assign(d, updated); // Ports may have been on an edge that just moved (right or bottom) // — re-distribute everything to the new dims. await relayoutAllEdges(d.id); renderCanvas(); } catch (err) { alert(`Resize failed: ${err.message}`); } }; svg.addEventListener("pointermove", onMove); svg.addEventListener("pointerup", onUp); svg.addEventListener("pointercancel", onUp); } // Find the topmost canvas element under (clientX, clientY) that maps to // a cable endpoint target. Returns { kind, id } for port / device / IO, // or null when m dropped on empty canvas. function hitTestEndpointTarget(clientX, clientY) { // elementsFromPoint walks the z-order so we can skip the dragged // cable handle itself (it sits at the top while pointer-captured). const els = document.elementsFromPoint(clientX, clientY); for (const el of els) { if (!(el instanceof Element)) continue; if (el.classList?.contains("cable-handle")) continue; // skip self const portID = el.getAttribute && el.getAttribute("data-port-id"); if (portID) return { kind: "port", id: Number(portID) }; const devEl = el.closest && el.closest("[data-device-id]"); if (devEl) return { kind: "device", id: Number(devEl.getAttribute("data-device-id")) }; const ioEl = el.closest && el.closest("[data-io-id]"); if (ioEl) return { kind: "io", id: Number(ioEl.getAttribute("data-io-id")) }; } return null; } // Endpoint-drag gesture: pointerdown on a .cable-handle starts a replug. // While held, renderCanvas anchors the affected end at the cursor. // On pointerup, hit-test the cursor to find the drop target: // - port → PATCH {from|to: {port_id}} // - device → PATCH {from|to: {device_id}} // - IO → PATCH {from|to: {io_id}} // - empty → cancel (revert) // When the cable was auto, a successful drop also sends promote=true so // the server flips it to manual (m took control). Cancel leaves auto alone. function startCableReplug(e, cableID, end) { if (!state.active) return; e.stopPropagation(); e.preventDefault(); const c = state.cables.find((x) => x.id === cableID); if (!c) return; const svg = /** @type {SVGSVGElement} */ ($("#canvas")); try { svg.setPointerCapture(e.pointerId); } catch {} $(".canvas-wrap").classList.add("replugging"); const startWorld = svgPoint(e); cableReplug = { cableID, end, x: startWorld.x, y: startWorld.y }; renderCanvas(); const onMove = (ev) => { const p = svgPoint(ev); cableReplug = { cableID, end, x: p.x, y: p.y }; renderCanvas(); }; const onUp = async (ev) => { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.removeEventListener("pointercancel", onUp); try { svg.releasePointerCapture(ev.pointerId); } catch {} $(".canvas-wrap").classList.remove("replugging"); const drop = hitTestEndpointTarget(ev.clientX, ev.clientY); // Clear the preview first so renderCanvas falls back to resolved anchors. cableReplug = null; if (!drop) { renderCanvas(); return; // cancel } // Build the patch for the affected endpoint. const ep = drop.kind === "port" ? { port_id: drop.id } : drop.kind === "device" ? { device_id: drop.id } : drop.kind === "io" ? { io_id: drop.id } : null; if (!ep) { renderCanvas(); return; } const body = {}; if (end === "from") body.from = ep; else body.to = ep; if (c.auto) body.promote = true; // If m dropped on the same endpoint we already had, treat as cancel. const sameAsBefore = (drop.kind === "port" && ((end === "from" ? c.from_port_id : c.to_port_id) === drop.id)) || (drop.kind === "device" && ((end === "from" ? c.from_device_id : c.to_device_id) === drop.id)) || (drop.kind === "io" && ((end === "from" ? c.from_io_id : c.to_io_id) === drop.id)); if (sameAsBefore) { renderCanvas(); return; } try { const updated = await patchCable(state.active.id, c.id, body); Object.assign(c, updated); render(); } catch (err) { alert(`Replug failed: ${err.message}`); renderCanvas(); } }; svg.addEventListener("pointermove", onMove); svg.addEventListener("pointerup", onUp); svg.addEventListener("pointercancel", onUp); } /** Port-click flow: * - A cable draw is in progress (cableDrawFromPortID set): * same port → cancel; another port → finish the cable. * - Otherwise, no tool armed: * select the port (inspector shows edge picker + label + delete). * - Otherwise, any non-cable tool armed: * bubble so the canvas-level tool handler runs (lets +Port place * a new port even when the click lands on an existing one). */ function onPortPointerDown(e, port) { if (!state.active) return; // Cable-draw flow takes precedence whenever a source is already picked. if (state.cableDrawFromPortID != null) { e.stopPropagation(); e.preventDefault(); if (state.cableDrawFromPortID === port.id) { state.cableDrawFromPortID = null; armTool(null); render(); return; } finishCableDrawAt(port, e.shiftKey); return; } // No cable in progress, no tool: select the port → inspector pane. if (!state.tool) { e.stopPropagation(); e.preventDefault(); state.selection = { kind: "port", id: port.id }; render(); return; } // The cable tool: start a draw from this port. if (state.tool === "cable") { e.stopPropagation(); e.preventDefault(); state.cableDrawFromPortID = port.id; render(); return; } // Any other tool (port / frame / device / io / req): let the click // bubble up so the canvas-level branch fires. } async function finishCableDrawAt(targetPort, shiftKey) { if (!state.active) return; const fromPortID = state.cableDrawFromPortID; state.cableDrawFromPortID = null; armTool(null); if (fromPortID == null) return; const sourcePort = state.ports.find((p) => p.id === fromPortID); if (!sourcePort) { render(); return; } // Body: shift-click on a port = bind to that port's parent device // (whole-device cable) instead of the port. Plain click = port-to-port. const body = { type_id: sourcePort.type_id, auto: false, from: { port_id: fromPortID }, to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id }, }; if (!shiftKey && targetPort.type_id !== sourcePort.type_id) { if (!confirm(`Target port is a different cable type. Connect anyway?`)) { render(); return; } } try { const c = await createCableAPI(state.active.id, body); state.cables.push(c); state.selection = { kind: "cable", id: c.id }; render(); } catch (e) { alert(`Create cable failed: ${e.message}`); render(); } } /** Click on an IO marker while a cable draw is in progress → terminate * the cable on that IO. Plugged into the IO marker's pointerdown * handler in renderCanvas. */ async function finishCableDrawAtIO(ioMarker) { if (!state.active) return; const fromPortID = state.cableDrawFromPortID; state.cableDrawFromPortID = null; armTool(null); if (fromPortID == null) return; const sourcePort = state.ports.find((p) => p.id === fromPortID); if (!sourcePort) { render(); return; } const body = { type_id: sourcePort.type_id, auto: false, from: { port_id: fromPortID }, to: { io_id: ioMarker.id }, }; try { const c = await createCableAPI(state.active.id, body); state.cables.push(c); state.selection = { kind: "cable", id: c.id }; render(); } catch (e) { alert(`Create cable failed: ${e.message}`); render(); } } // Create a port from the sidebar "Add port" form and switch the // inspector to its editor. Used by renderInspectorPortNew on submit. async function createPortFromForm(deviceID, typeID, edge, label) { if (!state.active) return; const dev = state.devices.find((d) => d.id === deviceID); if (!dev) return; const tmp = edgeCentre(dev, edge); try { const port = await createPort(state.active.id, deviceID, { type_id: typeID, label: label || undefined, x_offset: tmp.xOff, y_offset: tmp.yOff, }); state.ports.push(port); // Re-space every port on this edge so the new one slots into the // even-spacing grid. await relayoutEdge(deviceID, edge); state.selection = { kind: "port", id: port.id }; render(); } catch (e) { alert(`Add port failed: ${e.message}`); } } async function placeIOMarkerAt(p) { if (!state.active) return; armTool(null); const x = p.x - IO_SIZE / 2; const y = p.y - IO_SIZE / 2; // Label is optional; a blank prompt commits with the default "IO" // (server-side fallback in CreateIOMarker). Esc cancels. const label = await promptInline("Outlet label (Enter for 'IO')", p.x, p.y - IO_SIZE); if (label === null || !state.active) return; const frame = frameAt(p.x, p.y); try { const m = await createIOMarker(state.active.id, { label: label || undefined, x, y, frame_id: frame ? frame.id : undefined, }); state.ioMarkers.push(m); state.selection = { kind: "io", id: m.id }; render(); } catch (err) { alert(`Create IO marker failed: ${err.message}`); } } // ---------- inline namer (foreignObject overlay) ---------- // let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null); function cancelInlineNamer() { if (activeNamer) { activeNamer.remove(); activeNamer = null; } } function promptInline(placeholder, cx, cy) { cancelInlineNamer(); return new Promise((resolve) => { const fo = document.createElementNS(SVG_NS, "foreignObject"); fo.setAttribute("x", String(cx - 110)); fo.setAttribute("y", String(cy - 14)); fo.setAttribute("width", "220"); fo.setAttribute("height", "28"); fo.innerHTML = `
`; $("#canvas").append(fo); activeNamer = fo; const input = fo.querySelector("input"); input.focus(); const done = (val) => { // Clear the flag *before* removing the node. Enter-key triggers a // synchronous blur on the input, which re-enters done() — and if // fo.remove() ran first, the second call hits a // "node no longer a child" pageerror. Reordering makes the second // re-entry a no-op (activeNamer is already null). if (activeNamer !== fo) return; activeNamer = null; fo.remove(); resolve(val); }; input.addEventListener("keydown", (e) => { if (e.key === "Enter") done(input.value.trim()); else if (e.key === "Escape") done(null); }); input.addEventListener("blur", () => done(input.value.trim() || null)); }); } // ---------- drag ---------- // function startDrag(e, kind, id) { if (!state.active) return; // Req tool intercepts device-down to start the drag-A-to-B gesture. if (state.tool === "req" && kind === "device") { e.stopPropagation(); e.preventDefault(); startRequirementDrag(e, id); return; } if (state.tool) return; // any other tool — let the canvas-level handler run e.stopPropagation(); state.selection = { kind, id }; // Render immediately so the inspector reflects the new selection from // pointerdown — independent of whether the drag-completion render at // the end of onUp runs. (Previously, the inspector only updated if // pointerup completed cleanly; any throw in onUp left it stale.) render(); const svg = /** @type {SVGSVGElement} */ ($("#canvas")); const start = svgPoint(e); /** @type {Frame|Device|IOMarker|undefined} */ let obj; if (kind === "frame") obj = state.frames.find((f) => f.id === id); else if (kind === "device") obj = state.devices.find((d) => d.id === id); else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id); if (!obj) return; const startX = obj.x; const startY = obj.y; // For frame drags, remember the contained devices + IO markers + their // offsets so they follow the frame visually + persist on release. let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]); let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]); if (kind === "frame") { for (const d of state.devices) { if (d.frame_id === obj.id) { trackedDevices.push({ d, sx: d.x, sy: d.y }); } } for (const m of state.ioMarkers) { if (m.frame_id === obj.id) { trackedIOs.push({ m, sx: m.x, sy: m.y }); } } } // Capture the rect element NOW: by the time onUp fires async, the // browser has nulled out e.currentTarget on the pointerdown event, // so `e.currentTarget.classList.remove("dragging")` would throw // "Cannot read properties of null". Sherlock surfaced this from the // click-only path that pageerror-spammed every device click. const dragTarget = /** @type {Element} */ (e.currentTarget); dragTarget.classList.add("dragging"); svg.setPointerCapture(e.pointerId); let dragged = false; const onMove = (ev) => { const p = svgPoint(ev); const dx = p.x - start.x; const dy = p.y - start.y; if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true; obj.x = startX + dx; obj.y = startY + dy; if (kind === "frame") { for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; } for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; } } renderCanvas(); }; const onUp = async (ev) => { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.releasePointerCapture(e.pointerId); dragTarget.classList.remove("dragging"); if (!dragged) { render(); return; } // click only — re-render to apply selection halo if (!state.active) return; try { if (kind === "frame") { const f = /** @type {Frame} */ (obj); await patchFrame(state.active.id, f.id, { x: f.x, y: f.y }); // Persist contained devices + IO markers too. await Promise.all([ ...trackedDevices.map((t) => patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })), ...trackedIOs.map((t) => patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })), ]); } else if (kind === "device") { const d = /** @type {Device} */ (obj); // Recompute frame_id from drop point (centre of device). const cx = d.x + d.width / 2; const cy = d.y + d.height / 2; const targetFrame = frameAt(cx, cy); const newFrameID = targetFrame ? targetFrame.id : null; const patchBody = { x: d.x, y: d.y }; if ((d.frame_id ?? null) !== newFrameID) { patchBody.frame_id = newFrameID; // explicit null = clear d.frame_id = newFrameID; } await patchDevice(state.active.id, d.id, patchBody); } else /* io */ { const m = /** @type {IOMarker} */ (obj); const cx = m.x + IO_SIZE / 2; const cy = m.y + IO_SIZE / 2; const targetFrame = frameAt(cx, cy); const newFrameID = targetFrame ? targetFrame.id : null; const patchBody = { x: m.x, y: m.y }; if ((m.frame_id ?? null) !== newFrameID) { patchBody.frame_id = newFrameID; m.frame_id = newFrameID; } await patchIOMarker(state.active.id, m.id, patchBody); } } catch (err) { alert(`Save failed: ${err.message}`); } render(); }; svg.addEventListener("pointermove", onMove); svg.addEventListener("pointerup", onUp); } // ---------- modals (project / cable type) ---------- // function bindCloseButtons(dialog) { dialog.querySelectorAll("[data-close]").forEach((btn) => btn.addEventListener("click", () => dialog.close()), ); } function showError(el, msg) { if (!msg) { setHidden(el, true); el.textContent = ""; return; } el.textContent = msg; setHidden(el, false); } function openNewProjectModal() { const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project")); const form = /** @type {HTMLFormElement} */ ($("#form-new-project")); const err = $("#np-error"); form.reset(); showError(err, ""); dlg.showModal(); form.elements.namedItem("name").focus(); form.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(form); const body = { name: String(fd.get("name") || "").trim(), drawing_name: String(fd.get("drawing_name") || "").trim(), description: String(fd.get("description") || ""), }; if (!body.drawing_name) delete body.drawing_name; try { const p = await createProject(body); state.projects = await listProjects(); dlg.close(); await activateProject(p.id); } catch (e) { showError(err, e.message || "Failed to create project"); } }; } function openCableTypeModal(existing) { const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type")); const form = /** @type {HTMLFormElement} */ ($("#form-cable-type")); const err = $("#ct-error"); const title = $("#ct-title"); form.reset(); showError(err, ""); title.textContent = existing ? `Edit "${existing.name}"` : "New cable type"; if (existing) { form.elements.namedItem("name").value = existing.name; form.elements.namedItem("color").value = existing.color; } else { form.elements.namedItem("color").value = "#1971c2"; } const actions = form.querySelector(".actions"); actions.querySelector(".btn-delete-type")?.remove(); if (existing) { const del = document.createElement("button"); del.type = "button"; del.className = "btn btn-danger btn-delete-type"; del.style.marginRight = "auto"; del.textContent = "Delete"; del.addEventListener("click", async () => { try { await deleteCableType(existing.id); state.cableTypes = await listCableTypes(); if (state.activeTypeId === existing.id) state.activeTypeId = null; dlg.close(); render(); } catch (e) { const n = e.details?.in_use_by_cables; showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed")); } }); actions.prepend(del); } dlg.showModal(); form.elements.namedItem("name").focus(); form.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(form); const body = { name: String(fd.get("name") || "").trim(), color: String(fd.get("color") || "").trim(), }; try { if (existing) await patchCableType(existing.id, body); else await createCableType(body); state.cableTypes = await listCableTypes(); dlg.close(); render(); } catch (e) { showError(err, e.message || "Save failed"); } }; } function openDeleteProjectModal() { if (!state.active) return; const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project")); const form = /** @type {HTMLFormElement} */ ($("#form-delete-project")); const err = $("#dp-error"); const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input")); form.reset(); showError(err, ""); input.placeholder = state.active.name; dlg.showModal(); input.focus(); form.onsubmit = async (e) => { e.preventDefault(); const confirm = String(new FormData(form).get("confirm") || ""); try { await deleteProject(state.active.id, confirm); state.projects = await listProjects(); dlg.close(); await activateProject(null); } catch (e) { showError(err, e.message || "Delete failed"); } }; } // ---------- solve flow ---------- // function openSolveModal() { if (!state.active) { alert("Pick a project first"); return; } const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve")); const body = $("#sv-body"); body.innerHTML = `

Computing…

`; dlg.showModal(); solveProject(state.active.id, true) .then((preview) => renderSolvePreview(body, preview)) .catch((e) => { body.innerHTML = `

${escapeHtml(e.message)}

`; }); $("#sv-apply").onclick = async () => { if (!state.active) return; try { const applied = await solveProject(state.active.id, false); // Refresh from snapshot to pick up new cable ids + bundle assignments. const snap = await getSnapshot(state.active.id); state.cables = snap.cables || []; state.bundles = snap.bundles || []; state.ports = snap.ports || []; state.requirements = snap.connection_requirements || []; dlg.close(); render(); // Surface a brief summary as an alert (slice 9+ can replace with a toast). const adds = applied.cables_added?.length ?? 0; const rem = applied.cables_removed?.length ?? 0; const bun = applied.bundles_added?.length ?? 0; const un = applied.unsatisfied?.length ?? 0; const lines = [`Solve applied: +${adds} cables / -${rem} cables / +${bun} bundles`]; if (un > 0) lines.push(`${un} requirement${un === 1 ? "" : "s"} unsatisfied`); console.log(lines.join("\n")); } catch (e) { alert(`Apply failed: ${e.message}`); } }; } function renderSolvePreview(body, preview) { const reqByID = new Map(state.requirements.map((r) => [r.id, r])); const deviceByID = new Map(state.devices.map((d) => [d.id, d])); const portByID = new Map(state.ports.map((p) => [p.id, p])); const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name])); const addsHtml = (preview.cables_added || []).map((c) => { const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id; const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id; const a = deviceByID.get(fromDev)?.name ?? "?"; const b = deviceByID.get(toDev)?.name ?? "?"; return `
  • + ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}
  • `; }).join(""); const remsHtml = (preview.cables_removed || []).map((id) => `
  • cable #${id}
  • `).join(""); const bunsHtml = (preview.bundles_added || []).map((b) => `
  • bundle: ${escapeHtml(b.name)}
  • `).join(""); const unmetsHtml = (preview.unsatisfied || []).map((u) => { const r = reqByID.get(u.requirement_id); const a = r ? deviceByID.get(r.from_device_id)?.name : "?"; const b = r ? deviceByID.get(r.to_device_id)?.name : "?"; const reqDesc = `${escapeHtml(a ?? "?")} ↔ ${escapeHtml(b ?? "?")}`; let action = ""; // Quick-fix per design v4.1 §5b.4. if ((u.reason || "").startsWith("no free") && u.cable_type && u.which_side) { const side = u.which_side === "from" ? r.from_device_id : r.to_device_id; const sideName = deviceByID.get(side)?.name ?? "?"; action = `+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve`; } else if ((u.reason || "").startsWith("ambiguous") && r) { action = `Specify cable type…`; } else if ((u.reason || "").startsWith("no compat") && r && r.preferred_cable_type_id != null) { // No common port type for the preferred — offer to add a port on either device. const sideName = deviceByID.get(r.from_device_id)?.name ?? "?"; action = `+ Add port to ${escapeHtml(sideName)} and re-solve`; } return `
  • ⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}
  • `; }).join(""); body.innerHTML = ` ${addsHtml ? `

    Cables to add

    ` : ""} ${remsHtml ? `

    Cables to remove

    ` : ""} ${bunsHtml ? `

    Bundles to add

    ` : ""} ${unmetsHtml ? `

    Unsatisfied

    ` : ""} ${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `

    No changes — already solved.

    `} `; body.querySelectorAll(".quickfix").forEach((el) => { el.addEventListener("click", async () => { const fix = el.getAttribute("data-fix"); if (fix === "addport") { const devID = Number(el.getAttribute("data-device")); let typeID = Number(el.getAttribute("data-cable-type-id")); if (!typeID) { const typeName = el.getAttribute("data-cable-type"); const t = state.cableTypes.find((x) => x.name === typeName); typeID = t ? t.id : null; } if (!devID || !typeID) return; try { await portsAndResolve(state.active.id, devID, { type_id: typeID }); // Refresh + re-render the preview const refresh = await solveProject(state.active.id, true); const snap = await getSnapshot(state.active.id); state.cables = snap.cables; state.bundles = snap.bundles; state.ports = snap.ports; state.requirements = snap.connection_requirements; state.devices = snap.devices; renderSolvePreview(body, refresh); render(); // sidebar updates } catch (e) { alert(`Quick-fix failed: ${e.message}`); } } else if (fix === "picktype") { // Open the requirement modal so m can specify a type. const rid = Number(el.getAttribute("data-req")); const r = state.requirements.find((x) => x.id === rid); if (r) openRequirementModal(r); } }); }); } // ---------- apply-template flow ---------- // async function openApplyTemplateModal() { if (!state.active) { alert("Pick a project first"); return; } if (!state.setupTemplates.length) { state.setupTemplates = await listSetupTemplates(); } const dlg = /** @type {HTMLDialogElement} */ ($("#modal-template")); const form = /** @type {HTMLFormElement} */ ($("#form-template")); const sel = /** @type {HTMLSelectElement} */ ($("#tp-select")); const preview = $("#tp-preview"); const err = $("#tp-error"); showError(err, ""); sel.innerHTML = ""; for (const t of state.setupTemplates) { sel.append(new Option(t.name, String(t.id))); } sel.onchange = () => renderTemplatePreview(preview, sel.value); renderTemplatePreview(preview, sel.value); dlg.showModal(); form.onsubmit = async (e) => { e.preventDefault(); if (!state.active) return; const tid = Number(sel.value); if (!tid) { showError(err, "Pick a template"); return; } // Collect any per-device name overrides (the preview renders inputs). const overrides = {}; preview.querySelectorAll("[data-template-device-id]").forEach((row) => { const did = row.getAttribute("data-template-device-id"); const input = row.querySelector("input.tp-name"); if (input && input.value.trim()) overrides[did] = input.value.trim(); }); const skip = []; preview.querySelectorAll("input.tp-skip:checked").forEach((cb) => { const did = Number(cb.getAttribute("data-template-device-id")); if (did) skip.push(did); }); try { // The server auto-solves by default since v0c7d165 — the response // is {template_apply, solve} (or {template_apply, solve_error}). // We don't need to read the body here; activateProject() below // pulls a fresh snapshot that includes both the seeded devices // and any cables the solver placed. const projID = state.active.id; await applyTemplate(projID, { template_id: tid, name_overrides: overrides, skip_devices: skip, }); dlg.close(); // Route through the canonical project-load path. That re-hydrates // ALL collections (frames, devices, ports, io_markers, cables, // bundles, requirements, cable_types, device_types) AND clears // the selection — important because m may have had a stale // selection from before the apply. Slice 6's bare re-snapshot // missed the device_types refresh + selection reset. await activateProject(projID); } catch (ex) { showError(err, ex.message || "Apply failed"); } }; } function renderTemplatePreview(preview, templateIDStr) { if (!templateIDStr) { preview.innerHTML = ""; return; } const t = state.setupTemplates.find((x) => String(x.id) === templateIDStr); if (!t) { preview.innerHTML = ""; return; } const cableTypeName = new Map(state.cableTypes.map((c) => [c.id, c.name])); const devByTplID = new Map(t.devices.map((d) => [d.id, d])); const devsHtml = t.devices.map((d) => { const dtName = d.device_type?.name ?? `type #${d.device_type_id}`; const suggested = d.suggested_name ?? dtName; return `
  • ${escapeHtml(dtName)}
  • `; }).join(""); const reqsHtml = t.requirements.map((r) => { const a = devByTplID.get(r.from_template_device_id); const b = devByTplID.get(r.to_template_device_id); const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : "solver picks"; return `
  • ${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}
  • `; }).join(""); preview.innerHTML = `

    ${escapeHtml(t.description)}

    Devices

    Requirements

    `; } // ---------- export flow ---------- // let toastTimer = null; function showToast(kind, html, holdMs = 5000) { const t = $("#toast"); t.className = "toast " + (kind || ""); t.innerHTML = html; setHidden(t, false); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs); } async function exportCurrentProject() { if (!state.active) { alert("Pick a project first"); return; } const btn = $("#btn-export"); btn.disabled = true; showToast("", "Exporting…", 30000); try { const res = await syncExport(state.active.id); const url = res.url ?? "(no url)"; const count = res.element_count ?? 0; showToast("ok", `Exported ${count} elements → ${escapeHtml(url)}`, 8000); } catch (e) { // Surface mxdrw unreachability or the upstream error verbatim. const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? ""); showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000); } finally { btn.disabled = false; } } // ---------- admin modal ---------- // const adminState = { activeTab: /** @type {"projects"|"cable-types"|"device-types"|"setup-templates"} */ ("projects"), }; async function openAdminModal() { const dlg = /** @type {HTMLDialogElement} */ ($("#modal-admin")); // Always re-fetch the lists when opening so the modal reflects the // latest server state (m may have edited things from inspector panes // while the modal was closed). try { state.projects = await listProjects(); state.cableTypes = await listCableTypes(); state.setupTemplates = await listSetupTemplates(); } catch (e) { alert(`Failed to load admin data: ${e.message}`); return; } for (const btn of dlg.querySelectorAll(".admin-tab")) { btn.addEventListener("click", () => switchAdminTab(btn.getAttribute("data-admin-tab"))); } switchAdminTab(adminState.activeTab); dlg.showModal(); } function switchAdminTab(name) { adminState.activeTab = name; for (const btn of $("#modal-admin").querySelectorAll(".admin-tab")) { const on = btn.getAttribute("data-admin-tab") === name; btn.setAttribute("aria-selected", on ? "true" : "false"); } const body = $("#admin-body"); switch (name) { case "projects": return renderAdminProjects(body); case "cable-types": return renderAdminCableTypes(body); case "device-types": return renderAdminDeviceTypes(body); case "setup-templates": return renderAdminSetupTemplates(body); case "requirements": return renderAdminRequirements(body); } } // ---------- admin: projects ---------- // function renderAdminProjects(body) { const rows = state.projects.map((p) => `
    ${escapeHtml(p.name)} #${p.id}
    `).join("") || `

    No projects.

    `; body.innerHTML = `

    Rename, retitle the drawing, or change the description. Delete cascades all frames / devices / cables / etc. in the project (cable types are global and unaffected).

    ${rows} `; for (const row of body.querySelectorAll(".admin-row[data-project-id]")) { const pid = Number(row.getAttribute("data-project-id")); row.querySelector(".adm-save").addEventListener("click", async () => { const name = row.querySelector(".adm-name").value.trim(); const drawing = row.querySelector(".adm-drawing").value.trim(); const desc = row.querySelector(".adm-desc").value; try { const updated = await patchProject(pid, { name, drawing_name: drawing, description: desc, }); const idx = state.projects.findIndex((p) => p.id === pid); if (idx >= 0) state.projects[idx] = updated; if (state.active?.id === pid) state.active = updated; renderProjectPicker(); switchAdminTab("projects"); } catch (e) { alert(`Save failed: ${e.message}`); } }); row.querySelector(".adm-delete").addEventListener("click", async () => { const p = state.projects.find((x) => x.id === pid); if (!p) return; const typed = prompt(`Type "${p.name}" to confirm delete:`); if (typed !== p.name) return; try { await deleteProject(pid, p.name); state.projects = state.projects.filter((x) => x.id !== pid); if (state.active?.id === pid) await activateProject(null); switchAdminTab("projects"); } catch (e) { alert(`Delete failed: ${e.message}`); } }); } } // ---------- admin: cable types ---------- // function renderAdminCableTypes(body) { const rows = state.cableTypes.map((t) => `
    ${escapeHtml(t.name)} #${t.id}
    `).join("") || `

    No cable types.

    `; body.innerHTML = ` ${rows}
    New cable type
    `; for (const row of body.querySelectorAll(".admin-row[data-cable-type-id]")) { const id = Number(row.getAttribute("data-cable-type-id")); row.querySelector(".adm-save").addEventListener("click", async () => { const name = row.querySelector(".adm-name").value.trim(); const color = row.querySelector(".adm-color").value; try { const updated = await patchCableType(id, { name, color }); const idx = state.cableTypes.findIndex((t) => t.id === id); if (idx >= 0) state.cableTypes[idx] = updated; renderLegend(); renderCanvas(); switchAdminTab("cable-types"); } catch (e) { alert(`Save failed: ${e.message}`); } }); row.querySelector(".adm-delete").addEventListener("click", async () => { if (!confirm("Delete this cable type? Requires no ports / cables to reference it.")) return; try { await deleteCableType(id); state.cableTypes = state.cableTypes.filter((t) => t.id !== id); renderLegend(); renderCanvas(); switchAdminTab("cable-types"); } catch (e) { alert(`Delete failed: ${e.message}`); } }); } body.querySelector("#adm-ct-new-create").addEventListener("click", async () => { const name = body.querySelector("#adm-ct-new-name").value.trim(); const color = body.querySelector("#adm-ct-new-color").value; if (!name) { alert("Name required"); return; } try { const created = await createCableType({ name, color }); state.cableTypes.push(created); renderLegend(); renderCanvas(); switchAdminTab("cable-types"); } catch (e) { alert(`Create failed: ${e.message}`); } }); } // ---------- admin: device types ---------- // function renderAdminDeviceTypes(body) { if (!state.active) { body.innerHTML = `

    Pick a project to manage its custom device types. Built-ins are listed once a project is active (they're project-agnostic but the catalog read takes a project context).

    `; return; } const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name])); const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color])); const portsLine = (ports) => ports.map((p) => `
  • ` + `${escapeHtml(cableTypeName.get(p.cable_type_id) || "?")} × ${p.count} (${escapeHtml(p.edge)})
  • `, ).join(""); const builtIns = state.deviceTypes.filter((t) => t.built_in); const customs = state.deviceTypes.filter((t) => !t.built_in); const builtRows = builtIns.map((t) => `
    ${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)} · ${escapeHtml(t.kind || "")} built-in

    ${escapeHtml(t.description || "")}

      ${portsLine(t.ports || [])}
    `).join(""); const customRows = customs.map((t) => `
    ${t.icon ? escapeHtml(t.icon) + " " : ""}${escapeHtml(t.name)} #${t.id}
      ${portsLine(t.ports || []) || '
    • no port profile
    • '}
    `).join("") || `

    No project-custom types yet.

    `; body.innerHTML = `

    Built-in types are seeded by migrations and read-only. Project-custom types live under the active project ('${escapeHtml(state.active.name)}') and can be edited or deleted. Port profiles can't be re-shaped here yet — m can still override per device-instance from the device inspector.

    Built-in (${builtIns.length})

    ${builtRows}

    Project-custom (${customs.length})

    ${customRows} `; for (const row of body.querySelectorAll(".admin-row:not(.locked)[data-device-type-id]")) { const id = Number(row.getAttribute("data-device-type-id")); row.querySelector(".adm-save").addEventListener("click", async () => { const name = row.querySelector(".adm-name").value.trim(); const kind = row.querySelector(".adm-kind").value.trim(); const icon = row.querySelector(".adm-icon").value.trim(); const desc = row.querySelector(".adm-desc").value; try { const updated = await patchDeviceType(state.active.id, id, { name, kind, icon, description: desc, }); const idx = state.deviceTypes.findIndex((t) => t.id === id); if (idx >= 0) state.deviceTypes[idx] = updated; switchAdminTab("device-types"); } catch (e) { alert(`Save failed: ${e.message}`); } }); row.querySelector(".adm-delete").addEventListener("click", async () => { if (!confirm("Delete this custom device type?")) return; try { await deleteDeviceType(state.active.id, id); state.deviceTypes = state.deviceTypes.filter((t) => t.id !== id); switchAdminTab("device-types"); } catch (e) { alert(`Delete failed: ${e.message}`); } }); } } // ---------- admin: setup templates ---------- // function renderAdminSetupTemplates(body) { const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name])); const rows = state.setupTemplates.map((t) => { const dt = (d) => d.device_type?.name ?? `type #${d.device_type_id}`; const devsById = new Map(t.devices.map((d) => [d.id, d])); const devsHtml = t.devices.map((d) => `
  • ${escapeHtml(d.suggested_name ?? dt(d))} (${escapeHtml(dt(d))})
  • `, ).join("") || `
  • no devices
  • `; const reqsHtml = t.requirements.map((r) => { const a = devsById.get(r.from_template_device_id); const b = devsById.get(r.to_template_device_id); const an = a ? (a.suggested_name ?? dt(a)) : "?"; const bn = b ? (b.suggested_name ?? dt(b)) : "?"; const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : null; const tag = r.must_connect ? "must" : "nice"; return `
  • ${escapeHtml(an)} ↔ ${escapeHtml(bn)} · ${escapeHtml(ct ?? "solver picks")} · ${tag}
  • `; }).join("") || `
  • no requirements
  • `; return `
    ${escapeHtml(t.name)} ${t.built_in ? "built-in" : "custom"}

    ${escapeHtml(t.description || "")}

    Devices (${t.devices.length})
      ${devsHtml}
    Requirements (${t.requirements.length})
      ${reqsHtml}
    `; }).join("") || `

    No setup templates.

    `; body.innerHTML = `

    Setup templates are stamps for a project — apply one from the header ("Apply template…") to seed a frame + devices + requirements at once. Built-in templates are read-only.

    ${rows} `; } // ---------- admin: requirements (all) ---------- // function renderAdminRequirements(body) { if (!state.active) { body.innerHTML = `

    Pick a project to see its requirements.

    `; return; } const deviceById = new Map(state.devices.map((d) => [d.id, d])); const cableTypeBy = new Map(state.cableTypes.map((t) => [t.id, t])); const rows = state.requirements.map((r) => { const a = deviceById.get(r.from_device_id); const b = deviceById.get(r.to_device_id); const ct = r.preferred_cable_type_id != null ? cableTypeBy.get(r.preferred_cable_type_id) : null; return `
    ${escapeHtml(a?.name ?? "?")} ↔ ${escapeHtml(b?.name ?? "?")} · ${escapeHtml(ct?.name ?? "solver picks")} ${r.must_connect ? "must" : "nice"} #${r.id}
    ${r.notes ? `

    ${escapeHtml(r.notes)}

    ` : ""}
    `; }).join("") || `

    No requirements yet.

    `; body.innerHTML = `

    Requirements are the solver's input — "device A must connect to device B". Add new ones from the per-device inspector (more contextual); manage them here.

    ${rows}
    ${state.devices.length < 2 ? '(needs ≥ 2 devices)' : ""}
    `; for (const row of body.querySelectorAll(".admin-row[data-req-id]")) { const rid = Number(row.getAttribute("data-req-id")); row.querySelector(".adm-edit").addEventListener("click", () => { const r = state.requirements.find((x) => x.id === rid); if (!r) return; const dlg = $("#modal-admin"); dlg.close(); openRequirementModal(r); }); row.querySelector(".adm-delete").addEventListener("click", async () => { if (!confirm("Delete this requirement?")) return; try { await deleteRequirement(state.active.id, rid); state.requirements = state.requirements.filter((r) => r.id !== rid); switchAdminTab("requirements"); render(); } catch (e) { alert(`Delete failed: ${e.message}`); } }); } const newBtn = body.querySelector("#adm-req-new"); if (newBtn) { newBtn.addEventListener("click", () => { $("#modal-admin").close(); openRequirementModal(null); }); } } // ---------- boot ---------- // async function boot() { bindCloseButtons($("#modal-new-project")); bindCloseButtons($("#modal-cable-type")); bindCloseButtons($("#modal-delete-project")); bindCloseButtons($("#modal-new-device")); bindCloseButtons($("#modal-requirement")); bindCloseButtons($("#modal-solve")); bindCloseButtons($("#modal-template")); bindCloseButtons($("#modal-admin")); $("#btn-new-project").addEventListener("click", openNewProjectModal); $("#btn-add-type").addEventListener("click", () => openCableTypeModal(null)); $("#btn-delete-project").addEventListener("click", openDeleteProjectModal); $("#btn-admin").addEventListener("click", openAdminModal); $("#btn-solve").addEventListener("click", openSolveModal); $("#btn-apply-template").addEventListener("click", openApplyTemplateModal); $("#btn-export").addEventListener("click", exportCurrentProject); $("#btn-fit").addEventListener("click", fitToContent); $("#project-select").addEventListener("change", (e) => { const v = /** @type {HTMLSelectElement} */ (e.target).value; activateProject(v ? Number(v) : null); }); bindTools(); viewFromURL(); applyViewBox(); updateZoomUI(); try { [state.projects, state.cableTypes] = await Promise.all([ listProjects(), listCableTypes(), ]); } catch (e) { alert(`Failed to load: ${e.message}`); return; } const wanted = activeProjectIdFromURL(); if (wanted && state.projects.some((p) => p.id === wanted)) { await activateProject(wanted); } else { render(); } } boot();