feat(v5 slice 5): shared-segment bundle viz + clamp count badges
Walks every cable's polyline, keys each vertex by stable identity
(port:N / device:N / io:N / clamp:N), and accumulates cables by
undirected segment-key. Segments with ≥ 2 cables get a thick striped
overlay line in a new <g id="canvas-bundles"> layer, drawn on top of
the individual cable lines so the shared portion reads as a bundle
while endpoints still fan out to each cable's port colour.
- Stripe width: 2 + N px, capped at 12 (design v5 §11.3).
- Stripe order: by distinct cable-type count (ties by id) per
v5 §11.9 q4.
- Implementation: SVG <linearGradient> with hard stops oriented
perpendicular to the segment, registered in a new
<defs id="canvas-defs"> on every render. Bundle <line> uses
stroke="url(#bundle-grad-…)".
- <title> child lists the cable types and total cable count for
hover tooltips.
- Clamp render gains a ×N badge when ≥ 2 cables route through it,
derived independently from state.cableClamps.
Helper rename: cableVertices → cableVerticesWithKeys (returns
{vertices, keys}). The keys array also feeds the shared-segment
detection — keeps the geometry + identity tracking in one pass.
This commit is contained in:
@@ -53,10 +53,12 @@
|
||||
|
||||
<section class="canvas-wrap" aria-label="Diagram">
|
||||
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
||||
<defs id="canvas-defs"></defs>
|
||||
<g id="canvas-frames"></g>
|
||||
<g id="canvas-devices"></g>
|
||||
<g id="canvas-ports"></g>
|
||||
<g id="canvas-cables"></g>
|
||||
<g id="canvas-bundles"></g>
|
||||
<g id="canvas-clamps"></g>
|
||||
<g id="canvas-io"></g>
|
||||
</svg>
|
||||
|
||||
@@ -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:<id> for a port-anchored endpoint
|
||||
// - device:<id> for a device-anchored endpoint (no port)
|
||||
// - io:<id> for an IO-anchored endpoint
|
||||
// - clamp:<id> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user