fix(ui): even-spacing relayout on every port-set change
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.
This commit is contained in:
@@ -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 = `
|
||||
<p class="section-title">Port</p>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user