From 2cbefd3146869cb79f42f370655826a0e8301e32 Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 13:54:57 +0200 Subject: [PATCH] feat(v5 slice 5): shared-segment bundle viz + clamp count badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 with hard stops oriented perpendicular to the segment, registered in a new on every render. Bundle uses stroke="url(#bundle-grad-…)". - 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. --- web/static/index.html | 2 + web/static/main.js | 134 ++++++++++++++++++++++++++++++++++++++---- web/static/style.css | 15 +++++ 3 files changed, 139 insertions(+), 12 deletions(-) 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 @@ <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> 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:<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 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;