Add collapsible device nodes, cable bundling, and aggregated edges

Canvas devices can now collapse to a compact per-direction-group view; when
collapsed, cables between the same two devices of the same type merge into
one dashed "virtual" edge with a ×N marker so a busy rig doesn't turn into
a wall of parallel lines. Adds CableBundle so several real Connections
(a stereo RCA pair, a snake's channels) can be grouped and bought as one
BOM line while keeping each leg's own family-checked cable type intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-28 10:04:37 -05:00
co-authored by Claude Sonnet 5
parent 29c3ed46a3
commit 143a96aed1
11 changed files with 811 additions and 113 deletions
+83 -57
View File
@@ -1,5 +1,5 @@
import { allCableTypes, manufacturerName } from './diagram'
import type { CableType, Catalog, Connection, Device, Diagram } from './types'
import type { CableType, Catalog, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
* is undefined for the bucket of connections that have no length entered
@@ -62,22 +62,23 @@ function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
}
/** A connection's cost for BOM purposes: its own override if set, else
* `costPerUnit * distance` if both the cable type's rate and the
* connection's distance are known. Undefined means "not computable" —
* callers must exclude it from totals rather than treating it as zero. */
function connectionCost(connection: Connection, cableType: CableType | undefined): number | undefined {
if (typeof connection.cost === 'number' && !Number.isNaN(connection.cost)) {
return connection.cost
/** A connection's (or a bundle's — same shape, just its own distance/cost
* standing in for a member's) cost for BOM purposes: its own override if
* set, else `costPerUnit * distance` if both the cable type's rate and the
* distance are known. Undefined means "not computable" — callers must
* exclude it from totals rather than treating it as zero. */
function runCost(run: { cost?: number; distance?: number }, cableType: CableType | undefined): number | undefined {
if (typeof run.cost === 'number' && !Number.isNaN(run.cost)) {
return run.cost
}
if (
cableType &&
typeof cableType.costPerUnit === 'number' &&
!Number.isNaN(cableType.costPerUnit) &&
typeof connection.distance === 'number' &&
!Number.isNaN(connection.distance)
typeof run.distance === 'number' &&
!Number.isNaN(run.distance)
) {
return cableType.costPerUnit * connection.distance
return cableType.costPerUnit * run.distance
}
return undefined
}
@@ -98,6 +99,65 @@ function builtInPortIds(devices: Device[]): Set<string> {
return ids
}
/** Folds one purchasable unit — a plain connection, or a whole bundle
* standing in for its members — into the running cableLines/lengthLines
* maps. Shared by both so a bundle is aggregated (and combines with other
* same-type/same-length units, bundled or not) exactly like a connection
* would be, just contributing one unit regardless of how many legs it has. */
function addCableUnit(
cableLines: Map<string, BomCableLine>,
lengthLines: Map<string, Map<number | undefined, BomCableLengthLine>>,
cableTypes: Map<string, CableType>,
run: { cableTypeId: string; distance?: number; cost?: number },
): void {
const cableType = cableTypes.get(run.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
const existing = cableLines.get(run.cableTypeId) ?? {
cableTypeId: run.cableTypeId,
cableTypeName: name,
unit,
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
lengths: [],
}
existing.count += 1
const distance = typeof run.distance === 'number' && !Number.isNaN(run.distance) ? run.distance : undefined
if (distance !== undefined) {
existing.totalLength += distance
} else {
existing.connectionsMissingLength += 1
}
const cost = runCost(run, cableType)
if (cost !== undefined) {
existing.totalCost += cost
} else {
existing.connectionsMissingCost += 1
}
cableLines.set(run.cableTypeId, existing)
const byLength = lengthLines.get(run.cableTypeId) ?? new Map<number | undefined, BomCableLengthLine>()
const lengthLine = byLength.get(distance) ?? {
distance,
count: 0,
totalLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
}
lengthLine.count += 1
if (distance !== undefined) lengthLine.totalLength += distance
if (cost !== undefined) {
lengthLine.totalCost += cost
} else {
lengthLine.connectionsMissingCost += 1
}
byLength.set(distance, lengthLine)
lengthLines.set(run.cableTypeId, byLength)
}
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const builtIn = builtInPortIds(diagram.devices)
@@ -114,53 +174,19 @@ export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
// for it here, not even a "missing cost" note; the canvas is where this
// connection's built-in-ness is visible, not the shopping list.
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
const cableType = cableTypes.get(connection.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft'
const existing = cableLines.get(connection.cableTypeId) ?? {
cableTypeId: connection.cableTypeId,
cableTypeName: name,
unit,
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
lengths: [],
}
existing.count += 1
const distance =
typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ? connection.distance : undefined
if (distance !== undefined) {
existing.totalLength += distance
} else {
existing.connectionsMissingLength += 1
}
const cost = connectionCost(connection, cableType)
if (cost !== undefined) {
existing.totalCost += cost
} else {
existing.connectionsMissingCost += 1
}
cableLines.set(connection.cableTypeId, existing)
// Represented by its bundle below instead — a bundled leg never gets
// its own BOM line.
if (connection.bundleId) continue
addCableUnit(cableLines, lengthLines, cableTypes, connection)
}
const byLength = lengthLines.get(connection.cableTypeId) ?? new Map<number | undefined, BomCableLengthLine>()
const lengthLine = byLength.get(distance) ?? {
distance,
count: 0,
totalLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
}
lengthLine.count += 1
if (distance !== undefined) lengthLine.totalLength += distance
if (cost !== undefined) {
lengthLine.totalCost += cost
} else {
lengthLine.connectionsMissingCost += 1
}
byLength.set(distance, lengthLine)
lengthLines.set(connection.cableTypeId, byLength)
for (const bundle of diagram.bundles) {
// A bundle with no remaining members (every leg was rewired or deleted)
// shouldn't normally exist — removeConnection/removeConnectionFromBundle
// prune it — but skip defensively rather than phantom-charging for it.
const hasMember = diagram.connections.some((c) => c.bundleId === bundle.id)
if (!hasMember) continue
addCableUnit(cableLines, lengthLines, cableTypes, bundle)
}
for (const [cableTypeId, line] of cableLines) {
+1
View File
@@ -158,6 +158,7 @@ export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
updatedAt: now,
devices: [],
connections: [],
bundles: [],
}
}
+29
View File
@@ -155,6 +155,34 @@ export interface Connection {
cost?: number
label?: string
notes?: string
/** Set when this run is one leg of a multi-conductor physical cable (a
* stereo RCA pair, an 8-channel snake) — see CableBundle's own comment.
* Each leg keeps its own real ports and its own family-checked
* `cableTypeId` (still a valid single-run cable for *this* port pair);
* the bundle is purely a BOM/visual grouping layered on top. */
bundleId?: string
}
/**
* A physical multi-conductor cable that's wired as several independent
* point-to-point Connections (e.g. a stereo RCA pair's L and R legs, or an
* 8-channel snake's individual channels) but is bought and counted as one
* item. Deliberately decoupled from its members' own `cableTypeId`s — a
* member's cable type has to satisfy that specific port pair's family
* compatibility for wiring purposes, while this `cableTypeId` is purely
* "what to put on the shopping list" (often a catalog entry, like "8-Channel
* Snake", that wouldn't make sense as any single leg's own type). See
* domain/bom.ts: a connection with `bundleId` set stops contributing its own
* unit to the BOM; each bundle with at least one member contributes exactly
* one unit instead, using its own cableTypeId/distance/cost.
*/
export interface CableBundle {
id: string
name: string
cableTypeId: string
distance?: number
cost?: number
notes?: string
}
export interface Diagram {
@@ -164,6 +192,7 @@ export interface Diagram {
updatedAt: string
devices: Device[]
connections: Connection[]
bundles: CableBundle[]
}
/**