diff --git a/web/static/main.js b/web/static/main.js index 34563d2..6e6c52b 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -293,10 +293,14 @@ function renderCanvas() { for (const d of state.devices) { const g = svgEl("g", { "data-device-id": d.id }); + // Stroke = the user-picked colour; fill = a 12% tint of it via + // color-mix so the device "reads" coloured without becoming garish. + // Inline style beats the .device-rect class CSS, which is why CSS + // no longer hard-codes stroke/fill on that class. const rect = svgEl("rect", { x: d.x, y: d.y, width: d.width, height: d.height, class: "device-rect svg-draggable", - stroke: d.color, + style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`, rx: 3, ry: 3, }); if (state.selection?.kind === "device" && state.selection.id === d.id) { @@ -1008,7 +1012,7 @@ function renderInspectorPort(body, id) { const ct = state.cableTypes.find((t) => t.id === prt.type_id); const ctColor = ct?.color || "#888"; const ctName = ct?.name || "?"; - const currentEdge = edgeOf(dev, prt); + const currentEdge = portEdge(prt, dev); body.innerHTML = `
Port
@@ -1046,13 +1050,26 @@ function renderInspectorPort(body, id) { body.querySelector("#port-edge").addEventListener("change", async (e) => { if (!state.active) return; - const edge = /** @type {HTMLSelectElement} */ (e.target).value; - const { xOff, yOff } = edgeCenter(dev, edge); + const newEdge = /** @type {HTMLSelectElement} */ (e.target).value; + const oldEdge = portEdge(prt, dev); + if (newEdge === oldEdge) return; + // PATCH to a temp position on the new edge so portEdge() classifies + // this port onto newEdge in the upcoming relayouts. The temp position + // gets overwritten by relayoutEdge(newEdge); the only thing that + // matters is that the port is unambiguously on the right edge. + const tmp = edgeCentre(dev, newEdge); try { const updated = await patchPort(state.active.id, prt.id, { - x_offset: xOff, y_offset: yOff, + x_offset: tmp.xOff, y_offset: tmp.yOff, }); Object.assign(prt, updated); + // Re-space both affected edges: the one the port left and the one + // it landed on. Order doesn't matter — they operate on disjoint + // port sets. + await Promise.all([ + relayoutEdge(dev.id, oldEdge), + relayoutEdge(dev.id, newEdge), + ]); renderCanvas(); } catch (ex) { alert(`Move port failed: ${ex.message}`); @@ -1062,11 +1079,15 @@ function renderInspectorPort(body, id) { body.querySelector("#port-delete").addEventListener("click", async () => { if (!state.active) return; if (!confirm("Delete this port?")) return; + const wasEdge = portEdge(prt, dev); try { await deletePort(state.active.id, prt.id); state.ports = state.ports.filter((p) => p.id !== prt.id); const snap = await getSnapshot(state.active.id); state.cables = snap.cables || []; + // Re-space the edge the deleted port was on so the survivors + // shift back to even spacing. + await relayoutEdge(dev.id, wasEdge); state.selection = null; render(); } catch (ex) { @@ -1075,19 +1096,11 @@ function renderInspectorPort(body, id) { }); } -// Which edge does a port currently sit on? Matches the convention in -// snapToDeviceEdge: x_offset = 0 → left, = width → right, y_offset = 0 -// → top, otherwise bottom (the default). -function edgeOf(dev, prt) { - if (prt.x_offset <= 0) return "left"; - if (prt.x_offset >= dev.width) return "right"; - if (prt.y_offset <= 0) return "top"; - return "bottom"; -} - // Centre of the named edge, expressed as (x_offset, y_offset) relative -// to the device origin. -function edgeCenter(dev, edge) { +// to the device origin. Used as a temp anchor when moving a port between +// edges — the precise centre value is immediately overwritten by +// relayoutEdge, but it has to land on the right edge. +function edgeCentre(dev, edge) { switch (edge) { case "top": return { xOff: dev.width / 2, yOff: 0 }; case "right": return { xOff: dev.width, yOff: dev.height / 2 }; @@ -1567,12 +1580,79 @@ function snapToDeviceEdge(device, x, y) { // 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" }; + 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" }; } +// Which edge does a given port currently sit on? Snaps the port's +// existing (x_offset, y_offset) to the nearest of the four edges using +// the same distance heuristic as snapToDeviceEdge. +function portEdge(port, device) { + const dL = port.x_offset; + const dR = device.width - port.x_offset; + const dT = port.y_offset; + const dB = device.height - port.y_offset; + const min = Math.min(dL, dR, dT, dB); + if (min === dL) return "left"; + if (min === dR) return "right"; + if (min === dT) return "top"; + return "bottom"; +} + +// Even-spacing layout invariant for ports on a device edge: m wants +// every port lined up on its edge with no overlap. After any change +// to the set of ports on an edge (add / move / delete), recompute the +// offsets so that for N ports they sit at relative positions +// i/(N+1) along the edge for i=1..N. +// +// Sort key preserves m's intent: top/bottom by current x_offset +// (left→right), left/right by current y_offset (top→bottom). For a +// freshly-placed port, that's the click position projected onto the +// edge, so the port keeps its "I dropped it roughly here" rank. +// +// PATCHes only the ports whose offsets actually change, and updates +// state.ports in place. Returns once every PATCH resolves. +async function relayoutEdge(deviceID, edge) { + if (!state.active) return; + const dev = state.devices.find((d) => d.id === deviceID); + if (!dev) return; + const isHorizontal = edge === "top" || edge === "bottom"; + const axis = isHorizontal ? dev.width : dev.height; + const peers = state.ports + .filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge) + .slice() + .sort((a, b) => + isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset); + const n = peers.length; + if (n === 0) return; + const patches = []; + for (let i = 0; i < n; i++) { + const parallel = axis * (i + 1) / (n + 1); + let xOff, yOff; + switch (edge) { + case "top": xOff = parallel; yOff = 0; break; + case "bottom": xOff = parallel; yOff = dev.height; break; + case "left": xOff = 0; yOff = parallel; break; + case "right": xOff = dev.width; yOff = parallel; break; + } + const p = peers[i]; + if (p.x_offset === xOff && p.y_offset === yOff) continue; + p.x_offset = xOff; + p.y_offset = yOff; + patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff }) + .then((updated) => Object.assign(p, updated))); + } + if (patches.length) { + try { + await Promise.all(patches); + } catch (err) { + alert(`Re-layout failed: ${err.message}`); + } + } +} + /** Port-click flow: * - A cable draw is in progress (cableDrawFromPortID set): * same port → cancel; another port → finish the cable. @@ -1697,6 +1777,13 @@ async function placePortAt(p) { y_offset: snap.yOff, }); state.ports.push(port); + // Re-layout all ports on this edge so the new one + existing ones + // are evenly spaced — m's invariant: never let two ports stack. + await relayoutEdge(did, snap.edge); + // Select the freshly-placed port so the inspector switches to the + // port panel (edge dropdown / label / delete) and the .selected halo + // marks it. + state.selection = { kind: "port", id: port.id }; armTool(null); render(); } catch (e) { @@ -1821,7 +1908,13 @@ function startDrag(e, kind, id) { } } - e.currentTarget.classList.add("dragging"); + // Capture the rect element NOW: by the time onUp fires async, the + // browser has nulled out e.currentTarget on the pointerdown event, + // so `e.currentTarget.classList.remove("dragging")` would throw + // "Cannot read properties of null". Sherlock surfaced this from the + // click-only path that pageerror-spammed every device click. + const dragTarget = /** @type {Element} */ (e.currentTarget); + dragTarget.classList.add("dragging"); svg.setPointerCapture(e.pointerId); let dragged = false; @@ -1843,7 +1936,7 @@ function startDrag(e, kind, id) { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.releasePointerCapture(e.pointerId); - e.currentTarget.classList.remove("dragging"); + dragTarget.classList.remove("dragging"); if (!dragged) { render(); return; } // click only — re-render to apply selection halo if (!state.active) return; diff --git a/web/static/style.css b/web/static/style.css index d30ca38..84e041c 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -183,9 +183,10 @@ body { pointer-events: none; } +/* Stroke + fill come from the device's user-set colour, written as + inline style in renderCanvas — leaving them out of .device-rect so + the author CSS doesn't override the inline style. */ .device-rect { - fill: #fff; - stroke: var(--text); stroke-width: 1.5; } .device-rect.selected { stroke-width: 3; }