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">
|
<section class="canvas-wrap" aria-label="Diagram">
|
||||||
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<defs id="canvas-defs"></defs>
|
||||||
<g id="canvas-frames"></g>
|
<g id="canvas-frames"></g>
|
||||||
<g id="canvas-devices"></g>
|
<g id="canvas-devices"></g>
|
||||||
<g id="canvas-ports"></g>
|
<g id="canvas-ports"></g>
|
||||||
<g id="canvas-cables"></g>
|
<g id="canvas-cables"></g>
|
||||||
|
<g id="canvas-bundles"></g>
|
||||||
<g id="canvas-clamps"></g>
|
<g id="canvas-clamps"></g>
|
||||||
<g id="canvas-io"></g>
|
<g id="canvas-io"></g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -406,13 +406,17 @@ function renderCanvas() {
|
|||||||
const gFrames = $("#canvas-frames");
|
const gFrames = $("#canvas-frames");
|
||||||
const gDevices = $("#canvas-devices");
|
const gDevices = $("#canvas-devices");
|
||||||
const gCables = $("#canvas-cables");
|
const gCables = $("#canvas-cables");
|
||||||
|
const gBundles = $("#canvas-bundles");
|
||||||
const gClamps = $("#canvas-clamps");
|
const gClamps = $("#canvas-clamps");
|
||||||
const gIO = $("#canvas-io");
|
const gIO = $("#canvas-io");
|
||||||
|
const gDefs = $("#canvas-defs");
|
||||||
gFrames.innerHTML = "";
|
gFrames.innerHTML = "";
|
||||||
gDevices.innerHTML = "";
|
gDevices.innerHTML = "";
|
||||||
gCables.innerHTML = "";
|
gCables.innerHTML = "";
|
||||||
|
gBundles.innerHTML = "";
|
||||||
gClamps.innerHTML = "";
|
gClamps.innerHTML = "";
|
||||||
gIO.innerHTML = "";
|
gIO.innerHTML = "";
|
||||||
|
gDefs.innerHTML = "";
|
||||||
|
|
||||||
for (const f of state.frames) {
|
for (const f of state.frames) {
|
||||||
const g = svgEl("g", { "data-frame-id": f.id });
|
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).
|
// Clamps — small grey rounded squares (per design v5 §11.9 q1).
|
||||||
// Slice 4 wires them into cable polylines; for slice 3 they just
|
// 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) {
|
for (const cl of state.clamps) {
|
||||||
const g = svgEl("g", { "data-clamp-id": cl.id });
|
const g = svgEl("g", { "data-clamp-id": cl.id });
|
||||||
const sz = CLAMP_SIZE;
|
const sz = CLAMP_SIZE;
|
||||||
@@ -566,6 +575,15 @@ function renderCanvas() {
|
|||||||
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
|
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
|
||||||
});
|
});
|
||||||
g.append(rect);
|
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) {
|
if (cl.label) {
|
||||||
const label = svgEl("text", {
|
const label = svgEl("text", {
|
||||||
x: cl.x + sz / 2 + 4, y: cl.y + 3,
|
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);
|
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) {
|
for (const c of state.cables) {
|
||||||
const vertices = cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
|
const built = cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
|
||||||
if (vertices.length < 2) continue;
|
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
|
// Replug preview: while m drags an endpoint handle, override the
|
||||||
// affected end with the live cursor world position so the line
|
// affected end with the live cursor world position so the line
|
||||||
// tracks the pointer. Mid-vertices (clamps) are unchanged.
|
// 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
|
// Compute the resolved polyline vertices for a cable plus a stable
|
||||||
// endpoint can't be resolved.
|
// vertex-key per vertex used to detect shared segments for bundle viz.
|
||||||
function cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
|
// 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 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);
|
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||||||
if (!fromAnchor || !toAnchor) return [];
|
if (!fromAnchor || !toAnchor) return null;
|
||||||
const out = [fromAnchor];
|
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) || [];
|
const clamps = clampsByCable.get(c.id) || [];
|
||||||
for (const cc of clamps) {
|
for (const cc of clamps) {
|
||||||
const cl = clampByID.get(cc.clamp_id);
|
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);
|
vertices.push(toAnchor);
|
||||||
return out;
|
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
|
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
|
||||||
|
|||||||
@@ -249,6 +249,21 @@ body {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
pointer-events: none;
|
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 {
|
.btn-link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
Reference in New Issue
Block a user