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:
+83
-57
@@ -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) {
|
||||
|
||||
@@ -158,6 +158,7 @@ export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
|
||||
updatedAt: now,
|
||||
devices: [],
|
||||
connections: [],
|
||||
bundles: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user