Export button is no longer disabled. On click it POSTs to the export endpoint and shows a toast next to the button: ✓ Exported · open in mxdrw (with viewer URL) ✗ Export failed — <detail>
2199 lines
83 KiB
JavaScript
2199 lines
83 KiB
JavaScript
// mCables frontend entry — vanilla ES module, no build step.
|
||
//
|
||
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
|
||
// inline naming, inspector for selection. State stays minimal: one
|
||
// snapshot from the server, then individual PATCHes on each mutation.
|
||
|
||
/**
|
||
* @typedef {{ id: number, name: string, drawing_name: string,
|
||
* description: string, created_at: string, updated_at: string }} Project
|
||
* @typedef {{ id: number, name: string, color: string,
|
||
* created_at: string, updated_at: string }} CableType
|
||
* @typedef {{ id: number, project_id: number, name: string,
|
||
* x: number, y: number, width: number, height: number }} Frame
|
||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||
* type_id: number|null, name: string, color: string,
|
||
* x: number, y: number, width: number, height: number }} Device
|
||
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
||
* label: string, x: number, y: number }} IOMarker
|
||
* @typedef {{ id: number, project_id: number, device_id: number,
|
||
* type_id: number, label: string|null,
|
||
* x_offset: number, y_offset: number }} Port
|
||
* @typedef {{ id: number, device_type_id: number, cable_type_id: number,
|
||
* label_prefix: string, count: number, edge: string,
|
||
* sort_order: number }} DeviceTypePort
|
||
* @typedef {{ id: number, project_id: number|null, name: string,
|
||
* kind: string, icon: string|null, description: string,
|
||
* built_in: boolean, ports: DeviceTypePort[] }} DeviceType
|
||
* @typedef {{ id: number, project_id: number, from_device_id: number,
|
||
* to_device_id: number, preferred_cable_type_id: number|null,
|
||
* must_connect: boolean, notes: string }} ConnectionRequirement
|
||
* @typedef {{ id: number, project_id: number, type_id: number,
|
||
* label: string|null, auto: boolean,
|
||
* from_port_id: number|null, from_device_id: number|null, from_io_id: number|null,
|
||
* to_port_id: number|null, to_device_id: number|null, to_io_id: number|null }} Cable
|
||
* @typedef {{ id: number, project_id: number, name: string, auto: boolean,
|
||
* cable_ids: number[] }} Bundle
|
||
* @typedef {{ id: number, name: string, description: string, built_in: boolean,
|
||
* devices: any[], requirements: any[] }} SetupTemplate
|
||
*/
|
||
|
||
const API = "/api";
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
|
||
|
||
const state = {
|
||
/** @type {Project[]} */ projects: [],
|
||
/** @type {CableType[]} */ cableTypes: [],
|
||
/** @type {DeviceType[]} */ deviceTypes: [],
|
||
/** @type {Project | null} */ active: null,
|
||
/** @type {Frame[]} */ frames: [],
|
||
/** @type {Device[]} */ devices: [],
|
||
/** @type {Port[]} */ ports: [],
|
||
/** @type {IOMarker[]} */ ioMarkers: [],
|
||
/** @type {ConnectionRequirement[]} */ requirements: [],
|
||
/** @type {Cable[]} */ cables: [],
|
||
/** @type {Bundle[]} */ bundles: [],
|
||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||
activeTypeId: /** @type {number|null} */ (null),
|
||
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
|
||
tool: /** @type {string|null} */ (null),
|
||
/** Slice-7 transient state for the +Port tool. */
|
||
portToolDevice: /** @type {number|null} */ (null),
|
||
portToolTypeID: /** @type {number|null} */ (null),
|
||
/** Slice-7: when the user clicked a source port, this is its id. */
|
||
cableDrawFromPortID: /** @type {number|null} */ (null),
|
||
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
|
||
};
|
||
|
||
// ---------- API client ---------- //
|
||
|
||
async function api(method, path, body) {
|
||
const res = await fetch(API + path, {
|
||
method,
|
||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
if (res.status === 204) return null;
|
||
const text = await res.text();
|
||
const json = text ? JSON.parse(text) : null;
|
||
if (!res.ok) {
|
||
const err = new Error(json?.error || res.statusText);
|
||
err.status = res.status;
|
||
err.details = json?.details;
|
||
throw err;
|
||
}
|
||
return json;
|
||
}
|
||
|
||
const listProjects = () => api("GET", "/projects");
|
||
const createProject = (body) => api("POST", "/projects", body);
|
||
const deleteProject = (id, confirm) =>
|
||
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
|
||
const getSnapshot = (id) => api("GET", `/projects/${id}`);
|
||
|
||
const listCableTypes = () => api("GET", "/cable-types");
|
||
const createCableType = (body) => api("POST", "/cable-types", body);
|
||
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
|
||
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
|
||
|
||
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
|
||
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
|
||
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
|
||
|
||
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
|
||
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
|
||
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
|
||
|
||
const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers`, body);
|
||
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
|
||
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
|
||
|
||
const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body);
|
||
const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body);
|
||
const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
|
||
|
||
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
|
||
|
||
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
|
||
|
||
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
|
||
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
|
||
const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`);
|
||
|
||
const patchCable = (pid, id, body) => api("PATCH", `/projects/${pid}/cables/${id}`, body);
|
||
const deleteCable = (pid, id) => api("DELETE", `/projects/${pid}/cables/${id}`);
|
||
|
||
const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${preview ? "?preview=1" : ""}`, {});
|
||
const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body);
|
||
const listSetupTemplates = () => api("GET", `/setup-templates`);
|
||
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
|
||
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
|
||
|
||
// ---------- DOM helpers ---------- //
|
||
|
||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||
|
||
function setHidden(el, hidden) {
|
||
if (hidden) el.setAttribute("hidden", "");
|
||
else el.removeAttribute("hidden");
|
||
}
|
||
|
||
function svgEl(name, attrs = {}) {
|
||
const el = document.createElementNS(SVG_NS, name);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v == null) continue;
|
||
el.setAttribute(k, String(v));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
// ---------- URL state ---------- //
|
||
|
||
function activeProjectIdFromURL() {
|
||
const raw = new URLSearchParams(location.search).get("project");
|
||
const id = raw && Number.parseInt(raw, 10);
|
||
return Number.isFinite(id) && id > 0 ? id : null;
|
||
}
|
||
|
||
function setActiveInURL(id) {
|
||
const url = new URL(location.href);
|
||
if (id == null) url.searchParams.delete("project");
|
||
else url.searchParams.set("project", String(id));
|
||
history.replaceState(null, "", url.toString());
|
||
}
|
||
|
||
// ---------- geometry ---------- //
|
||
|
||
/** Returns the smallest frame whose bbox contains (x, y), or null. */
|
||
function frameAt(x, y) {
|
||
/** @type {Frame|null} */ let best = null;
|
||
let bestArea = Infinity;
|
||
for (const f of state.frames) {
|
||
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
|
||
const a = f.width * f.height;
|
||
if (a < bestArea) { best = f; bestArea = a; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** Convert a pointer event to SVG-canvas coordinates. */
|
||
function svgPoint(evt) {
|
||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||
const pt = svg.createSVGPoint();
|
||
pt.x = evt.clientX;
|
||
pt.y = evt.clientY;
|
||
const ctm = svg.getScreenCTM();
|
||
if (!ctm) return { x: 0, y: 0 };
|
||
const local = pt.matrixTransform(ctm.inverse());
|
||
return { x: local.x, y: local.y };
|
||
}
|
||
|
||
// ---------- render ---------- //
|
||
|
||
function renderProjectPicker() {
|
||
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
|
||
const current = state.active?.id ?? "";
|
||
sel.innerHTML = "";
|
||
sel.append(new Option("— pick a project —", ""));
|
||
for (const p of state.projects) {
|
||
const opt = new Option(p.name, String(p.id));
|
||
if (p.id === current) opt.selected = true;
|
||
sel.append(opt);
|
||
}
|
||
setHidden($("#btn-delete-project"), !state.active);
|
||
}
|
||
|
||
function renderLegend() {
|
||
const ul = $("#legend-list");
|
||
ul.innerHTML = "";
|
||
for (const t of state.cableTypes) {
|
||
const li = document.createElement("li");
|
||
li.className = "legend-row";
|
||
li.dataset.id = String(t.id);
|
||
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
|
||
li.innerHTML = `
|
||
<span class="legend-swatch" style="background:${t.color}"></span>
|
||
<span class="legend-name"></span>
|
||
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
|
||
`;
|
||
li.querySelector(".legend-name").textContent = t.name;
|
||
li.addEventListener("click", (e) => {
|
||
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
|
||
openCableTypeModal(t);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
// Click toggles activeTypeId AND moves the inspector to show the
|
||
// cable type's details. If m clicks the already-active type the
|
||
// active is cleared but the inspector still shows it (so m can
|
||
// edit name/colour without an active draw mode getting in the way).
|
||
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
|
||
state.selection = { kind: "cable_type", id: t.id };
|
||
render();
|
||
});
|
||
ul.append(li);
|
||
}
|
||
}
|
||
|
||
function renderEmptyHint() {
|
||
const hint = $("#empty-hint");
|
||
if (!state.active) {
|
||
hint.textContent = state.projects.length
|
||
? "Pick a project from the dropdown to start drawing."
|
||
: "Create your first project to get started.";
|
||
setHidden(hint, false);
|
||
return;
|
||
}
|
||
if (state.frames.length === 0 && state.devices.length === 0) {
|
||
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
|
||
setHidden(hint, false);
|
||
} else {
|
||
setHidden(hint, true);
|
||
}
|
||
}
|
||
|
||
function renderCanvas() {
|
||
const gFrames = $("#canvas-frames");
|
||
const gDevices = $("#canvas-devices");
|
||
const gCables = $("#canvas-cables");
|
||
const gIO = $("#canvas-io");
|
||
gFrames.innerHTML = "";
|
||
gDevices.innerHTML = "";
|
||
gCables.innerHTML = "";
|
||
gIO.innerHTML = "";
|
||
|
||
for (const f of state.frames) {
|
||
const g = svgEl("g", { "data-frame-id": f.id });
|
||
const rect = svgEl("rect", {
|
||
x: f.x, y: f.y, width: f.width, height: f.height,
|
||
class: "frame-rect svg-draggable",
|
||
rx: 6, ry: 6,
|
||
});
|
||
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
|
||
rect.classList.add("selected");
|
||
}
|
||
const label = svgEl("text", {
|
||
x: f.x + 8, y: f.y + 18,
|
||
class: "frame-label",
|
||
});
|
||
label.textContent = f.name;
|
||
g.append(rect, label);
|
||
gFrames.append(g);
|
||
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
|
||
}
|
||
|
||
const portsByDevice = new Map();
|
||
for (const prt of state.ports) {
|
||
const arr = portsByDevice.get(prt.device_id) || [];
|
||
arr.push(prt);
|
||
portsByDevice.set(prt.device_id, arr);
|
||
}
|
||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||
|
||
for (const d of state.devices) {
|
||
const g = svgEl("g", { "data-device-id": d.id });
|
||
const rect = svgEl("rect", {
|
||
x: d.x, y: d.y, width: d.width, height: d.height,
|
||
class: "device-rect svg-draggable",
|
||
stroke: d.color,
|
||
rx: 3, ry: 3,
|
||
});
|
||
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
||
rect.classList.add("selected");
|
||
}
|
||
const label = svgEl("text", {
|
||
x: d.x + d.width / 2, y: d.y + d.height / 2,
|
||
class: "device-label",
|
||
});
|
||
label.textContent = d.name;
|
||
g.append(rect, label);
|
||
|
||
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
|
||
// Stroke colour = the cable_type colour the port carries; fill stays white
|
||
// so the port reads against any device colour.
|
||
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 cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
|
||
const c = svgEl("circle", {
|
||
cx, cy, r: 5,
|
||
class: cls,
|
||
stroke: color,
|
||
"data-port-id": prt.id,
|
||
});
|
||
// Slice 7: port-click drives the manual cable-draw flow.
|
||
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
|
||
g.append(c);
|
||
}
|
||
|
||
gDevices.append(g);
|
||
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
|
||
}
|
||
|
||
for (const m of state.ioMarkers) {
|
||
const g = svgEl("g", { "data-io-id": m.id });
|
||
// Diamond = a square rotated 45° around its centre. Using a <rect>
|
||
// with rotate(45 cx cy) is the easiest hit-shape that still respects
|
||
// x/y as the rotated bounding box.
|
||
const cx = m.x + IO_SIZE / 2;
|
||
const cy = m.y + IO_SIZE / 2;
|
||
const rect = svgEl("rect", {
|
||
x: m.x, y: m.y, width: IO_SIZE, height: IO_SIZE,
|
||
class: "io-marker svg-draggable",
|
||
transform: `rotate(45 ${cx} ${cy})`,
|
||
});
|
||
if (state.selection?.kind === "io" && state.selection.id === m.id) {
|
||
rect.classList.add("selected");
|
||
}
|
||
const label = svgEl("text", {
|
||
x: cx, y: cy + IO_SIZE * 0.85,
|
||
class: "io-marker-label",
|
||
});
|
||
label.textContent = m.label;
|
||
g.append(rect, label);
|
||
gIO.append(g);
|
||
rect.addEventListener("pointerdown", (e) => {
|
||
// Slice 7: if a cable draw is in progress, terminate the cable on
|
||
// this IO marker instead of starting a drag.
|
||
if (state.cableDrawFromPortID != null) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
finishCableDrawAtIO(m);
|
||
return;
|
||
}
|
||
startDrag(e, "io", m.id);
|
||
});
|
||
}
|
||
|
||
// Cables — straight lines between resolved endpoint anchors.
|
||
// Auto-cables render with dashed stroke so m sees which the solver
|
||
// placed; manual cables are solid.
|
||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||
for (const c of state.cables) {
|
||
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
||
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||
if (!fromAnchor || !toAnchor) continue;
|
||
const color = cableTypeColor.get(c.type_id) || "#888";
|
||
const line = svgEl("line", {
|
||
x1: fromAnchor.x, y1: fromAnchor.y,
|
||
x2: toAnchor.x, y2: toAnchor.y,
|
||
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
|
||
stroke: color,
|
||
"data-cable-id": c.id,
|
||
});
|
||
line.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
state.selection = { kind: "cable", id: c.id };
|
||
render();
|
||
});
|
||
gCables.append(line);
|
||
}
|
||
}
|
||
|
||
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
|
||
* the referenced row has gone missing (rare, but possible mid-edit). */
|
||
function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) {
|
||
if (portID != null) {
|
||
const p = portByID.get(portID);
|
||
if (!p) return null;
|
||
const d = deviceByID.get(p.device_id);
|
||
if (!d) return null;
|
||
return { x: d.x + p.x_offset, y: d.y + p.y_offset };
|
||
}
|
||
if (deviceID != null) {
|
||
const d = deviceByID.get(deviceID);
|
||
if (!d) return null;
|
||
return { x: d.x + d.width / 2, y: d.y + d.height / 2 };
|
||
}
|
||
if (ioID != null) {
|
||
const m = ioByID.get(ioID);
|
||
if (!m) return null;
|
||
return { x: m.x + IO_SIZE / 2, y: m.y + IO_SIZE / 2 };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderInspector() {
|
||
const body = $("#inspector-body");
|
||
if (!state.selection) {
|
||
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||
return;
|
||
}
|
||
switch (state.selection.kind) {
|
||
case "frame": return renderInspectorFrame(body, state.selection.id);
|
||
case "device": return renderInspectorDevice(body, state.selection.id);
|
||
case "io": return renderInspectorIO(body, state.selection.id);
|
||
case "cable_type": return renderInspectorCableType(body, state.selection.id);
|
||
case "requirement": return renderInspectorRequirement(body, state.selection.id);
|
||
case "cable": return renderInspectorCable(body, state.selection.id);
|
||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||
}
|
||
}
|
||
|
||
function renderInspectorCable(body, id) {
|
||
const c = state.cables.find((x) => x.id === id);
|
||
if (!c) { body.innerHTML = ""; return; }
|
||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||
const ct = state.cableTypes.find((t) => t.id === c.type_id);
|
||
function endpointLabel(portID, deviceID, ioID) {
|
||
if (portID != null) {
|
||
const p = portByID.get(portID);
|
||
if (!p) return "(missing port)";
|
||
const d = deviceByID.get(p.device_id);
|
||
return `${d?.name ?? "?"} · ${p.label ?? "port"}`;
|
||
}
|
||
if (deviceID != null) {
|
||
const d = deviceByID.get(deviceID);
|
||
return d?.name ?? "(missing device)";
|
||
}
|
||
if (ioID != null) {
|
||
const m = ioByID.get(ioID);
|
||
return m?.label ?? "(missing IO)";
|
||
}
|
||
return "?";
|
||
}
|
||
const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id);
|
||
const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id);
|
||
|
||
// Find the driving requirement (auto cable only) — match by
|
||
// unordered device pair + (cable type or null).
|
||
let drivingReq = null;
|
||
if (c.auto) {
|
||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||
if (fromDev != null && toDev != null) {
|
||
drivingReq = state.requirements.find((r) => {
|
||
const same = (r.from_device_id === fromDev && r.to_device_id === toDev)
|
||
|| (r.from_device_id === toDev && r.to_device_id === fromDev);
|
||
if (!same) return false;
|
||
if (r.preferred_cable_type_id == null) return true; // solver-picked match
|
||
return r.preferred_cable_type_id === c.type_id;
|
||
});
|
||
}
|
||
}
|
||
|
||
body.innerHTML = `
|
||
<p class="section-title">Cable ${c.auto ? "(solver)" : "(manual)"}</p>
|
||
<dl>
|
||
<dt>type</dt><dd id="cab-type"></dd>
|
||
<dt>from</dt><dd id="cab-from"></dd>
|
||
<dt>to</dt><dd id="cab-to"></dd>
|
||
<dt>driver</dt><dd id="cab-driver"></dd>
|
||
</dl>
|
||
<div class="inspector-actions">
|
||
${c.auto ? `<button type="button" class="btn btn-tiny" id="cab-promote">Promote to manual</button>` : ""}
|
||
<button type="button" class="btn btn-danger btn-tiny" id="cab-delete">Delete</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`;
|
||
body.querySelector("#cab-from").textContent = fromLabel;
|
||
body.querySelector("#cab-to").textContent = toLabel;
|
||
const driverCell = body.querySelector("#cab-driver");
|
||
if (drivingReq) {
|
||
const deviceByID2 = new Map(state.devices.map((d) => [d.id, d]));
|
||
const an = deviceByID2.get(drivingReq.from_device_id)?.name ?? "?";
|
||
const bn = deviceByID2.get(drivingReq.to_device_id)?.name ?? "?";
|
||
const link = document.createElement("button");
|
||
link.type = "button";
|
||
link.className = "btn-link";
|
||
link.style.padding = "0";
|
||
link.textContent = `${an} ↔ ${bn}`;
|
||
link.title = "Jump to this requirement";
|
||
link.addEventListener("click", () => {
|
||
state.selection = { kind: "requirement", id: drivingReq.id };
|
||
render();
|
||
});
|
||
driverCell.append(link);
|
||
} else {
|
||
driverCell.textContent = c.auto ? "(no matching requirement)" : "—";
|
||
}
|
||
|
||
if (c.auto) {
|
||
body.querySelector("#cab-promote").addEventListener("click", async () => {
|
||
if (!state.active) return;
|
||
try {
|
||
const updated = await patchCable(state.active.id, c.id, { promote: true });
|
||
Object.assign(c, updated);
|
||
render();
|
||
} catch (e) { alert(`Promote failed: ${e.message}`); }
|
||
});
|
||
}
|
||
body.querySelector("#cab-delete").addEventListener("click", async () => {
|
||
if (!state.active) return;
|
||
if (!confirm("Delete this cable?")) return;
|
||
try {
|
||
await deleteCable(state.active.id, c.id);
|
||
state.cables = state.cables.filter((x) => x.id !== c.id);
|
||
state.selection = null;
|
||
render();
|
||
} catch (e) { alert(`Delete failed: ${e.message}`); }
|
||
});
|
||
}
|
||
|
||
function renderInspectorFrame(body, id) {
|
||
const f = state.frames.find((x) => x.id === id);
|
||
if (!f) { body.innerHTML = ""; return; }
|
||
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
|
||
const ioCount = state.ioMarkers.filter((m) => m.frame_id === f.id).length;
|
||
body.innerHTML = `
|
||
<p class="section-title">Frame</p>
|
||
<label class="field">
|
||
<span>Name</span>
|
||
<input class="inline-input" id="frm-name" value="" />
|
||
</label>
|
||
<dl>
|
||
<dt>x</dt><dd id="frm-x"></dd>
|
||
<dt>y</dt><dd id="frm-y"></dd>
|
||
<dt>w</dt><dd id="frm-w"></dd>
|
||
<dt>h</dt><dd id="frm-h"></dd>
|
||
<dt>devices</dt><dd id="frm-count"></dd>
|
||
<dt>IO</dt><dd id="frm-io-count"></dd>
|
||
</dl>
|
||
<div class="inspector-actions">
|
||
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#frm-name").value = f.name;
|
||
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
|
||
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
|
||
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
|
||
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
|
||
body.querySelector("#frm-count").textContent = String(deviceCount);
|
||
body.querySelector("#frm-io-count").textContent = String(ioCount);
|
||
|
||
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
|
||
if (!state.active) return;
|
||
const updated = await patchFrame(state.active.id, f.id, { name });
|
||
Object.assign(f, updated);
|
||
renderCanvas();
|
||
});
|
||
|
||
body.querySelector("#frm-delete").addEventListener("click", () => {
|
||
if (!state.active) return;
|
||
if (!confirm(`Delete frame "${f.name}"? Its devices and IO markers stay but lose their frame.`)) return;
|
||
deleteFrame(state.active.id, f.id).then(() => {
|
||
state.frames = state.frames.filter((x) => x.id !== f.id);
|
||
for (const m of state.ioMarkers) if (m.frame_id === f.id) m.frame_id = null;
|
||
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
|
||
state.selection = null;
|
||
render();
|
||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||
});
|
||
}
|
||
|
||
function renderInspectorDevice(body, id) {
|
||
const d = state.devices.find((x) => x.id === id);
|
||
if (!d) { body.innerHTML = ""; return; }
|
||
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
|
||
const type = d.type_id ? state.deviceTypes.find((t) => t.id === d.type_id) : null;
|
||
const ports = state.ports.filter((p) => p.device_id === d.id);
|
||
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
|
||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||
|
||
const portsHtml = ports.length
|
||
? ports.map((p) => `
|
||
<div class="port-row" data-port-id="${p.id}">
|
||
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
|
||
<span class="label">${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
|
||
<span class="conn">
|
||
<button type="button" class="btn-link port-del" data-port-id="${p.id}" title="Delete port">×</button>
|
||
</span>
|
||
</div>`).join("")
|
||
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
|
||
|
||
// Requirements involving this device — sorted as (other-device-name asc).
|
||
const involved = state.requirements.filter((r) => r.from_device_id === d.id || r.to_device_id === d.id);
|
||
const deviceById = new Map(state.devices.map((x) => [x.id, x]));
|
||
involved.sort((a, b) => {
|
||
const oa = (a.from_device_id === d.id ? a.to_device_id : a.from_device_id);
|
||
const ob = (b.from_device_id === d.id ? b.to_device_id : b.from_device_id);
|
||
return (deviceById.get(oa)?.name || "").localeCompare(deviceById.get(ob)?.name || "");
|
||
});
|
||
const reqsHtml = involved.length
|
||
? involved.map((r) => {
|
||
const other = (r.from_device_id === d.id ? r.to_device_id : r.from_device_id);
|
||
const otherName = deviceById.get(other)?.name ?? `device #${other}`;
|
||
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : null;
|
||
return `
|
||
<div class="requirement-row" data-req-id="${r.id}">
|
||
<span class="pair">↔ ${escapeHtml(otherName)}
|
||
<span class="type"> · ${escapeHtml(ct ?? "solver picks")}</span>
|
||
</span>
|
||
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
|
||
</div>`;
|
||
}).join("")
|
||
: `<p class="muted" style="font-size:12px">No requirements yet.</p>`;
|
||
|
||
body.innerHTML = `
|
||
<p class="section-title">Device</p>
|
||
<label class="field">
|
||
<span>Name</span>
|
||
<input class="inline-input" id="dev-name" value="" />
|
||
</label>
|
||
<label class="field">
|
||
<span>Colour</span>
|
||
<input type="color" class="inline-input" id="dev-color" />
|
||
</label>
|
||
<dl>
|
||
<dt>type</dt><dd id="dev-type"></dd>
|
||
<dt>x</dt><dd id="dev-x"></dd>
|
||
<dt>y</dt><dd id="dev-y"></dd>
|
||
<dt>w</dt><dd id="dev-w"></dd>
|
||
<dt>h</dt><dd id="dev-h"></dd>
|
||
<dt>frame</dt><dd id="dev-frame"></dd>
|
||
</dl>
|
||
<p class="section-title">Ports</p>
|
||
<div id="dev-ports">${portsHtml}</div>
|
||
<div class="inspector-actions" style="margin-top: 4px;">
|
||
<button type="button" class="btn btn-tiny" id="dev-add-port">+ Port</button>
|
||
</div>
|
||
<p class="section-title">Requirements</p>
|
||
<div id="dev-reqs">${reqsHtml}</div>
|
||
<div class="inspector-actions">
|
||
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#dev-name").value = d.name;
|
||
body.querySelector("#dev-color").value = d.color;
|
||
body.querySelector("#dev-type").textContent = type
|
||
? `${type.name}${type.built_in ? "" : " (custom)"}`
|
||
: "Custom (no type)";
|
||
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
|
||
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
|
||
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
|
||
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
|
||
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
|
||
|
||
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
|
||
if (!state.active) return;
|
||
const updated = await patchDevice(state.active.id, d.id, { name });
|
||
Object.assign(d, updated);
|
||
renderCanvas();
|
||
});
|
||
|
||
// Colour changes need no debounce — the native colour picker only fires
|
||
// `change` on commit.
|
||
body.querySelector("#dev-color").addEventListener("change", async (e) => {
|
||
if (!state.active) return;
|
||
const color = /** @type {HTMLInputElement} */ (e.target).value;
|
||
try {
|
||
const updated = await patchDevice(state.active.id, d.id, { color });
|
||
Object.assign(d, updated);
|
||
renderCanvas();
|
||
} catch (err) {
|
||
alert(`Colour update failed: ${err.message}`);
|
||
}
|
||
});
|
||
|
||
body.querySelector("#dev-delete").addEventListener("click", () => {
|
||
if (!state.active) return;
|
||
if (!confirm(`Delete device "${d.name}"?`)) return;
|
||
deleteDevice(state.active.id, d.id).then(() => {
|
||
state.devices = state.devices.filter((x) => x.id !== d.id);
|
||
state.ports = state.ports.filter((p) => p.device_id !== d.id);
|
||
// Server cascaded the requirements; drop them locally too.
|
||
state.requirements = state.requirements.filter(
|
||
(r) => r.from_device_id !== d.id && r.to_device_id !== d.id,
|
||
);
|
||
state.selection = null;
|
||
render();
|
||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||
});
|
||
|
||
// Clicking a requirement row in the device inspector jumps to that
|
||
// requirement's own inspector pane.
|
||
body.querySelectorAll("[data-req-id]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const rid = Number(el.getAttribute("data-req-id"));
|
||
state.selection = { kind: "requirement", id: rid };
|
||
render();
|
||
});
|
||
});
|
||
|
||
// +Port — arms the port-placement gesture. Active cable type comes
|
||
// from the legend selection; if none, defaults to the first cable_type.
|
||
body.querySelector("#dev-add-port").addEventListener("click", () => {
|
||
if (!state.active) return;
|
||
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
|
||
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
|
||
armPortTool(d.id, typeID);
|
||
});
|
||
|
||
// Per-port delete.
|
||
body.querySelectorAll(".port-del").forEach((btn) => {
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (!state.active) return;
|
||
const pid = Number(btn.getAttribute("data-port-id"));
|
||
if (!pid) return;
|
||
if (!confirm("Delete this port?")) return;
|
||
try {
|
||
await deletePort(state.active.id, pid);
|
||
state.ports = state.ports.filter((p) => p.id !== pid);
|
||
// Cables that referenced the port get from_port_id/to_port_id
|
||
// set to NULL by the schema — refresh from snapshot.
|
||
const snap = await getSnapshot(state.active.id);
|
||
state.cables = snap.cables || [];
|
||
render();
|
||
} catch (ex) { alert(`Delete failed: ${ex.message}`); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderInspectorRequirement(body, id) {
|
||
const r = state.requirements.find((x) => x.id === id);
|
||
if (!r) { body.innerHTML = ""; return; }
|
||
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
|
||
const a = deviceById.get(r.from_device_id);
|
||
const b = deviceById.get(r.to_device_id);
|
||
const ctName = r.preferred_cable_type_id != null
|
||
? state.cableTypes.find((t) => t.id === r.preferred_cable_type_id)?.name
|
||
: null;
|
||
body.innerHTML = `
|
||
<p class="section-title">Connection requirement</p>
|
||
<dl>
|
||
<dt>from</dt><dd id="rq-from-name"></dd>
|
||
<dt>to</dt><dd id="rq-to-name"></dd>
|
||
<dt>cable</dt><dd id="rq-ct"></dd>
|
||
<dt>type</dt><dd id="rq-must">${r.must_connect ? "must connect" : "nice to have"}</dd>
|
||
</dl>
|
||
<label class="field">
|
||
<span>Notes</span>
|
||
<textarea class="inline-input" id="rq-notes" rows="2"></textarea>
|
||
</label>
|
||
<div class="inspector-actions">
|
||
<button type="button" class="btn btn-tiny" id="rq-edit">Edit</button>
|
||
<button type="button" class="btn btn-tiny" id="rq-toggle">${r.must_connect ? "Make nice" : "Make must"}</button>
|
||
<button type="button" class="btn btn-danger btn-tiny" id="rq-del">Delete</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#rq-from-name").textContent = a ? a.name : `#${r.from_device_id}`;
|
||
body.querySelector("#rq-to-name").textContent = b ? b.name : `#${r.to_device_id}`;
|
||
body.querySelector("#rq-ct").textContent = ctName ?? "solver picks";
|
||
body.querySelector("#rq-notes").value = r.notes ?? "";
|
||
|
||
bindDebouncedRename(body.querySelector("#rq-notes"), async (notes) => {
|
||
if (!state.active) return;
|
||
const updated = await patchRequirement(state.active.id, r.id, { notes });
|
||
Object.assign(r, updated);
|
||
renderRequirements();
|
||
});
|
||
|
||
body.querySelector("#rq-edit").addEventListener("click", () => openRequirementModal(r));
|
||
body.querySelector("#rq-toggle").addEventListener("click", async () => {
|
||
if (!state.active) return;
|
||
try {
|
||
const updated = await patchRequirement(state.active.id, r.id, { must_connect: !r.must_connect });
|
||
Object.assign(r, updated);
|
||
render();
|
||
} catch (e) { alert(`Update failed: ${e.message}`); }
|
||
});
|
||
body.querySelector("#rq-del").addEventListener("click", async () => {
|
||
if (!state.active) return;
|
||
if (!confirm("Delete this requirement?")) return;
|
||
try {
|
||
await deleteRequirement(state.active.id, r.id);
|
||
state.requirements = state.requirements.filter((x) => x.id !== r.id);
|
||
state.selection = null;
|
||
render();
|
||
} catch (e) { alert(`Delete failed: ${e.message}`); }
|
||
});
|
||
}
|
||
|
||
// ---------- requirement drag gesture ---------- //
|
||
|
||
/** Pointerdown on a device with `req` tool armed → draw a dashed line to
|
||
* the pointer position. Pointerup on another device opens the modal
|
||
* with from/to pre-filled. Anywhere else cancels. */
|
||
function startRequirementDrag(e, fromDeviceID) {
|
||
if (!state.active) return;
|
||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||
const fromDev = state.devices.find((d) => d.id === fromDeviceID);
|
||
if (!fromDev) return;
|
||
const sx = fromDev.x + fromDev.width / 2;
|
||
const sy = fromDev.y + fromDev.height / 2;
|
||
|
||
const line = svgEl("line", {
|
||
x1: sx, y1: sy, x2: sx, y2: sy,
|
||
class: "req-drag-line",
|
||
});
|
||
svg.append(line);
|
||
svg.setPointerCapture(e.pointerId);
|
||
|
||
const onMove = (ev) => {
|
||
const p = svgPoint(ev);
|
||
line.setAttribute("x2", String(p.x));
|
||
line.setAttribute("y2", String(p.y));
|
||
};
|
||
const onUp = (ev) => {
|
||
svg.removeEventListener("pointermove", onMove);
|
||
svg.removeEventListener("pointerup", onUp);
|
||
svg.releasePointerCapture(e.pointerId);
|
||
line.remove();
|
||
|
||
// Hit-test: which device did the pointer land on?
|
||
let toDeviceID = null;
|
||
if (ev.target instanceof Element) {
|
||
const g = ev.target.closest("[data-device-id]");
|
||
if (g) toDeviceID = Number(g.getAttribute("data-device-id"));
|
||
}
|
||
armTool(null);
|
||
if (!toDeviceID || toDeviceID === fromDeviceID) return; // cancel
|
||
openRequirementModal(null, { from: fromDeviceID, to: toDeviceID });
|
||
};
|
||
svg.addEventListener("pointermove", onMove);
|
||
svg.addEventListener("pointerup", onUp);
|
||
}
|
||
|
||
// ---------- requirement modal ---------- //
|
||
|
||
/**
|
||
* Open the +Requirement / edit modal. Pass `existing` to edit an existing
|
||
* row; pass `{from, to}` (device ids, both optional) to pre-fill a new row.
|
||
*/
|
||
function openRequirementModal(existing, prefill = {}) {
|
||
if (!state.active) return;
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-requirement"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-requirement"));
|
||
const selFrom = /** @type {HTMLSelectElement} */ ($("#rq-from"));
|
||
const selTo = /** @type {HTMLSelectElement} */ ($("#rq-to"));
|
||
const selCt = /** @type {HTMLSelectElement} */ ($("#rq-cable"));
|
||
const mustCb = /** @type {HTMLInputElement} */ ($("#rq-must"));
|
||
const err = $("#rq-error");
|
||
const title = $("#rq-title");
|
||
showError(err, "");
|
||
|
||
title.textContent = existing ? "Edit requirement" : "New requirement";
|
||
|
||
// Populate the device pickers.
|
||
for (const sel of [selFrom, selTo]) {
|
||
sel.innerHTML = "";
|
||
for (const d of state.devices) {
|
||
sel.append(new Option(d.name, String(d.id)));
|
||
}
|
||
}
|
||
// Cable-type picker: "solver picks" + every cable type.
|
||
selCt.innerHTML = "";
|
||
selCt.append(new Option("— solver picks —", ""));
|
||
for (const ct of state.cableTypes) {
|
||
selCt.append(new Option(ct.name, String(ct.id)));
|
||
}
|
||
|
||
if (existing) {
|
||
selFrom.value = String(existing.from_device_id);
|
||
selTo.value = String(existing.to_device_id);
|
||
selCt.value = existing.preferred_cable_type_id != null ? String(existing.preferred_cable_type_id) : "";
|
||
mustCb.checked = existing.must_connect;
|
||
form.elements.namedItem("notes").value = existing.notes || "";
|
||
} else {
|
||
if (prefill.from != null) selFrom.value = String(prefill.from);
|
||
if (prefill.to != null) selTo.value = String(prefill.to);
|
||
if (selFrom.value === selTo.value && state.devices.length >= 2) {
|
||
// Pick a different "to" so the form starts valid.
|
||
const other = state.devices.find((d) => String(d.id) !== selFrom.value);
|
||
if (other) selTo.value = String(other.id);
|
||
}
|
||
selCt.value = "";
|
||
mustCb.checked = true;
|
||
form.elements.namedItem("notes").value = "";
|
||
}
|
||
|
||
dlg.showModal();
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const fromID = Number(selFrom.value);
|
||
const toID = Number(selTo.value);
|
||
if (!fromID || !toID || fromID === toID) {
|
||
showError(err, "from and to must be two different devices");
|
||
return;
|
||
}
|
||
const ctRaw = selCt.value;
|
||
const notes = String(form.elements.namedItem("notes").value || "");
|
||
const must = mustCb.checked;
|
||
try {
|
||
if (existing) {
|
||
const body = {
|
||
must_connect: must,
|
||
notes,
|
||
// tri-state: empty string → null on the wire (= clear)
|
||
preferred_cable_type_id: ctRaw === "" ? null : Number(ctRaw),
|
||
};
|
||
const updated = await patchRequirement(state.active.id, existing.id, body);
|
||
Object.assign(existing, updated);
|
||
} else {
|
||
const body = {
|
||
from_device_id: fromID,
|
||
to_device_id: toID,
|
||
must_connect: must,
|
||
notes,
|
||
};
|
||
if (ctRaw !== "") body.preferred_cable_type_id = Number(ctRaw);
|
||
const created = await createRequirement(state.active.id, body);
|
||
state.requirements.push(created);
|
||
state.selection = { kind: "requirement", id: created.id };
|
||
}
|
||
dlg.close();
|
||
render();
|
||
} catch (ex) {
|
||
showError(err, ex.message || "Save failed");
|
||
}
|
||
};
|
||
}
|
||
|
||
function renderInspectorIO(body, id) {
|
||
const m = state.ioMarkers.find((x) => x.id === id);
|
||
if (!m) { body.innerHTML = ""; return; }
|
||
const frame = m.frame_id ? state.frames.find((f) => f.id === m.frame_id) : null;
|
||
body.innerHTML = `
|
||
<p class="section-title">IO marker</p>
|
||
<label class="field">
|
||
<span>Label</span>
|
||
<input class="inline-input" id="io-label" value="" />
|
||
</label>
|
||
<dl>
|
||
<dt>x</dt><dd id="io-x"></dd>
|
||
<dt>y</dt><dd id="io-y"></dd>
|
||
<dt>frame</dt><dd id="io-frame"></dd>
|
||
</dl>
|
||
<p class="muted" style="font-size:12px">
|
||
Wall-outlet terminator. Power-by-convention; a future cable terminating
|
||
here means "plugged into a socket outside the diagram".
|
||
</p>
|
||
<div class="inspector-actions">
|
||
<button type="button" class="btn btn-danger btn-tiny" id="io-delete">Delete</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#io-label").value = m.label;
|
||
body.querySelector("#io-x").textContent = m.x.toFixed(0);
|
||
body.querySelector("#io-y").textContent = m.y.toFixed(0);
|
||
body.querySelector("#io-frame").textContent = frame ? frame.name : "—";
|
||
|
||
bindDebouncedRename(body.querySelector("#io-label"), async (label) => {
|
||
if (!state.active) return;
|
||
const updated = await patchIOMarker(state.active.id, m.id, { label });
|
||
Object.assign(m, updated);
|
||
renderCanvas();
|
||
});
|
||
|
||
body.querySelector("#io-delete").addEventListener("click", () => {
|
||
if (!state.active) return;
|
||
if (!confirm(`Delete IO marker "${m.label}"?`)) return;
|
||
deleteIOMarker(state.active.id, m.id).then(() => {
|
||
state.ioMarkers = state.ioMarkers.filter((x) => x.id !== m.id);
|
||
state.selection = null;
|
||
render();
|
||
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
||
});
|
||
}
|
||
|
||
function renderInspectorCableType(body, id) {
|
||
const t = state.cableTypes.find((x) => x.id === id);
|
||
if (!t) { body.innerHTML = ""; return; }
|
||
// The "used by N cables" counter is purely informational in slice 3.
|
||
// Slice 7+ will populate state.cables; until then we surface 0.
|
||
const usedBy = 0;
|
||
const banner = `
|
||
<p class="banner" style="margin: 0 0 12px 0">
|
||
Cable types are shared across all projects. Renaming or recolouring
|
||
affects every project.
|
||
</p>
|
||
`;
|
||
body.innerHTML = `
|
||
<p class="section-title">Cable type</p>
|
||
${banner}
|
||
<label class="field">
|
||
<span>Name</span>
|
||
<input class="inline-input" id="ct-name" value="" />
|
||
</label>
|
||
<label class="field">
|
||
<span>Colour</span>
|
||
<input type="color" class="inline-input" id="ct-color" />
|
||
</label>
|
||
<dl>
|
||
<dt>used by</dt><dd id="ct-used"></dd>
|
||
</dl>
|
||
<div class="inspector-actions">
|
||
<button type="button" class="btn btn-danger btn-tiny" id="ct-delete">Delete</button>
|
||
</div>
|
||
`;
|
||
body.querySelector("#ct-name").value = t.name;
|
||
body.querySelector("#ct-color").value = t.color;
|
||
body.querySelector("#ct-used").textContent = `${usedBy} cable${usedBy === 1 ? "" : "s"}`;
|
||
|
||
bindDebouncedRename(body.querySelector("#ct-name"), async (name) => {
|
||
const updated = await patchCableType(t.id, { name });
|
||
Object.assign(t, updated);
|
||
render();
|
||
});
|
||
|
||
body.querySelector("#ct-color").addEventListener("change", async (e) => {
|
||
const color = /** @type {HTMLInputElement} */ (e.target).value;
|
||
try {
|
||
const updated = await patchCableType(t.id, { color });
|
||
Object.assign(t, updated);
|
||
render();
|
||
} catch (err) {
|
||
alert(`Colour update failed: ${err.message}`);
|
||
}
|
||
});
|
||
|
||
body.querySelector("#ct-delete").addEventListener("click", async () => {
|
||
if (!confirm(`Delete cable type "${t.name}"? Blocked if any cable uses it.`)) return;
|
||
try {
|
||
await deleteCableType(t.id);
|
||
state.cableTypes = await listCableTypes();
|
||
if (state.activeTypeId === t.id) state.activeTypeId = null;
|
||
state.selection = null;
|
||
render();
|
||
} catch (err) {
|
||
const n = err.details?.in_use_by_cables;
|
||
alert(n != null
|
||
? `Cannot delete "${t.name}" — in use by ${n} cable${n === 1 ? "" : "s"}.`
|
||
: `Delete failed: ${err.message}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function bindDebouncedRename(input, persist) {
|
||
let timer = null;
|
||
input.addEventListener("input", () => {
|
||
if (timer) clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
const v = input.value.trim();
|
||
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
||
}, 400);
|
||
});
|
||
input.addEventListener("blur", () => {
|
||
if (timer) { clearTimeout(timer); timer = null; }
|
||
const v = input.value.trim();
|
||
if (v && v !== input.dataset.last) {
|
||
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
||
input.dataset.last = v;
|
||
}
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
renderProjectPicker();
|
||
renderLegend();
|
||
renderRequirements();
|
||
renderCanvas();
|
||
renderEmptyHint();
|
||
renderInspector();
|
||
}
|
||
|
||
// ---------- requirements sidebar ---------- //
|
||
|
||
function renderRequirements() {
|
||
const ul = $("#requirement-list");
|
||
ul.innerHTML = "";
|
||
const deviceById = new Map(state.devices.map((d) => [d.id, d]));
|
||
const cableTypeById = new Map(state.cableTypes.map((t) => [t.id, t]));
|
||
for (const r of state.requirements) {
|
||
const a = deviceById.get(r.from_device_id);
|
||
const b = deviceById.get(r.to_device_id);
|
||
if (!a || !b) continue; // a device delete cascade — UI will rerender soon
|
||
const ct = r.preferred_cable_type_id != null ? cableTypeById.get(r.preferred_cable_type_id) : null;
|
||
const li = document.createElement("li");
|
||
li.className = "requirement-row";
|
||
li.dataset.id = String(r.id);
|
||
if (state.selection?.kind === "requirement" && state.selection.id === r.id) {
|
||
li.setAttribute("aria-current", "true");
|
||
}
|
||
const cableLabel = ct ? `${ct.name}` : "solver picks";
|
||
li.innerHTML = `
|
||
<span class="pair">
|
||
${escapeHtml(a.name)} ↔ ${escapeHtml(b.name)}
|
||
<span class="type"> · ${escapeHtml(cableLabel)}</span>
|
||
</span>
|
||
<span class="badge ${r.must_connect ? "must" : "nice"}">${r.must_connect ? "must" : "nice"}</span>
|
||
`;
|
||
li.addEventListener("click", () => {
|
||
state.selection = { kind: "requirement", id: r.id };
|
||
render();
|
||
});
|
||
ul.append(li);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
}[c]));
|
||
}
|
||
|
||
// ---------- active project ---------- //
|
||
|
||
async function activateProject(id) {
|
||
if (id == null) {
|
||
state.active = null;
|
||
state.frames = [];
|
||
state.devices = [];
|
||
state.ports = [];
|
||
state.ioMarkers = [];
|
||
state.requirements = [];
|
||
state.cables = [];
|
||
state.bundles = [];
|
||
state.selection = null;
|
||
setActiveInURL(null);
|
||
render();
|
||
return;
|
||
}
|
||
try {
|
||
const snap = await getSnapshot(id);
|
||
state.active = snap.project;
|
||
state.frames = snap.frames || [];
|
||
state.devices = snap.devices || [];
|
||
state.ioMarkers = snap.io_markers || [];
|
||
state.ports = snap.ports || [];
|
||
state.cables = snap.cables || [];
|
||
state.bundles = snap.bundles || [];
|
||
state.requirements = snap.connection_requirements || [];
|
||
state.cableTypes = snap.cable_types || [];
|
||
state.selection = null;
|
||
setActiveInURL(id);
|
||
// Hydrate the device-type catalog for this project — used by the
|
||
// +Dev modal's dropdown + the device inspector's "Type" row. Done in
|
||
// parallel after snapshot loads (small response, doesn't gate render).
|
||
try {
|
||
state.deviceTypes = await listDeviceTypesForProject(id) || [];
|
||
} catch (_) {
|
||
// Don't fail the whole load if catalog fetch fails — the +Dev
|
||
// modal can show a degraded "Custom only" mode.
|
||
state.deviceTypes = [];
|
||
}
|
||
render();
|
||
} catch (err) {
|
||
if (err.status === 404) {
|
||
state.active = null;
|
||
state.frames = [];
|
||
state.devices = [];
|
||
state.ports = [];
|
||
state.ioMarkers = [];
|
||
state.requirements = [];
|
||
state.cables = [];
|
||
state.bundles = [];
|
||
setActiveInURL(null);
|
||
render();
|
||
} else {
|
||
alert(`Failed to load project: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- tools ---------- //
|
||
|
||
function armTool(tool) {
|
||
if (state.tool === tool) tool = null; // toggle off
|
||
state.tool = tool;
|
||
const wrap = $(".canvas-wrap");
|
||
wrap.classList.toggle("tool-frame", tool === "frame");
|
||
wrap.classList.toggle("tool-device", tool === "device");
|
||
wrap.classList.toggle("tool-port", tool === "port");
|
||
wrap.classList.toggle("tool-cable", tool === "cable");
|
||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
||
}
|
||
if (tool !== "port") {
|
||
state.portToolDevice = null;
|
||
state.portToolTypeID = null;
|
||
}
|
||
if (tool !== "cable") {
|
||
state.cableDrawFromPortID = null;
|
||
}
|
||
}
|
||
|
||
/** Slice 7: device inspector arms +Port for a specific device + type. */
|
||
function armPortTool(deviceID, typeID) {
|
||
state.portToolDevice = deviceID;
|
||
state.portToolTypeID = typeID;
|
||
armTool("port");
|
||
}
|
||
|
||
function bindTools() {
|
||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
|
||
}
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
// Avoid stealing keys while user is typing into an input.
|
||
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
|
||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||
else if (e.key === "r" || e.key === "R") armTool("req");
|
||
else if (e.key === "s" || e.key === "S") openSolveModal();
|
||
});
|
||
|
||
// Canvas-level pointerdown handles tool activation + selection clearing.
|
||
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
|
||
}
|
||
|
||
let rubberBand = /** @type {SVGRectElement|null} */ (null);
|
||
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
|
||
|
||
function onCanvasPointerDown(e) {
|
||
if (!state.active) return;
|
||
|
||
const p = svgPoint(e);
|
||
|
||
// Armed tool wins: a click anywhere on the canvas — including on top
|
||
// of an existing frame or device — fires the tool. The +Dev tool needs
|
||
// this so m can drop a device inside a frame; without it the frame's
|
||
// own pointerdown handler would steal the click and start a drag.
|
||
//
|
||
// e.preventDefault() suppresses the compatibility mousedown's default
|
||
// focus-shift. Without it, the freshly-focused inline-namer input gets
|
||
// blurred ~6ms later by the browser's "focus nearest focusable ancestor
|
||
// or blur active" behaviour (SVG rects are not focusable), and the
|
||
// blur handler tears the namer down before m can type. Root cause +
|
||
// verified fix from sherlock's Playwright shift; see docs/sherlock-+dev-bug.md
|
||
// for the full trace.
|
||
if (state.tool === "frame") {
|
||
e.preventDefault();
|
||
startFrameRubberBand(e, p);
|
||
return;
|
||
}
|
||
if (state.tool === "device") {
|
||
e.preventDefault();
|
||
placeDeviceAt(p);
|
||
return;
|
||
}
|
||
if (state.tool === "port") {
|
||
e.preventDefault();
|
||
placePortAt(p);
|
||
return;
|
||
}
|
||
if (state.tool === "io") {
|
||
e.preventDefault();
|
||
placeIOMarkerAt(p);
|
||
return;
|
||
}
|
||
|
||
// No tool armed: clicks that started on a device/frame/io go to their
|
||
// own handlers (drag / select). Leave them alone.
|
||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return;
|
||
|
||
// Plain canvas click = clear selection.
|
||
if (state.selection) { state.selection = null; render(); }
|
||
}
|
||
|
||
function startFrameRubberBand(e, p0) {
|
||
if (!state.active) return;
|
||
rubberStart = p0;
|
||
rubberBand = svgEl("rect", {
|
||
x: p0.x, y: p0.y, width: 0, height: 0,
|
||
class: "rubber-band", rx: 6, ry: 6,
|
||
});
|
||
$("#canvas").append(rubberBand);
|
||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||
svg.setPointerCapture(e.pointerId);
|
||
|
||
const onMove = (ev) => {
|
||
if (!rubberBand || !rubberStart) return;
|
||
const p = svgPoint(ev);
|
||
const x = Math.min(rubberStart.x, p.x);
|
||
const y = Math.min(rubberStart.y, p.y);
|
||
rubberBand.setAttribute("x", String(x));
|
||
rubberBand.setAttribute("y", String(y));
|
||
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
|
||
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
|
||
};
|
||
const onUp = async (ev) => {
|
||
svg.removeEventListener("pointermove", onMove);
|
||
svg.removeEventListener("pointerup", onUp);
|
||
svg.releasePointerCapture(e.pointerId);
|
||
const rect = rubberBand;
|
||
const start = rubberStart;
|
||
rubberBand = null;
|
||
rubberStart = null;
|
||
if (!rect || !start) return;
|
||
const w = Number(rect.getAttribute("width"));
|
||
const h = Number(rect.getAttribute("height"));
|
||
const x = Number(rect.getAttribute("x"));
|
||
const y = Number(rect.getAttribute("y"));
|
||
rect.remove();
|
||
if (w < 80 || h < 60) { armTool(null); return; }
|
||
armTool(null);
|
||
const name = await promptInline("Frame name", x + w / 2, y + 16);
|
||
if (!name || !state.active) return;
|
||
try {
|
||
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
|
||
state.frames.push(f);
|
||
state.selection = { kind: "frame", id: f.id };
|
||
render();
|
||
} catch (err) {
|
||
alert(`Create frame failed: ${err.message}`);
|
||
}
|
||
};
|
||
svg.addEventListener("pointermove", onMove);
|
||
svg.addEventListener("pointerup", onUp);
|
||
}
|
||
|
||
async function placeDeviceAt(p) {
|
||
if (!state.active) return;
|
||
armTool(null);
|
||
const W = 100, H = 35;
|
||
const x = p.x - W / 2;
|
||
const y = p.y - H / 2;
|
||
const frame = frameAt(p.x, p.y);
|
||
// Modal-driven flow (v4 slice 4): pick type + name in one form. Click
|
||
// position is captured here and POSTed on submit.
|
||
openNewDeviceModal({ x, y, width: W, height: H, frame_id: frame?.id ?? null });
|
||
}
|
||
|
||
function nextNameFor(typeName) {
|
||
// Auto-pick a name like "PC" / "PC-2" / "PC-3" against current devices.
|
||
const taken = new Set(state.devices.map((d) => d.name));
|
||
if (!taken.has(typeName)) return typeName;
|
||
for (let i = 2; i < 1000; i++) {
|
||
const candidate = `${typeName}-${i}`;
|
||
if (!taken.has(candidate)) return candidate;
|
||
}
|
||
return typeName;
|
||
}
|
||
|
||
function openNewDeviceModal(geom) {
|
||
if (!state.active) return;
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-device"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-new-device"));
|
||
const sel = /** @type {HTMLSelectElement} */ ($("#nd-type"));
|
||
const nameInput = /** @type {HTMLInputElement} */ ($("#nd-name"));
|
||
const err = $("#nd-error");
|
||
showError(err, "");
|
||
form.reset();
|
||
|
||
// Build the dropdown: <optgroup label="kind"> for built-ins grouped by
|
||
// their `kind`, then project-custom, then a "Custom (no type)" option.
|
||
sel.innerHTML = "";
|
||
const builtIns = state.deviceTypes.filter((t) => t.built_in);
|
||
const customs = state.deviceTypes.filter((t) => !t.built_in);
|
||
|
||
const byKind = new Map();
|
||
for (const t of builtIns) {
|
||
const k = t.kind || "generic";
|
||
const arr = byKind.get(k) || [];
|
||
arr.push(t);
|
||
byKind.set(k, arr);
|
||
}
|
||
for (const [kind, arr] of byKind) {
|
||
const og = document.createElement("optgroup");
|
||
og.label = kind;
|
||
for (const t of arr) {
|
||
const opt = new Option(t.name, String(t.id));
|
||
og.append(opt);
|
||
}
|
||
sel.append(og);
|
||
}
|
||
if (customs.length) {
|
||
const og = document.createElement("optgroup");
|
||
og.label = "custom";
|
||
for (const t of customs) {
|
||
og.append(new Option(t.name, String(t.id)));
|
||
}
|
||
sel.append(og);
|
||
}
|
||
const customOpt = new Option("Custom (no type)", "");
|
||
sel.append(customOpt);
|
||
|
||
// Default to the first built-in (NAS in m's catalog) so m sees a
|
||
// sensible first option. Auto-fill the name to match.
|
||
sel.value = builtIns[0] ? String(builtIns[0].id) : "";
|
||
syncNameToType();
|
||
|
||
sel.onchange = syncNameToType;
|
||
|
||
function syncNameToType() {
|
||
const idStr = sel.value;
|
||
if (!idStr) { nameInput.value = ""; return; }
|
||
const t = state.deviceTypes.find((x) => String(x.id) === idStr);
|
||
if (!t) return;
|
||
nameInput.value = nextNameFor(t.name);
|
||
}
|
||
|
||
dlg.showModal();
|
||
nameInput.focus();
|
||
nameInput.select();
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const name = nameInput.value.trim();
|
||
if (!name) { showError(err, "Name is required"); return; }
|
||
const idStr = sel.value;
|
||
const body = {
|
||
name,
|
||
x: geom.x, y: geom.y, width: geom.width, height: geom.height,
|
||
};
|
||
if (geom.frame_id != null) body.frame_id = geom.frame_id;
|
||
if (idStr) body.type_id = Number(idStr);
|
||
try {
|
||
const d = await createDevice(state.active.id, body);
|
||
state.devices.push(d);
|
||
// Re-fetch ports for the project — the server seeded them in the
|
||
// same transaction, so they're already in the DB.
|
||
const snap = await getSnapshot(state.active.id);
|
||
state.ports = snap.ports || [];
|
||
state.selection = { kind: "device", id: d.id };
|
||
dlg.close();
|
||
render();
|
||
} catch (e) {
|
||
showError(err, e.message || "Create failed");
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
|
||
* y_off) relative to the device's top-left + a debug-friendly edge name. */
|
||
function snapToDeviceEdge(device, x, y) {
|
||
// Distance from the point to each of the four edges.
|
||
const dxLeft = Math.abs(x - device.x);
|
||
const dxRight = Math.abs((device.x + device.width) - x);
|
||
const dyTop = Math.abs(y - device.y);
|
||
const dyBottom = Math.abs((device.y + device.height) - y);
|
||
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
|
||
// Clamp the perpendicular coordinate so the port sits *on* the rect.
|
||
const localX = Math.max(0, Math.min(device.width, x - device.x));
|
||
const localY = Math.max(0, Math.min(device.height, y - device.y));
|
||
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
||
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
||
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
||
return { xOff: localX, yOff: device.height, edge: "bottom" };
|
||
}
|
||
|
||
/** Port-click flow:
|
||
* 1) No source picked yet → this port becomes the source. Highlight it.
|
||
* 2) Source already picked → this port is the target. POST a cable
|
||
* with `from_port_id` / `to_port_id`, type from the source port,
|
||
* auto=0. Shift-click flips the target to "bind to whole device"
|
||
* (uses `to_device_id` instead). */
|
||
function onPortPointerDown(e, port) {
|
||
if (!state.active) return;
|
||
if (state.tool && state.tool !== "cable") return; // other tool wins
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
if (state.cableDrawFromPortID == null) {
|
||
state.cableDrawFromPortID = port.id;
|
||
armTool("cable"); // get the crosshair cursor + visual cue
|
||
render();
|
||
return;
|
||
}
|
||
if (state.cableDrawFromPortID === port.id) {
|
||
// Cancel — clicked the same port again.
|
||
state.cableDrawFromPortID = null;
|
||
armTool(null);
|
||
render();
|
||
return;
|
||
}
|
||
finishCableDrawAt(port, e.shiftKey);
|
||
}
|
||
|
||
async function finishCableDrawAt(targetPort, shiftKey) {
|
||
if (!state.active) return;
|
||
const fromPortID = state.cableDrawFromPortID;
|
||
state.cableDrawFromPortID = null;
|
||
armTool(null);
|
||
if (fromPortID == null) return;
|
||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||
if (!sourcePort) { render(); return; }
|
||
|
||
// Body: shift-click on a port = bind to that port's parent device
|
||
// (whole-device cable) instead of the port. Plain click = port-to-port.
|
||
const body = {
|
||
type_id: sourcePort.type_id,
|
||
auto: false,
|
||
from: { port_id: fromPortID },
|
||
to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id },
|
||
};
|
||
if (!shiftKey && targetPort.type_id !== sourcePort.type_id) {
|
||
if (!confirm(`Target port is a different cable type. Connect anyway?`)) {
|
||
render();
|
||
return;
|
||
}
|
||
}
|
||
try {
|
||
const c = await createCableAPI(state.active.id, body);
|
||
state.cables.push(c);
|
||
state.selection = { kind: "cable", id: c.id };
|
||
render();
|
||
} catch (e) {
|
||
alert(`Create cable failed: ${e.message}`);
|
||
render();
|
||
}
|
||
}
|
||
|
||
/** Click on an IO marker while a cable draw is in progress → terminate
|
||
* the cable on that IO. Plugged into the IO marker's pointerdown
|
||
* handler in renderCanvas. */
|
||
async function finishCableDrawAtIO(ioMarker) {
|
||
if (!state.active) return;
|
||
const fromPortID = state.cableDrawFromPortID;
|
||
state.cableDrawFromPortID = null;
|
||
armTool(null);
|
||
if (fromPortID == null) return;
|
||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||
if (!sourcePort) { render(); return; }
|
||
const body = {
|
||
type_id: sourcePort.type_id,
|
||
auto: false,
|
||
from: { port_id: fromPortID },
|
||
to: { io_id: ioMarker.id },
|
||
};
|
||
try {
|
||
const c = await createCableAPI(state.active.id, body);
|
||
state.cables.push(c);
|
||
state.selection = { kind: "cable", id: c.id };
|
||
render();
|
||
} catch (e) {
|
||
alert(`Create cable failed: ${e.message}`);
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function placePortAt(p) {
|
||
if (!state.active) return;
|
||
const did = state.portToolDevice;
|
||
const tid = state.portToolTypeID;
|
||
if (did == null || tid == null) { armTool(null); return; }
|
||
const dev = state.devices.find((d) => d.id === did);
|
||
if (!dev) { armTool(null); return; }
|
||
const snap = snapToDeviceEdge(dev, p.x, p.y);
|
||
try {
|
||
const port = await createPort(state.active.id, did, {
|
||
type_id: tid,
|
||
x_offset: snap.xOff,
|
||
y_offset: snap.yOff,
|
||
});
|
||
state.ports.push(port);
|
||
armTool(null);
|
||
render();
|
||
} catch (e) {
|
||
alert(`Add port failed: ${e.message}`);
|
||
armTool(null);
|
||
}
|
||
}
|
||
|
||
async function placeIOMarkerAt(p) {
|
||
if (!state.active) return;
|
||
armTool(null);
|
||
const x = p.x - IO_SIZE / 2;
|
||
const y = p.y - IO_SIZE / 2;
|
||
// Label is optional; a blank prompt commits with the default "IO"
|
||
// (server-side fallback in CreateIOMarker). Esc cancels.
|
||
const label = await promptInline("Outlet label (Enter for 'IO')", p.x, p.y - IO_SIZE);
|
||
if (label === null || !state.active) return;
|
||
const frame = frameAt(p.x, p.y);
|
||
try {
|
||
const m = await createIOMarker(state.active.id, {
|
||
label: label || undefined,
|
||
x, y,
|
||
frame_id: frame ? frame.id : undefined,
|
||
});
|
||
state.ioMarkers.push(m);
|
||
state.selection = { kind: "io", id: m.id };
|
||
render();
|
||
} catch (err) {
|
||
alert(`Create IO marker failed: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// ---------- inline namer (foreignObject overlay) ---------- //
|
||
|
||
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
|
||
|
||
function cancelInlineNamer() {
|
||
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
|
||
}
|
||
|
||
function promptInline(placeholder, cx, cy) {
|
||
cancelInlineNamer();
|
||
return new Promise((resolve) => {
|
||
const fo = document.createElementNS(SVG_NS, "foreignObject");
|
||
fo.setAttribute("x", String(cx - 110));
|
||
fo.setAttribute("y", String(cy - 14));
|
||
fo.setAttribute("width", "220");
|
||
fo.setAttribute("height", "28");
|
||
fo.innerHTML = `
|
||
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
|
||
<input type="text" placeholder="${placeholder}" />
|
||
</div>
|
||
`;
|
||
$("#canvas").append(fo);
|
||
activeNamer = fo;
|
||
const input = fo.querySelector("input");
|
||
input.focus();
|
||
const done = (val) => {
|
||
// Clear the flag *before* removing the node. Enter-key triggers a
|
||
// synchronous blur on the input, which re-enters done() — and if
|
||
// fo.remove() ran first, the second call hits a
|
||
// "node no longer a child" pageerror. Reordering makes the second
|
||
// re-entry a no-op (activeNamer is already null).
|
||
if (activeNamer !== fo) return;
|
||
activeNamer = null;
|
||
fo.remove();
|
||
resolve(val);
|
||
};
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") done(input.value.trim());
|
||
else if (e.key === "Escape") done(null);
|
||
});
|
||
input.addEventListener("blur", () => done(input.value.trim() || null));
|
||
});
|
||
}
|
||
|
||
// ---------- drag ---------- //
|
||
|
||
function startDrag(e, kind, id) {
|
||
if (!state.active) return;
|
||
// Req tool intercepts device-down to start the drag-A-to-B gesture.
|
||
if (state.tool === "req" && kind === "device") {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
startRequirementDrag(e, id);
|
||
return;
|
||
}
|
||
if (state.tool) return; // any other tool — let the canvas-level handler run
|
||
e.stopPropagation();
|
||
state.selection = { kind, id };
|
||
// Render immediately so the inspector reflects the new selection from
|
||
// pointerdown — independent of whether the drag-completion render at
|
||
// the end of onUp runs. (Previously, the inspector only updated if
|
||
// pointerup completed cleanly; any throw in onUp left it stale.)
|
||
render();
|
||
|
||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||
const start = svgPoint(e);
|
||
/** @type {Frame|Device|IOMarker|undefined} */
|
||
let obj;
|
||
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
|
||
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
|
||
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
|
||
if (!obj) return;
|
||
const startX = obj.x;
|
||
const startY = obj.y;
|
||
|
||
// For frame drags, remember the contained devices + IO markers + their
|
||
// offsets so they follow the frame visually + persist on release.
|
||
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
|
||
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
|
||
if (kind === "frame") {
|
||
for (const d of state.devices) {
|
||
if (d.frame_id === obj.id) {
|
||
trackedDevices.push({ d, sx: d.x, sy: d.y });
|
||
}
|
||
}
|
||
for (const m of state.ioMarkers) {
|
||
if (m.frame_id === obj.id) {
|
||
trackedIOs.push({ m, sx: m.x, sy: m.y });
|
||
}
|
||
}
|
||
}
|
||
|
||
e.currentTarget.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);
|
||
e.currentTarget.classList.remove("dragging");
|
||
|
||
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
|
||
if (!state.active) return;
|
||
|
||
try {
|
||
if (kind === "frame") {
|
||
const f = /** @type {Frame} */ (obj);
|
||
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
|
||
// Persist contained devices + IO markers too.
|
||
await Promise.all([
|
||
...trackedDevices.map((t) =>
|
||
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
||
...trackedIOs.map((t) =>
|
||
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
|
||
]);
|
||
} else if (kind === "device") {
|
||
const d = /** @type {Device} */ (obj);
|
||
// Recompute frame_id from drop point (centre of device).
|
||
const cx = d.x + d.width / 2;
|
||
const cy = d.y + d.height / 2;
|
||
const targetFrame = frameAt(cx, cy);
|
||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||
const patchBody = { x: d.x, y: d.y };
|
||
if ((d.frame_id ?? null) !== newFrameID) {
|
||
patchBody.frame_id = newFrameID; // explicit null = clear
|
||
d.frame_id = newFrameID;
|
||
}
|
||
await patchDevice(state.active.id, d.id, patchBody);
|
||
} else /* io */ {
|
||
const m = /** @type {IOMarker} */ (obj);
|
||
const cx = m.x + IO_SIZE / 2;
|
||
const cy = m.y + IO_SIZE / 2;
|
||
const targetFrame = frameAt(cx, cy);
|
||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||
const patchBody = { x: m.x, y: m.y };
|
||
if ((m.frame_id ?? null) !== newFrameID) {
|
||
patchBody.frame_id = newFrameID;
|
||
m.frame_id = newFrameID;
|
||
}
|
||
await patchIOMarker(state.active.id, m.id, patchBody);
|
||
}
|
||
} catch (err) {
|
||
alert(`Save failed: ${err.message}`);
|
||
}
|
||
render();
|
||
};
|
||
svg.addEventListener("pointermove", onMove);
|
||
svg.addEventListener("pointerup", onUp);
|
||
}
|
||
|
||
// ---------- modals (project / cable type) ---------- //
|
||
|
||
function bindCloseButtons(dialog) {
|
||
dialog.querySelectorAll("[data-close]").forEach((btn) =>
|
||
btn.addEventListener("click", () => dialog.close()),
|
||
);
|
||
}
|
||
|
||
function showError(el, msg) {
|
||
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
|
||
el.textContent = msg;
|
||
setHidden(el, false);
|
||
}
|
||
|
||
function openNewProjectModal() {
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
|
||
const err = $("#np-error");
|
||
form.reset();
|
||
showError(err, "");
|
||
dlg.showModal();
|
||
form.elements.namedItem("name").focus();
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(form);
|
||
const body = {
|
||
name: String(fd.get("name") || "").trim(),
|
||
drawing_name: String(fd.get("drawing_name") || "").trim(),
|
||
description: String(fd.get("description") || ""),
|
||
};
|
||
if (!body.drawing_name) delete body.drawing_name;
|
||
try {
|
||
const p = await createProject(body);
|
||
state.projects = await listProjects();
|
||
dlg.close();
|
||
await activateProject(p.id);
|
||
} catch (e) {
|
||
showError(err, e.message || "Failed to create project");
|
||
}
|
||
};
|
||
}
|
||
|
||
function openCableTypeModal(existing) {
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
|
||
const err = $("#ct-error");
|
||
const title = $("#ct-title");
|
||
form.reset();
|
||
showError(err, "");
|
||
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
|
||
|
||
if (existing) {
|
||
form.elements.namedItem("name").value = existing.name;
|
||
form.elements.namedItem("color").value = existing.color;
|
||
} else {
|
||
form.elements.namedItem("color").value = "#1971c2";
|
||
}
|
||
|
||
const actions = form.querySelector(".actions");
|
||
actions.querySelector(".btn-delete-type")?.remove();
|
||
if (existing) {
|
||
const del = document.createElement("button");
|
||
del.type = "button";
|
||
del.className = "btn btn-danger btn-delete-type";
|
||
del.style.marginRight = "auto";
|
||
del.textContent = "Delete";
|
||
del.addEventListener("click", async () => {
|
||
try {
|
||
await deleteCableType(existing.id);
|
||
state.cableTypes = await listCableTypes();
|
||
if (state.activeTypeId === existing.id) state.activeTypeId = null;
|
||
dlg.close();
|
||
render();
|
||
} catch (e) {
|
||
const n = e.details?.in_use_by_cables;
|
||
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
|
||
}
|
||
});
|
||
actions.prepend(del);
|
||
}
|
||
|
||
dlg.showModal();
|
||
form.elements.namedItem("name").focus();
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(form);
|
||
const body = {
|
||
name: String(fd.get("name") || "").trim(),
|
||
color: String(fd.get("color") || "").trim(),
|
||
};
|
||
try {
|
||
if (existing) await patchCableType(existing.id, body);
|
||
else await createCableType(body);
|
||
state.cableTypes = await listCableTypes();
|
||
dlg.close();
|
||
render();
|
||
} catch (e) {
|
||
showError(err, e.message || "Save failed");
|
||
}
|
||
};
|
||
}
|
||
|
||
function openDeleteProjectModal() {
|
||
if (!state.active) return;
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
|
||
const err = $("#dp-error");
|
||
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
|
||
form.reset();
|
||
showError(err, "");
|
||
input.placeholder = state.active.name;
|
||
dlg.showModal();
|
||
input.focus();
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const confirm = String(new FormData(form).get("confirm") || "");
|
||
try {
|
||
await deleteProject(state.active.id, confirm);
|
||
state.projects = await listProjects();
|
||
dlg.close();
|
||
await activateProject(null);
|
||
} catch (e) {
|
||
showError(err, e.message || "Delete failed");
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------- solve flow ---------- //
|
||
|
||
function openSolveModal() {
|
||
if (!state.active) { alert("Pick a project first"); return; }
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve"));
|
||
const body = $("#sv-body");
|
||
body.innerHTML = `<p class="muted">Computing…</p>`;
|
||
dlg.showModal();
|
||
solveProject(state.active.id, true)
|
||
.then((preview) => renderSolvePreview(body, preview))
|
||
.catch((e) => { body.innerHTML = `<p class="form-error">${escapeHtml(e.message)}</p>`; });
|
||
|
||
$("#sv-apply").onclick = async () => {
|
||
if (!state.active) return;
|
||
try {
|
||
const applied = await solveProject(state.active.id, false);
|
||
// Refresh from snapshot to pick up new cable ids + bundle assignments.
|
||
const snap = await getSnapshot(state.active.id);
|
||
state.cables = snap.cables || [];
|
||
state.bundles = snap.bundles || [];
|
||
state.ports = snap.ports || [];
|
||
state.requirements = snap.connection_requirements || [];
|
||
dlg.close();
|
||
render();
|
||
// Surface a brief summary as an alert (slice 9+ can replace with a toast).
|
||
const adds = applied.cables_added?.length ?? 0;
|
||
const rem = applied.cables_removed?.length ?? 0;
|
||
const bun = applied.bundles_added?.length ?? 0;
|
||
const un = applied.unsatisfied?.length ?? 0;
|
||
const lines = [`Solve applied: +${adds} cables / -${rem} cables / +${bun} bundles`];
|
||
if (un > 0) lines.push(`${un} requirement${un === 1 ? "" : "s"} unsatisfied`);
|
||
console.log(lines.join("\n"));
|
||
} catch (e) {
|
||
alert(`Apply failed: ${e.message}`);
|
||
}
|
||
};
|
||
}
|
||
|
||
function renderSolvePreview(body, preview) {
|
||
const reqByID = new Map(state.requirements.map((r) => [r.id, r]));
|
||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
|
||
|
||
const addsHtml = (preview.cables_added || []).map((c) => {
|
||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||
const a = deviceByID.get(fromDev)?.name ?? "?";
|
||
const b = deviceByID.get(toDev)?.name ?? "?";
|
||
return `<li class="added">+ ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}</li>`;
|
||
}).join("");
|
||
const remsHtml = (preview.cables_removed || []).map((id) => `<li class="removed">cable #${id}</li>`).join("");
|
||
const bunsHtml = (preview.bundles_added || []).map((b) => `<li class="added">bundle: ${escapeHtml(b.name)}</li>`).join("");
|
||
|
||
const unmetsHtml = (preview.unsatisfied || []).map((u) => {
|
||
const r = reqByID.get(u.requirement_id);
|
||
const a = r ? deviceByID.get(r.from_device_id)?.name : "?";
|
||
const b = r ? deviceByID.get(r.to_device_id)?.name : "?";
|
||
const reqDesc = `${escapeHtml(a ?? "?")} ↔ ${escapeHtml(b ?? "?")}`;
|
||
let action = "";
|
||
// Quick-fix per design v4.1 §5b.4.
|
||
if ((u.reason || "").startsWith("no free") && u.cable_type && u.which_side) {
|
||
const side = u.which_side === "from" ? r.from_device_id : r.to_device_id;
|
||
const sideName = deviceByID.get(side)?.name ?? "?";
|
||
action = `<span class="quickfix" data-fix="addport" data-device="${side}" data-cable-type="${escapeHtml(u.cable_type)}">+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||
} else if ((u.reason || "").startsWith("ambiguous") && r) {
|
||
action = `<span class="quickfix" data-fix="picktype" data-req="${r.id}">Specify cable type…</span>`;
|
||
} else if ((u.reason || "").startsWith("no compat") && r && r.preferred_cable_type_id != null) {
|
||
// No common port type for the preferred — offer to add a port on either device.
|
||
const sideName = deviceByID.get(r.from_device_id)?.name ?? "?";
|
||
action = `<span class="quickfix" data-fix="addport" data-device="${r.from_device_id}" data-cable-type-id="${r.preferred_cable_type_id}">+ Add port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||
}
|
||
return `<li class="unmet">⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}</li>`;
|
||
}).join("");
|
||
|
||
body.innerHTML = `
|
||
${addsHtml ? `<h3>Cables to add</h3><ul>${addsHtml}</ul>` : ""}
|
||
${remsHtml ? `<h3>Cables to remove</h3><ul>${remsHtml}</ul>` : ""}
|
||
${bunsHtml ? `<h3>Bundles to add</h3><ul>${bunsHtml}</ul>` : ""}
|
||
${unmetsHtml ? `<h3>Unsatisfied</h3><ul>${unmetsHtml}</ul>` : ""}
|
||
${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `<p class="muted">No changes — already solved.</p>`}
|
||
`;
|
||
|
||
body.querySelectorAll(".quickfix").forEach((el) => {
|
||
el.addEventListener("click", async () => {
|
||
const fix = el.getAttribute("data-fix");
|
||
if (fix === "addport") {
|
||
const devID = Number(el.getAttribute("data-device"));
|
||
let typeID = Number(el.getAttribute("data-cable-type-id"));
|
||
if (!typeID) {
|
||
const typeName = el.getAttribute("data-cable-type");
|
||
const t = state.cableTypes.find((x) => x.name === typeName);
|
||
typeID = t ? t.id : null;
|
||
}
|
||
if (!devID || !typeID) return;
|
||
try {
|
||
await portsAndResolve(state.active.id, devID, { type_id: typeID });
|
||
// Refresh + re-render the preview
|
||
const refresh = await solveProject(state.active.id, true);
|
||
const snap = await getSnapshot(state.active.id);
|
||
state.cables = snap.cables; state.bundles = snap.bundles;
|
||
state.ports = snap.ports; state.requirements = snap.connection_requirements;
|
||
state.devices = snap.devices;
|
||
renderSolvePreview(body, refresh);
|
||
render(); // sidebar updates
|
||
} catch (e) { alert(`Quick-fix failed: ${e.message}`); }
|
||
} else if (fix === "picktype") {
|
||
// Open the requirement modal so m can specify a type.
|
||
const rid = Number(el.getAttribute("data-req"));
|
||
const r = state.requirements.find((x) => x.id === rid);
|
||
if (r) openRequirementModal(r);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- apply-template flow ---------- //
|
||
|
||
async function openApplyTemplateModal() {
|
||
if (!state.active) { alert("Pick a project first"); return; }
|
||
if (!state.setupTemplates.length) {
|
||
state.setupTemplates = await listSetupTemplates();
|
||
}
|
||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-template"));
|
||
const form = /** @type {HTMLFormElement} */ ($("#form-template"));
|
||
const sel = /** @type {HTMLSelectElement} */ ($("#tp-select"));
|
||
const preview = $("#tp-preview");
|
||
const err = $("#tp-error");
|
||
showError(err, "");
|
||
|
||
sel.innerHTML = "";
|
||
for (const t of state.setupTemplates) {
|
||
sel.append(new Option(t.name, String(t.id)));
|
||
}
|
||
sel.onchange = () => renderTemplatePreview(preview, sel.value);
|
||
renderTemplatePreview(preview, sel.value);
|
||
|
||
dlg.showModal();
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
if (!state.active) return;
|
||
const tid = Number(sel.value);
|
||
if (!tid) { showError(err, "Pick a template"); return; }
|
||
// Collect any per-device name overrides (the preview renders inputs).
|
||
const overrides = {};
|
||
preview.querySelectorAll("[data-template-device-id]").forEach((row) => {
|
||
const did = row.getAttribute("data-template-device-id");
|
||
const input = row.querySelector("input.tp-name");
|
||
if (input && input.value.trim()) overrides[did] = input.value.trim();
|
||
});
|
||
const skip = [];
|
||
preview.querySelectorAll("input.tp-skip:checked").forEach((cb) => {
|
||
const did = Number(cb.getAttribute("data-template-device-id"));
|
||
if (did) skip.push(did);
|
||
});
|
||
try {
|
||
// The server auto-solves by default since v0c7d165 — the response
|
||
// is {template_apply, solve} (or {template_apply, solve_error}).
|
||
// We don't need to read the body here; activateProject() below
|
||
// pulls a fresh snapshot that includes both the seeded devices
|
||
// and any cables the solver placed.
|
||
const projID = state.active.id;
|
||
await applyTemplate(projID, {
|
||
template_id: tid,
|
||
name_overrides: overrides,
|
||
skip_devices: skip,
|
||
});
|
||
dlg.close();
|
||
// Route through the canonical project-load path. That re-hydrates
|
||
// ALL collections (frames, devices, ports, io_markers, cables,
|
||
// bundles, requirements, cable_types, device_types) AND clears
|
||
// the selection — important because m may have had a stale
|
||
// selection from before the apply. Slice 6's bare re-snapshot
|
||
// missed the device_types refresh + selection reset.
|
||
await activateProject(projID);
|
||
} catch (ex) {
|
||
showError(err, ex.message || "Apply failed");
|
||
}
|
||
};
|
||
}
|
||
|
||
function renderTemplatePreview(preview, templateIDStr) {
|
||
if (!templateIDStr) { preview.innerHTML = ""; return; }
|
||
const t = state.setupTemplates.find((x) => String(x.id) === templateIDStr);
|
||
if (!t) { preview.innerHTML = ""; return; }
|
||
const cableTypeName = new Map(state.cableTypes.map((c) => [c.id, c.name]));
|
||
const devByTplID = new Map(t.devices.map((d) => [d.id, d]));
|
||
const devsHtml = t.devices.map((d) => {
|
||
const dtName = d.device_type?.name ?? `type #${d.device_type_id}`;
|
||
const suggested = d.suggested_name ?? dtName;
|
||
return `
|
||
<li data-template-device-id="${d.id}">
|
||
<input type="checkbox" class="tp-skip" data-template-device-id="${d.id}" title="Skip this device" />
|
||
<input type="text" class="tp-name inline-input" value="${escapeHtml(suggested)}"
|
||
style="width: 140px; display: inline-block;" />
|
||
<span class="muted" style="margin-left: 6px;">${escapeHtml(dtName)}</span>
|
||
</li>`;
|
||
}).join("");
|
||
const reqsHtml = t.requirements.map((r) => {
|
||
const a = devByTplID.get(r.from_template_device_id);
|
||
const b = devByTplID.get(r.to_template_device_id);
|
||
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : "solver picks";
|
||
return `<li>${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}</li>`;
|
||
}).join("");
|
||
preview.innerHTML = `
|
||
<p>${escapeHtml(t.description)}</p>
|
||
<h4>Devices</h4>
|
||
<ul>${devsHtml}</ul>
|
||
<h4>Requirements</h4>
|
||
<ul>${reqsHtml}</ul>
|
||
`;
|
||
}
|
||
|
||
// ---------- export flow ---------- //
|
||
|
||
let toastTimer = null;
|
||
|
||
function showToast(kind, html, holdMs = 5000) {
|
||
const t = $("#toast");
|
||
t.className = "toast " + (kind || "");
|
||
t.innerHTML = html;
|
||
setHidden(t, false);
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs);
|
||
}
|
||
|
||
async function exportCurrentProject() {
|
||
if (!state.active) { alert("Pick a project first"); return; }
|
||
const btn = $("#btn-export");
|
||
btn.disabled = true;
|
||
showToast("", "Exporting…", 30000);
|
||
try {
|
||
const res = await syncExport(state.active.id);
|
||
const url = res.url ?? "(no url)";
|
||
const count = res.element_count ?? 0;
|
||
showToast("ok",
|
||
`Exported ${count} elements → <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`,
|
||
8000);
|
||
} catch (e) {
|
||
// Surface mxdrw unreachability or the upstream error verbatim.
|
||
const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? "");
|
||
showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ---------- boot ---------- //
|
||
|
||
async function boot() {
|
||
bindCloseButtons($("#modal-new-project"));
|
||
bindCloseButtons($("#modal-cable-type"));
|
||
bindCloseButtons($("#modal-delete-project"));
|
||
bindCloseButtons($("#modal-new-device"));
|
||
bindCloseButtons($("#modal-requirement"));
|
||
bindCloseButtons($("#modal-solve"));
|
||
bindCloseButtons($("#modal-template"));
|
||
|
||
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
||
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
||
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
|
||
$("#btn-add-requirement").addEventListener("click", () => {
|
||
if (!state.active) { alert("Pick a project first"); return; }
|
||
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
|
||
openRequirementModal(null);
|
||
});
|
||
$("#btn-solve").addEventListener("click", openSolveModal);
|
||
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
|
||
$("#btn-export").addEventListener("click", exportCurrentProject);
|
||
|
||
$("#project-select").addEventListener("change", (e) => {
|
||
const v = /** @type {HTMLSelectElement} */ (e.target).value;
|
||
activateProject(v ? Number(v) : null);
|
||
});
|
||
|
||
bindTools();
|
||
|
||
try {
|
||
[state.projects, state.cableTypes] = await Promise.all([
|
||
listProjects(),
|
||
listCableTypes(),
|
||
]);
|
||
} catch (e) {
|
||
alert(`Failed to load: ${e.message}`);
|
||
return;
|
||
}
|
||
|
||
const wanted = activeProjectIdFromURL();
|
||
if (wanted && state.projects.some((p) => p.id === wanted)) {
|
||
await activateProject(wanted);
|
||
} else {
|
||
render();
|
||
}
|
||
}
|
||
|
||
boot();
|