merge: v5 — cable routing via clamps (all 6 slices)

picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
  UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
  two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
  clamps + clamp inspector with cables-through list and
  cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
  nearest segment; pointerup snaps to existing clamp within
  MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
  min(12,2+N), gradient stripes by count desc / id asc).
  ×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
  cable arrows carry mid-vertices through clamps; bundle viz stays
  viewer-only (Excalidraw can't represent gradient strokes).
This commit is contained in:
mAi
2026-05-16 14:04:37 +02:00
14 changed files with 1489 additions and 26 deletions

View File

@@ -43,6 +43,7 @@
<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" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
</ul>
@@ -51,10 +52,13 @@
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<defs id="canvas-defs"></defs>
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-bundles"></g>
<g id="canvas-clamps"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">

View File

@@ -41,6 +41,7 @@
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 CLAMP_SIZE = 12; // small rounded square for routing clamps (v5 §11)
const state = {
/** @type {Project[]} */ projects: [],
@@ -55,8 +56,11 @@ const state = {
/** @type {Cable[]} */ cables: [],
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
/** v5 — routing anchors. */
/** @type {Clamp[]} */ clamps: [],
/** @type {CableClamp[]} */ cableClamps: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "cable" | null */
/** "frame" | "device" | "io" | "req" | "cable" | "clamp" | null */
tool: /** @type {string|null} */ (null),
/** Canvas viewport — drives the SVG viewBox. */
view: { x: 0, y: 0, zoom: 1 },
@@ -64,7 +68,7 @@ const state = {
spaceHeld: false,
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port"|"clamp", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -135,6 +139,15 @@ 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`, {});
// v5 — clamps + cable_clamps.
const listClamps = (pid) => api("GET", `/projects/${pid}/clamps`);
const createClamp = (pid, body) => api("POST", `/projects/${pid}/clamps`, body);
const patchClamp = (pid, id, body) => api("PATCH", `/projects/${pid}/clamps/${id}`, body);
const deleteClamp = (pid, id) => api("DELETE", `/projects/${pid}/clamps/${id}`);
const attachClampToCable = (pid, cid, body) => api("POST", `/projects/${pid}/cables/${cid}/clamps`, body);
const detachClampFromCable = (pid, cid, cmid) => api("DELETE", `/projects/${pid}/cables/${cid}/clamps/${cmid}`);
const reorderCableClamps = (pid, cid, body) => api("PUT", `/projects/${pid}/cables/${cid}/clamps`, body);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -393,11 +406,17 @@ function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
const gCables = $("#canvas-cables");
const gBundles = $("#canvas-bundles");
const gClamps = $("#canvas-clamps");
const gIO = $("#canvas-io");
const gDefs = $("#canvas-defs");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gCables.innerHTML = "";
gBundles.innerHTML = "";
gClamps.innerHTML = "";
gIO.innerHTML = "";
gDefs.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
@@ -539,30 +558,103 @@ function renderCanvas() {
});
}
// Cables — straight lines between resolved endpoint anchors.
// Auto-cables render with dashed stroke so m sees which the solver
// placed; manual cables are solid.
// Clamps — small grey rounded squares (per design v5 §11.9 q1).
// Slice 4 wires them into cable polylines; for slice 3 they just
// render + drag + select. Slice 5 adds a ×N count badge for clamps
// with ≥2 cables through them.
const cablesPerClamp = new Map();
for (const cc of state.cableClamps) {
cablesPerClamp.set(cc.clamp_id, (cablesPerClamp.get(cc.clamp_id) || 0) + 1);
}
for (const cl of state.clamps) {
const g = svgEl("g", { "data-clamp-id": cl.id });
const sz = CLAMP_SIZE;
const rect = svgEl("rect", {
x: cl.x - sz / 2, y: cl.y - sz / 2, width: sz, height: sz,
rx: 2, ry: 2,
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
});
g.append(rect);
const n = cablesPerClamp.get(cl.id) || 0;
if (n >= 2) {
const badge = svgEl("text", {
x: cl.x + sz / 2 + 2, y: cl.y - sz / 2 - 1,
class: "clamp-badge",
});
badge.textContent = `×${n}`;
g.append(badge);
}
if (cl.label) {
const label = svgEl("text", {
x: cl.x + sz / 2 + 4, y: cl.y + 3,
class: "clamp-label",
});
label.textContent = cl.label;
g.append(label);
}
gClamps.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id));
}
// Cables — polyline through endpoint(from) → clamps in ord sequence
// → endpoint(to). With zero clamps this collapses to a v0 straight
// line. Auto-cables render dashed; manual 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]));
const clampByID = new Map(state.clamps.map((cl) => [cl.id, cl]));
// Pre-group cable_clamps by cable, sorted by ord.
const clampsByCable = new Map();
for (const cc of state.cableClamps) {
let arr = clampsByCable.get(cc.cable_id);
if (!arr) { arr = []; clampsByCable.set(cc.cable_id, arr); }
arr.push(cc);
}
for (const arr of clampsByCable.values()) arr.sort((a, b) => a.ord - b.ord);
// sharedSegments: segmentKey → { a, b, cables:[Cable] }. Built up
// during the per-cable loop, then walked in a second pass for the
// bundle overlay layer (v5 §11.3).
const sharedSegments = new Map();
for (const c of state.cables) {
let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
if (!fromAnchor || !toAnchor) continue;
const built = cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
if (!built) continue;
const { vertices, keys } = built;
// Bundle accumulator — record this cable on every segment of its
// resolved polyline keyed by an undirected pair of vertex IDs.
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i], b = keys[i + 1];
const segKey = a < b ? `${a}|${b}` : `${b}|${a}`;
let bucket = sharedSegments.get(segKey);
if (!bucket) {
bucket = { a: vertices[i], b: vertices[i + 1], cables: [] };
sharedSegments.set(segKey, bucket);
}
bucket.cables.push(c);
}
// Replug preview: while m drags an endpoint handle, override the
// affected end with the live cursor world position so the line
// tracks the pointer.
// tracks the pointer. Mid-vertices (clamps) are unchanged.
if (cableReplug && cableReplug.cableID === c.id) {
if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y };
else toAnchor = { x: cableReplug.x, y: cableReplug.y };
const idx = cableReplug.end === "from" ? 0 : vertices.length - 1;
vertices[idx] = { x: cableReplug.x, y: cableReplug.y };
}
// Mid-segment drag preview: while m is bending a segment, insert
// a temp vertex at the cursor so the line tracks. On release this
// becomes a real clamp (or snaps to a nearby existing one).
if (cableMidDrag && cableMidDrag.cableID === c.id) {
const at = cableMidDrag.segmentIdx + 1;
vertices.splice(at, 0, { x: cableMidDrag.x, y: cableMidDrag.y });
}
const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id;
const color = cableTypeColor.get(c.type_id) || "#888";
const line = svgEl("line", {
x1: fromAnchor.x, y1: fromAnchor.y,
x2: toAnchor.x, y2: toAnchor.y,
const pointsStr = vertices.map((v) => `${v.x},${v.y}`).join(" ");
const line = svgEl("polyline", {
points: pointsStr,
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
stroke: color,
fill: "none",
"data-cable-id": c.id,
});
line.addEventListener("click", (e) => {
@@ -570,12 +662,20 @@ function renderCanvas() {
state.selection = { kind: "cable", id: c.id };
render();
});
line.addEventListener("pointerdown", (e) => {
// Selected cable + non-endpoint click → start a mid-segment drag
// that inserts (or snaps to) a clamp on release. Bypasses the
// canvas-level handler so panning / device drag don't fire.
if (isSelected && e.button === 0 && !state.spaceHeld) {
startCableMidDrag(e, c, vertices);
}
});
gCables.append(line);
// Endpoint handles — only on the currently-selected cable. Two small
// filled circles m can grab to drag the endpoint onto a new target.
// Endpoint handles — first + last vertex when selected.
if (isSelected) {
for (const end of ["from", "to"]) {
const a = end === "from" ? fromAnchor : toAnchor;
const first = vertices[0];
const last = vertices[vertices.length - 1];
for (const [end, a] of [["from", first], ["to", last]]) {
const h = svgEl("circle", {
cx: a.x, cy: a.y, r: 7,
class: "cable-handle",
@@ -589,6 +689,97 @@ function renderCanvas() {
}
}
}
// ---- bundle viz: shared segments + clamp count badges (v5 §11.3) ----
let gradSeq = 0;
for (const [segKey, bucket] of sharedSegments) {
if (bucket.cables.length < 2) continue;
// Distinct cable type IDs in this bundle, ordered by count desc
// (ties by id asc) per design v5 §11.9 q4.
const counts = new Map();
for (const c of bucket.cables) {
counts.set(c.type_id, (counts.get(c.type_id) || 0) + 1);
}
const distinctTypes = [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
.map(([id]) => id);
// Build a linearGradient perpendicular to the segment so the stripes
// run ACROSS the segment's thickness (visually: stripes parallel to
// the cable direction).
const { a, b } = bucket;
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
// Perpendicular unit vector — gradient runs along this so the stops
// become bands along the segment's direction.
const px = -dy / len, py = dx / len;
const thickness = Math.min(12, 2 + bucket.cables.length);
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
// Stops: hard-edged segments, one band per type.
const gradID = `bundle-grad-${gradSeq++}-${segKey.replace(/[^a-z0-9-]/gi, "_")}`;
const grad = svgEl("linearGradient", {
id: gradID,
gradientUnits: "userSpaceOnUse",
x1: mx + px * thickness / 2,
y1: my + py * thickness / 2,
x2: mx - px * thickness / 2,
y2: my - py * thickness / 2,
});
const n = distinctTypes.length;
for (let i = 0; i < n; i++) {
const color = cableTypeColor.get(distinctTypes[i]) || "#888";
const startStop = svgEl("stop", { offset: `${(i / n) * 100}%`, "stop-color": color });
const endStop = svgEl("stop", { offset: `${((i + 1) / n) * 100}%`, "stop-color": color });
grad.append(startStop, endStop);
}
gDefs.append(grad);
// Tooltip listing the bundled cable types.
const titleText = distinctTypes
.map((id) => cableTypeColor.has(id) ? state.cableTypes.find((t) => t.id === id)?.name ?? `#${id}` : `#${id}`)
.join(" · ") + ` (${bucket.cables.length} cables)`;
const overlay = svgEl("line", {
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
class: "bundle-line",
stroke: `url(#${gradID})`,
"stroke-width": thickness,
});
const title = svgEl("title", {});
title.textContent = titleText;
overlay.append(title);
gBundles.append(overlay);
}
}
// Compute the resolved polyline vertices for a cable plus a stable
// vertex-key per vertex used to detect shared segments for bundle viz.
// Vertex keys:
// - port:<id> for a port-anchored endpoint
// - device:<id> for a device-anchored endpoint (no port)
// - io:<id> for an IO-anchored endpoint
// - clamp:<id> for a mid-vertex
// Returns null if either endpoint can't be resolved.
function cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
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) return null;
function endpointKey(portID, deviceID, ioID) {
if (portID != null) return `port:${portID}`;
if (deviceID != null) return `device:${deviceID}`;
return `io:${ioID}`;
}
const vertices = [fromAnchor];
const keys = [endpointKey(c.from_port_id, c.from_device_id, c.from_io_id)];
const clamps = clampsByCable.get(c.id) || [];
for (const cc of clamps) {
const cl = clampByID.get(cc.clamp_id);
if (cl) {
vertices.push({ x: cl.x, y: cl.y });
keys.push(`clamp:${cl.id}`);
}
}
vertices.push(toAnchor);
keys.push(endpointKey(c.to_port_id, c.to_device_id, c.to_io_id));
return { vertices, keys };
}
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
@@ -629,6 +820,7 @@ function renderInspector() {
case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id);
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
case "clamp": return renderInspectorClamp(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -1217,6 +1409,112 @@ function renderInspectorIO(body, id) {
});
}
// Clamp inspector — label + position + cables-through list + delete.
// Slice 4 wires the cables-through list to actual data; for slice 3 it
// reads whatever's already on state.cableClamps (initially empty for a
// freshly-placed clamp).
function renderInspectorClamp(body, id) {
const cl = state.clamps.find((x) => x.id === id);
if (!cl) { body.innerHTML = ""; return; }
const frame = cl.frame_id ? state.frames.find((f) => f.id === cl.frame_id) : null;
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
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 cablesThrough = state.cableClamps
.filter((cc) => cc.clamp_id === id)
.map((cc) => state.cables.find((c) => c.id === cc.cable_id))
.filter(Boolean);
function endpointLabel(c, end) {
const portID = end === "from" ? c.from_port_id : c.to_port_id;
const devID = end === "from" ? c.from_device_id : c.to_device_id;
const ioID = end === "from" ? c.from_io_id : c.to_io_id;
if (portID != null) {
const p = portByID.get(portID);
const d = p && deviceByID.get(p.device_id);
return `${d?.name ?? "?"} · ${p?.label ?? "port"}`;
}
if (devID != null) return deviceByID.get(devID)?.name ?? "(missing device)";
if (ioID != null) return ioByID.get(ioID)?.label ?? "(missing IO)";
return "?";
}
const cablesHtml = cablesThrough.length
? cablesThrough.map((c) => `
<div class="port-row" data-cable-id="${c.id}">
<span class="swatch" style="background:${cableTypeColor.get(c.type_id) || "#888"}"></span>
<span class="label">${escapeHtml(endpointLabel(c, "from"))}${escapeHtml(endpointLabel(c, "to"))}</span>
<span class="conn">
<button type="button" class="btn-link clamp-detach" data-cable-id="${c.id}" title="Remove this clamp from the cable">×</button>
</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">Not on any cable yet.</p>`;
body.innerHTML = `
<p class="section-title">Clamp</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="clamp-label" value="" />
</label>
<dl>
<dt>x</dt><dd id="clamp-x"></dd>
<dt>y</dt><dd id="clamp-y"></dd>
<dt>frame</dt><dd id="clamp-frame"></dd>
</dl>
<p class="section-title">Cables through</p>
${cablesHtml}
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="clamp-delete">Delete clamp</button>
</div>
`;
body.querySelector("#clamp-label").value = cl.label;
body.querySelector("#clamp-x").textContent = cl.x.toFixed(0);
body.querySelector("#clamp-y").textContent = cl.y.toFixed(0);
body.querySelector("#clamp-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#clamp-label"), async (label) => {
if (!state.active) return;
const updated = await patchClamp(state.active.id, cl.id, { label });
Object.assign(cl, updated);
renderCanvas();
});
// Per-cable detach in the cables-through list.
body.querySelectorAll(".clamp-detach").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!state.active) return;
const cableID = Number(btn.getAttribute("data-cable-id"));
try {
await detachClampFromCable(state.active.id, cableID, cl.id);
// Re-fetch snapshot fragment to keep ord contiguous.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
} catch (ex) {
alert(`Detach failed: ${ex.message}`);
}
});
});
body.querySelector("#clamp-delete").addEventListener("click", async () => {
if (!state.active) return;
const n = cablesThrough.length;
const prompt = n > 0
? `This clamp is on ${n} cable(s). Delete it and remove from all of them?`
: "Delete this clamp?";
if (!confirm(prompt)) return;
try {
await deleteClamp(state.active.id, cl.id);
state.clamps = state.clamps.filter((c) => c.id !== id);
state.cableClamps = state.cableClamps.filter((cc) => cc.clamp_id !== id);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort(body, id) {
@@ -1568,6 +1866,8 @@ async function activateProject(id) {
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -1584,6 +1884,8 @@ async function activateProject(id) {
state.bundles = snap.bundles || [];
state.requirements = snap.connection_requirements || [];
state.cableTypes = snap.cable_types || [];
state.clamps = snap.clamps || [];
state.cableClamps = snap.cable_clamps || [];
state.selection = null;
setActiveInURL(id);
// Hydrate the device-type catalog for this project — used by the
@@ -1607,6 +1909,8 @@ async function activateProject(id) {
state.requirements = [];
state.cables = [];
state.bundles = [];
state.clamps = [];
state.cableClamps = [];
setActiveInURL(null);
render();
} else {
@@ -1624,6 +1928,7 @@ function armTool(tool) {
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-cable", tool === "cable");
wrap.classList.toggle("tool-clamp", tool === "clamp");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
@@ -1655,6 +1960,7 @@ function bindTools() {
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 === "c" || e.key === "C") armTool("clamp");
else if (e.key === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal();
});
@@ -1679,6 +1985,11 @@ let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
// on a .cable-handle, used by renderCanvas to anchor the dragged end
// at the cursor; cleared on pointerup (commit or cancel).
let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null);
// Mid-segment drag — m grabs a point on a cable's polyline (not on an
// endpoint handle, not on an existing clamp vertex) and drags. On
// release, either snap to a nearby clamp or create a fresh one at the
// drop point and insert at the right `ord`.
let cableMidDrag = /** @type {{cableID: number, segmentIdx: number, x: number, y: number}|null} */ (null);
function onCanvasPointerDown(e) {
// Pan gestures win over every tool. Middle-click and Space+drag both
@@ -1714,6 +2025,11 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "clamp") {
e.preventDefault();
placeClampAt(p, e);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -2105,6 +2421,121 @@ function startCableReplug(e, cableID, end) {
svg.addEventListener("pointercancel", onUp);
}
// Mid-segment cable drag: m grabs a point on a selected cable's
// polyline (not on an endpoint handle) and drags. On release, snap to
// the nearest clamp within MID_SNAP world-units, or create a fresh one
// at the drop point. Either way, attach it to the cable at the right
// ord so the new vertex sits inside the segment m was bending.
const MID_SNAP_PX = 16; // visual constant — divided by current zoom
function startCableMidDrag(e, cable, vertices) {
if (!state.active) return;
// Refuse if the click target is an endpoint handle — let the replug
// handler own that gesture.
if (e.target instanceof Element && e.target.classList.contains("cable-handle")) return;
e.stopPropagation();
e.preventDefault();
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
// Identify which segment the click landed closest to.
const segIdx = nearestSegmentIndex(vertices, start);
try { svg.setPointerCapture(e.pointerId); } catch {}
$(".canvas-wrap").classList.add("replugging");
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: start.x, y: start.y };
renderCanvas();
const onMove = (ev) => {
const p = svgPoint(ev);
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: p.x, y: p.y };
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
$(".canvas-wrap").classList.remove("replugging");
const dropWorld = svgPoint(ev);
cableMidDrag = null;
// Cancel if the cursor barely moved (≤ a few px in world coords) —
// m probably clicked the cable to select it, not bend it.
if (Math.hypot(dropWorld.x - start.x, dropWorld.y - start.y) < 4) {
renderCanvas();
return;
}
// Snap radius in world coords — visual constant per design v5 §11.9 q2.
const snapRadius = MID_SNAP_PX / state.view.zoom;
let nearest = null;
let bestDist = Infinity;
for (const cl of state.clamps) {
const d = Math.hypot(cl.x - dropWorld.x, cl.y - dropWorld.y);
if (d < bestDist) { bestDist = d; nearest = cl; }
}
try {
let clampID;
if (nearest && bestDist <= snapRadius) {
// Snap onto existing clamp — but only if it's not already on
// this cable (UNIQUE constraint would 409). Skip silently in
// that case rather than spamming an alert.
const already = state.cableClamps.some(
(cc) => cc.cable_id === cable.id && cc.clamp_id === nearest.id,
);
if (already) { renderCanvas(); return; }
clampID = nearest.id;
} else {
// Fresh clamp at the drop point.
const frame = frameAt(dropWorld.x, dropWorld.y);
const newClamp = await createClamp(state.active.id, {
x: dropWorld.x, y: dropWorld.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(newClamp);
clampID = newClamp.id;
}
// Insert at ord = segIdx + 1 (1-based; segmentIdx is the segment
// between vertices[segIdx] and vertices[segIdx + 1]).
const cc = await attachClampToCable(state.active.id, cable.id, {
clamp_id: clampID, ord: segIdx + 1,
});
// Refresh cable_clamps so the new ord + any shifted neighbours
// are reflected without a full snapshot reload.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
// Silence unused-var lint without dropping the result.
void cc;
} catch (err) {
alert(`Insert clamp failed: ${err.message}`);
renderCanvas();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Index of the segment in `vertices` closest to point p. Segment i sits
// between vertices[i] and vertices[i+1].
function nearestSegmentIndex(vertices, p) {
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < vertices.length - 1; i++) {
const a = vertices[i], b = vertices[i + 1];
const d = pointSegmentDistance(p, a, b);
if (d < bestDist) { bestDist = d; best = i; }
}
return best;
}
// Shortest distance from point p to the line segment ab.
function pointSegmentDistance(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/** Port-click flow:
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
@@ -2239,6 +2670,40 @@ async function createPortFromForm(deviceID, typeID, edge, label) {
}
}
// + Clamp tool: drop a standalone routing anchor at the click. If the
// click landed on a cable (slice 4 will detect this), the clamp will
// also be attached to that cable mid-segment. For slice 3 we just
// place it.
async function placeClampAt(p, e) {
if (!state.active) return;
armTool(null);
// Did the click hit a cable? If so, attach the new clamp to that cable.
const cableEl = e && e.target instanceof Element
? e.target.closest("[data-cable-id]")
: null;
const cableID = cableEl ? Number(cableEl.getAttribute("data-cable-id")) : null;
const frame = frameAt(p.x, p.y);
try {
const c = await createClamp(state.active.id, {
x: p.x, y: p.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(c);
if (cableID) {
try {
const cc = await attachClampToCable(state.active.id, cableID, { clamp_id: c.id });
state.cableClamps.push(cc);
} catch (ex) {
alert(`Attach to cable failed: ${ex.message}`);
}
}
state.selection = { kind: "clamp", id: c.id };
render();
} catch (err) {
alert(`Create clamp failed: ${err.message}`);
}
}
async function placeIOMarkerAt(p) {
if (!state.active) return;
armTool(null);
@@ -2329,19 +2794,21 @@ function startDrag(e, kind, id) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|IOMarker|undefined} */
/** @type {Frame|Device|IOMarker|Clamp|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);
else if (kind === "clamp") obj = state.clamps.find((c) => c.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.
// For frame drags, remember the contained devices + IO markers + clamps
// + 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}[]} */ ([]);
let trackedClamps = /** @type {{c: Clamp, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
@@ -2353,6 +2820,11 @@ function startDrag(e, kind, id) {
trackedIOs.push({ m, sx: m.x, sy: m.y });
}
}
for (const c of state.clamps) {
if (c.frame_id === obj.id) {
trackedClamps.push({ c, sx: c.x, sy: c.y });
}
}
}
// Capture the rect element NOW: by the time onUp fires async, the
@@ -2376,6 +2848,7 @@ function startDrag(e, kind, id) {
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; }
for (const t of trackedClamps) { t.c.x = t.sx + dx; t.c.y = t.sy + dy; }
}
renderCanvas();
};
@@ -2392,12 +2865,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 + IO markers too.
// Persist contained devices + IO markers + clamps 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 })),
...trackedClamps.map((t) =>
patchClamp(state.active.id, t.c.id, { x: t.c.x, y: t.c.y })),
]);
} else if (kind === "device") {
const d = /** @type {Device} */ (obj);
@@ -2412,7 +2887,7 @@ function startDrag(e, kind, id) {
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
} else /* io */ {
} else if (kind === "io") {
const m = /** @type {IOMarker} */ (obj);
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
@@ -2424,6 +2899,16 @@ function startDrag(e, kind, id) {
m.frame_id = newFrameID;
}
await patchIOMarker(state.active.id, m.id, patchBody);
} else /* clamp */ {
const c = /** @type {Clamp} */ (obj);
const targetFrame = frameAt(c.x, c.y);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: c.x, y: c.y };
if ((c.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
c.frame_id = newFrameID;
}
await patchClamp(state.active.id, c.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);

View File

@@ -227,9 +227,44 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-clamp #canvas,
.canvas-wrap.tool-clamp #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
/* Clamps — small grey rounded squares (v5 §11). Cables route through
them in `ord` sequence. */
.clamp {
fill: rgba(120, 120, 120, 0.85);
stroke: rgba(40, 40, 40, 0.85);
stroke-width: 1.5;
cursor: grab;
}
.clamp.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.clamp-label {
fill: var(--text-muted);
font-size: 10px;
pointer-events: none;
}
/* Shared-segment count badge — m sees ×N next to clamps that route
≥ 2 cables. */
.clamp-badge {
fill: var(--text);
font-size: 10px;
font-weight: 700;
pointer-events: none;
}
/* Bundle overlay — thick striped polyline drawn on top of individual
cables along shared segments. v5 §11.3. */
.bundle-line {
fill: none;
pointer-events: none;
opacity: 0.85;
}
.btn-link {
background: transparent;
border: 0;