diff --git a/web/static/index.html b/web/static/index.html
index 9664e7b..0658418 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -53,10 +53,12 @@
diff --git a/web/static/main.js b/web/static/main.js
index b33b229..cd96c81 100644
--- a/web/static/main.js
+++ b/web/static/main.js
@@ -406,13 +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 });
@@ -556,7 +560,12 @@ function renderCanvas() {
// 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.
+ // 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;
@@ -566,6 +575,15 @@ function renderCanvas() {
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,
@@ -594,9 +612,27 @@ function renderCanvas() {
}
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) {
- const vertices = cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
- if (vertices.length < 2) 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. Mid-vertices (clamps) are unchanged.
@@ -653,23 +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: from-anchor, then
-// each clamp's (x, y) in ord, then to-anchor. Returns [] if either
-// endpoint can't be resolved.
-function cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
+
+// 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: for a port-anchored endpoint
+// - device: for a device-anchored endpoint (no port)
+// - io: for an IO-anchored endpoint
+// - clamp: 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 [];
- const out = [fromAnchor];
+ 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) out.push({ x: cl.x, y: cl.y });
+ if (cl) {
+ vertices.push({ x: cl.x, y: cl.y });
+ keys.push(`clamp:${cl.id}`);
+ }
}
- out.push(toAnchor);
- return out;
+ 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
diff --git a/web/static/style.css b/web/static/style.css
index 0e0a573..c2950c1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -249,6 +249,21 @@ body {
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;