feat: frontend — IO markers + cable-type inspector

Slice 3 frontend.

+ IO tool (keyboard `I`):
- Single-click on canvas places a 30x30 diamond (rotated <rect>) at the
  point, with the Power-cable_type colour fill (red-ish).
- Inline namer prompts for a label; empty → server defaults to "IO".
- Drop-point determines initial frame_id via the existing frameAt()
  point-in-rect logic, same as devices.

Render:
- io_markers come from snap.io_markers in the snapshot loader. Each
  renders as a <rect> with rotate(45) around its centre + a small text
  label below the diamond. Selection halo on stroke-width.
- Drag is the same pointer-event flow as devices; on pointerup, PATCH
  x,y + recompute frame_id from the new centre. Cross-frame moves
  update frame_id with explicit null on the wire when leaving all frames.
- Frame-drag now also relocates contained IO markers (mirrors the
  device-cascade pattern). Single PATCH per IO marker on release.

Cable-type inspector:
- Clicking a legend row now sets state.selection = {kind:"cable_type", id}
  in addition to toggling activeTypeId. The inspector renders the cable
  type's details (name + colour, both editable, with the
  "shared across projects" banner from v3 §7), a used-by counter (0
  until slice 7 ships cables), and a Delete button that surfaces the
  RESTRICT in_use_by_cables count from the server.
- Debounced rename via the existing bindDebouncedRename helper.

Inspector frame view picks up an "IO" count alongside the device count.
Background click + Esc clear the selection (existing behaviour, now
covers cable_type too).

Hand-tested via the API equivalents: 3 IO markers created (free, in
frame, default-label), PATCH x,y + frame_id-to-null all work, cross-
project frame_id rejected with 400, DELETE 9999 returns 404. Snapshot
shape post-slice-3: {frames, devices, io_markers, cable_types} all
populated, ports/cables/bundles still [].
This commit is contained in:
mAi
2026-05-16 00:12:24 +02:00
parent d114bfb547
commit a3f0586296
3 changed files with 264 additions and 24 deletions

View File

@@ -37,8 +37,8 @@
<ul class="tool-list">
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
</ul>
</section>
</aside>

View File

@@ -14,10 +14,13 @@
* @typedef {{ id: number, project_id: number, frame_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
*/
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: [],
@@ -25,10 +28,11 @@ const state = {
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
/** @type {IOMarker[]} */ ioMarkers: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | null */
/** "frame" | "device" | "io" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
/** @type {{kind: "frame"|"device"|"io"|"cable_type", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -70,6 +74,10 @@ const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, bo
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}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -164,8 +172,13 @@ function renderLegend() {
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;
renderLegend();
state.selection = { kind: "cable_type", id: t.id };
render();
});
ul.append(li);
}
@@ -191,8 +204,10 @@ function renderEmptyHint() {
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
const gIO = $("#canvas-io");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gIO.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
@@ -234,6 +249,31 @@ function renderCanvas() {
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) => startDrag(e, "io", m.id));
}
}
function renderInspector() {
@@ -242,10 +282,12 @@ function renderInspector() {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
return;
}
if (state.selection.kind === "frame") {
renderInspectorFrame(body, state.selection.id);
} else {
renderInspectorDevice(body, state.selection.id);
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);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -253,6 +295,7 @@ 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">
@@ -265,6 +308,7 @@ function renderInspectorFrame(body, id) {
<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>
@@ -276,6 +320,7 @@ function renderInspectorFrame(body, id) {
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;
@@ -286,9 +331,10 @@ function renderInspectorFrame(body, id) {
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) 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();
@@ -361,6 +407,120 @@ function renderInspectorDevice(body, id) {
});
}
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", () => {
@@ -395,6 +555,7 @@ async function activateProject(id) {
state.active = null;
state.frames = [];
state.devices = [];
state.ioMarkers = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -405,6 +566,7 @@ async function activateProject(id) {
state.active = snap.project;
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.ioMarkers = snap.io_markers || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
@@ -414,6 +576,7 @@ async function activateProject(id) {
state.active = null;
state.frames = [];
state.devices = [];
state.ioMarkers = [];
setActiveInURL(null);
render();
} else {
@@ -447,6 +610,7 @@ function bindTools() {
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); 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");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
@@ -483,10 +647,15 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
return;
}
// No tool armed: clicks that started on a device/frame go to their
// 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]")) return;
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(); }
@@ -566,6 +735,30 @@ async function placeDeviceAt(p) {
}
}
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);
@@ -625,23 +818,30 @@ function startDrag(e, kind, id) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|undefined} */
const obj = kind === "frame"
? state.frames.find((f) => f.id === id)
: state.devices.find((d) => d.id === id);
/** @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 + their offsets so
// they follow the frame visually + persist on release.
// 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");
@@ -658,6 +858,7 @@ function startDrag(e, kind, id) {
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();
};
@@ -674,12 +875,14 @@ function startDrag(e, kind, id) {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices too.
await Promise.all(
trackedDevices.map((t) =>
// 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 })),
);
} else {
...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;
@@ -692,6 +895,18 @@ function startDrag(e, kind, id) {
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}`);

View File

@@ -211,7 +211,32 @@ body {
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
easiest way to draw a diamond that still hit-tests at the rotated
bounds (a <polygon> would also work; rect-with-rotate keeps the
same DOM shape as device/frame so the drag helpers reuse). */
.io-marker {
fill: var(--danger);
fill-opacity: 0.18;
stroke: var(--danger);
stroke-width: 1.5;
}
.io-marker.selected,
.io-marker:hover { stroke-width: 2.5; }
.io-marker-label {
fill: var(--danger);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);