From b28fc0c565cbeeed9f42124f52d36ae3a74aaa0d Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 11:19:16 +0200 Subject: [PATCH] fix(ui): even-spacing relayout on every port-set change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's stronger invariant: ports must never overlap and must line up on their edge. Replace the slide-collision dedup with full even-spacing re-layout — for N ports on an edge, position i goes to axis · i/(N+1) for i=1..N. - New portEdge(port, dev) — snaps a port's current offsets to the nearest of the four edges (same heuristic as snapToDeviceEdge). - New relayoutEdge(deviceID, edge) — re-spaces every port on the device-edge and PATCHes the ones whose offsets actually change. Sort key: x_offset for top/bottom, y_offset for left/right — preserves m's "I dropped it roughly here" order. Applied on: - placePortAt — re-layout the edge after the new port is created. - inspector edge picker — capture oldEdge, PATCH the port to the centre of newEdge, then re-layout BOTH old and new edges. - port delete — re-layout the edge the deleted port was on so the survivors collapse back to even spacing. snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts arg and resolveCollision helper); the layout invariant is owned by relayoutEdge now. edgeOf folded into portEdge. --- web/static/main.js | 158 ++++++++++++++++++++++++++++----------------- 1 file changed, 98 insertions(+), 60 deletions(-) diff --git a/web/static/main.js b/web/static/main.js index f503f6d..8c2e00a 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -1008,7 +1008,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 +1046,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 +1075,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 +1092,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 }; @@ -1557,7 +1566,7 @@ function openNewDeviceModal(geom) { /** 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, existingPorts) { +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); @@ -1567,49 +1576,77 @@ function snapToDeviceEdge(device, x, y, existingPorts) { // 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)); - let snap; - if (min === dxLeft) snap = { xOff: 0, yOff: localY, edge: "left" }; - else if (min === dxRight) snap = { xOff: device.width, yOff: localY, edge: "right" }; - else if (min === dyTop) snap = { xOff: localX, yOff: 0, edge: "top" }; - else snap = { xOff: localX, yOff: device.height, edge: "bottom" }; - return resolveCollision(snap, device, existingPorts); + 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" }; } -// If the snap lands within 8px of another port on the same edge, slide -// along the edge in 16px steps (alternating directions, expanding) until -// the slot is clear or we run out of room. Prevents pixel-perfect stacks -// when m drops two ports near the same midpoint — sherlock's secondary -// fix for the invisible-stacking problem in +Port. -function resolveCollision(snap, device, existingPorts) { - if (!existingPorts || !existingPorts.length) return snap; - const onSameEdge = (port) => { - switch (snap.edge) { - case "left": return port.x_offset === 0; - case "right": return port.x_offset === device.width; - case "top": return port.y_offset === 0; - case "bottom": return port.y_offset === device.height; +// 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}`); } - return false; - }; - const isHorizontal = snap.edge === "top" || snap.edge === "bottom"; - const axisMax = isHorizontal ? device.width : device.height; - const peers = existingPorts.filter(onSameEdge).map((p) => isHorizontal ? p.x_offset : p.y_offset); - if (!peers.length) return snap; - const step = 16, tol = 8; - let pos = isHorizontal ? snap.xOff : snap.yOff; - const clear = (v) => peers.every((q) => Math.abs(v - q) >= tol); - if (clear(pos)) return snap; - // Try increasing offsets in both directions until we find a free slot. - for (let i = 1; i * step <= axisMax; i++) { - const up = pos + i * step; - const down = pos - i * step; - if (up <= axisMax && clear(up)) { pos = up; break; } - if (down >= 0 && clear(down)) { pos = down; break; } } - pos = Math.max(0, Math.min(axisMax, pos)); - return isHorizontal - ? { xOff: pos, yOff: snap.yOff, edge: snap.edge } - : { xOff: snap.xOff, yOff: pos, edge: snap.edge }; } /** Port-click flow: @@ -1728,8 +1765,7 @@ async function placePortAt(p) { if (did == null || tid == null) { armTool(null); return; } const dev = state.devices.find((d) => d.id === did); if (!dev) { armTool(null); return; } - const sibs = state.ports.filter((p) => p.device_id === did); - const snap = snapToDeviceEdge(dev, p.x, p.y, sibs); + const snap = snapToDeviceEdge(dev, p.x, p.y); try { const port = await createPort(state.active.id, did, { type_id: tid, @@ -1737,10 +1773,12 @@ 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 - // makes the new circle visually obvious — sherlock's primary fix for - // the "+Port feels dead" perception bug. + // marks it. state.selection = { kind: "port", id: port.id }; armTool(null); render();