diff --git a/web/static/main.js b/web/static/main.js
index 353c796..34563d2 100644
--- a/web/static/main.js
+++ b/web/static/main.js
@@ -63,7 +63,7 @@ const state = {
portToolTypeID: /** @type {number|null} */ (null),
/** 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", id: number} | null} */ selection: null,
+ /** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -310,21 +310,26 @@ function renderCanvas() {
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
- // Stroke colour = the cable_type colour the port carries; fill stays white
- // so the port reads against any device colour.
+ // Both fill and stroke = cable_type colour so the port is obviously coloured
+ // against the device rect.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
- const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
+ const isCableFrom = state.cableDrawFromPortID === prt.id;
+ const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
+ const cls = "port-circle"
+ + (isCableFrom ? " cable-from" : "")
+ + (isSelected ? " selected" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: cls,
+ fill: color,
stroke: color,
"data-port-id": prt.id,
});
- // Slice 7: port-click drives the manual cable-draw flow.
+ // Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
g.append(c);
}
@@ -431,6 +436,7 @@ function renderInspector() {
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
+ case "port": return renderInspectorPort(body, state.selection.id);
default: body.innerHTML = `
Nothing selected.
`;
}
}
@@ -993,6 +999,104 @@ function renderInspectorIO(body, id) {
});
}
+// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
+function renderInspectorPort(body, id) {
+ const prt = state.ports.find((p) => p.id === id);
+ if (!prt) { body.innerHTML = ""; return; }
+ const dev = state.devices.find((d) => d.id === prt.device_id);
+ if (!dev) { body.innerHTML = ""; return; }
+ 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);
+
+ body.innerHTML = `
+ Port
+
+ - device
- ${dev.name}
+ - type
+ - ${ctName}
+
+
+
+
+
+
+ `;
+ body.querySelector("#port-label").value = prt.label ?? "";
+ body.querySelector("#port-edge").value = currentEdge;
+
+ bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
+ if (!state.active) return;
+ const updated = await patchPort(state.active.id, prt.id, { label });
+ Object.assign(prt, updated);
+ renderCanvas();
+ });
+
+ 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);
+ try {
+ const updated = await patchPort(state.active.id, prt.id, {
+ x_offset: xOff, y_offset: yOff,
+ });
+ Object.assign(prt, updated);
+ renderCanvas();
+ } catch (ex) {
+ alert(`Move port failed: ${ex.message}`);
+ }
+ });
+
+ body.querySelector("#port-delete").addEventListener("click", async () => {
+ if (!state.active) return;
+ if (!confirm("Delete this port?")) return;
+ 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 || [];
+ state.selection = null;
+ render();
+ } catch (ex) {
+ alert(`Delete failed: ${ex.message}`);
+ }
+ });
+}
+
+// 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) {
+ switch (edge) {
+ case "top": return { xOff: dev.width / 2, yOff: 0 };
+ case "right": return { xOff: dev.width, yOff: dev.height / 2 };
+ case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
+ case "left": return { xOff: 0, yOff: dev.height / 2 };
+ default: return { xOff: dev.width / 2, yOff: dev.height };
+ }
+}
+
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1470,30 +1574,50 @@ function snapToDeviceEdge(device, x, y) {
}
/** Port-click flow:
- * 1) No source picked yet → this port becomes the source. Highlight it.
- * 2) Source already picked → this port is the target. POST a cable
- * with `from_port_id` / `to_port_id`, type from the source port,
- * auto=0. Shift-click flips the target to "bind to whole device"
- * (uses `to_device_id` instead). */
+ * - A cable draw is in progress (cableDrawFromPortID set):
+ * same port → cancel; another port → finish the cable.
+ * - Otherwise, no tool armed:
+ * select the port (inspector shows edge picker + label + delete).
+ * - Otherwise, any non-cable tool armed:
+ * bubble so the canvas-level tool handler runs (lets +Port place
+ * a new port even when the click lands on an existing one). */
function onPortPointerDown(e, port) {
if (!state.active) return;
- if (state.tool && state.tool !== "cable") return; // other tool wins
- e.stopPropagation();
- e.preventDefault();
- if (state.cableDrawFromPortID == null) {
+
+ // Cable-draw flow takes precedence whenever a source is already picked.
+ if (state.cableDrawFromPortID != null) {
+ e.stopPropagation();
+ e.preventDefault();
+ if (state.cableDrawFromPortID === port.id) {
+ state.cableDrawFromPortID = null;
+ armTool(null);
+ render();
+ return;
+ }
+ finishCableDrawAt(port, e.shiftKey);
+ return;
+ }
+
+ // No cable in progress, no tool: select the port → inspector pane.
+ if (!state.tool) {
+ e.stopPropagation();
+ e.preventDefault();
+ state.selection = { kind: "port", id: port.id };
+ render();
+ return;
+ }
+
+ // The cable tool: start a draw from this port.
+ if (state.tool === "cable") {
+ e.stopPropagation();
+ e.preventDefault();
state.cableDrawFromPortID = port.id;
- armTool("cable"); // get the crosshair cursor + visual cue
render();
return;
}
- if (state.cableDrawFromPortID === port.id) {
- // Cancel — clicked the same port again.
- state.cableDrawFromPortID = null;
- armTool(null);
- render();
- return;
- }
- finishCableDrawAt(port, e.shiftKey);
+
+ // Any other tool (port / frame / device / io / req): let the click
+ // bubble up so the canvas-level branch fires.
}
async function finishCableDrawAt(targetPort, shiftKey) {
diff --git a/web/static/style.css b/web/static/style.css
index 85af292..d30ca38 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -277,16 +277,17 @@ body {
user-select: none;
}
-/* Ports — small circles laid out along the device edge. The fill is
- white so the port is visible regardless of the underlying device's
- stroke; the stroke colour comes from the cable_type the port carries
- (set inline in JS). */
+/* Ports — small circles laid out along the device edge. Both fill and
+ stroke come from the cable_type the port carries (set inline in JS)
+ so the port reads clearly as a coloured anchor on the device. */
.port-circle {
- fill: #fff;
- stroke: var(--text);
stroke-width: 2;
cursor: crosshair;
}
+.port-circle.selected {
+ stroke-width: 3;
+ filter: drop-shadow(0 0 4px var(--accent));
+}
.port-row {
display: grid;
@@ -296,11 +297,15 @@ body {
font-size: 12px;
padding: 2px 0;
}
-.port-row .swatch {
+.port-row .swatch,
+.swatch {
+ display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
+ margin-right: 6px;
+ vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }