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
+40 -4
View File
@@ -4,6 +4,26 @@ export type CableEdgeData = {
cableTypeName: string
distance?: number
unit: string
/** Set when this edge stands in for more than one real connection between
* the same two (collapsed) devices of the same cable type —
* organized-ideas.md §4's "virtual cable". A per-connection distance
* doesn't generalize across the group, so it's omitted whenever this is
* set; expand either device to see the real per-cable lengths instead. */
aggregateCount?: number
/** Set when either endpoint's port has a built-in/captive cable — the BOM
* omits this connection entirely (nothing to buy), but the wiring is
* still real, so it stays on the canvas with its own marker instead of
* looking identical to a connection that needs a cable purchased. Never
* true at the same time as aggregateCount>1 — FlowCanvas never merges a
* built-in connection with anything else. */
builtIn?: boolean
/** Set when this leg is part of a CableBundle (a stereo pair, a snake) —
* never true alongside aggregateCount or builtIn, since a bundled
* connection never merges with anything (see FlowCanvas). Colors the
* edge and swaps the label to the bundle's own name, so every leg of the
* same physical cable reads as visually one thing. */
bundleName?: string
bundleColor?: string
}
export type CableFlowEdge = Edge<CableEdgeData, 'cable'>
@@ -29,8 +49,15 @@ export default function CableEdge({
targetPosition,
})
const isAggregate = !!data?.aggregateCount && data.aggregateCount > 1
const isBuiltIn = !!data?.builtIn
const bundleColor = data?.bundleName ? data.bundleColor : undefined
const label = data
? `${data.cableTypeName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}`
? bundleColor
? `${data.bundleName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}`
: `${data.cableTypeName}${
isAggregate ? ` ×${data.aggregateCount}` : typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''
}${isBuiltIn ? ' · Built-in' : ''}`
: ''
return (
@@ -39,13 +66,22 @@ export default function CableEdge({
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{ stroke: selected ? '#6366f1' : '#94a3b8', strokeWidth: selected ? 2.5 : 1.5 }}
style={{
stroke: selected ? '#6366f1' : bundleColor ?? (isBuiltIn ? '#64748b' : '#94a3b8'),
strokeWidth: selected ? 2.5 : 1.5,
strokeDasharray: isAggregate ? '6 3' : undefined,
}}
/>
{label && (
<EdgeLabelRenderer>
<div
className="nodrag nopan absolute rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-600 shadow-sm"
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }}
className={`nodrag nopan absolute rounded border px-1.5 py-0.5 text-[10px] shadow-sm ${
bundleColor ? '' : isAggregate ? 'border-indigo-200 bg-indigo-50 text-indigo-700' : 'border-slate-200 bg-white text-slate-600'
}`}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
...(bundleColor ? { borderColor: bundleColor, color: bundleColor, backgroundColor: 'white' } : {}),
}}
>
{label}
</div>
+137 -11
View File
@@ -1,6 +1,7 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { Device, Port, PortType } from '../../domain/types'
import { buildHandleId } from './handleIds'
import type { Device, Port, PortDirection, PortType } from '../../domain/types'
import Chevron from '../common/Chevron'
import { buildGroupHandleId, buildHandleId } from './handleIds'
export interface PortInfo {
port: Port
@@ -14,7 +15,14 @@ export type DeviceNodeData = {
/** Resolved from the device's category id — see FlowCanvas — since a
* custom category's id is an opaque uuid, not something to show raw. */
categoryName: string
/** Resolved from the device's manufacturerId, same reasoning as
* categoryName — undefined when no manufacturer is set. */
manufacturerName: string | undefined
portInfos: PortInfo[]
/** Per-device compact/expanded toggle (organized-ideas.md §4) — local UI
* state in FlowCanvas, not persisted, defaulting to compact. */
expanded: boolean
onToggleExpanded: () => void
}
export type DeviceFlowNode = Node<DeviceNodeData, 'device'>
@@ -56,27 +64,134 @@ function PortHandle({ portInfo, position }: { portInfo: PortInfo; position: Posi
)
}
/** The single attachment point a compact device exposes for one direction
* group — every real connection that would otherwise land on one of this
* group's individual ports attaches here instead (see FlowCanvas's edge
* computation). Always connectable; "full" doesn't apply to a group. */
function GroupHandle({ direction, position }: { direction: PortDirection; position: Position }) {
// Absolutely positioned against the nearest `relative` ancestor (each
// caller below marks its row `relative` for this), with an explicit
// centering transform — rather than relying on React Flow's own
// `.react-flow__handle-*` classes to center it, since our layouts (a
// horizontal input/output row, a vertical bidirectional stack) don't
// match the single-port layout those classes assume, and partially
// overriding them via inline style leaves some of their rules (e.g. a
// stale `left: 50%`) still in effect with nothing to counter them.
const edgeStyle =
position === Position.Left
? { left: -13, top: '50%', transform: 'translateY(-50%)' }
: position === Position.Right
? { right: -13, top: '50%', transform: 'translateY(-50%)' }
: { bottom: -13, left: '50%', transform: 'translateX(-50%)' }
return (
<Handle
type="source"
position={position}
id={buildGroupHandleId(direction)}
// A group handle only exists so real edges have somewhere to land while
// this device is collapsed — dragging a *new* wire from it has no
// single port to attach to, so making one requires expanding first
// rather than silently failing connection validation.
isConnectableStart={false}
isConnectableEnd={false}
style={{
position: 'absolute',
...edgeStyle,
width: 10,
height: 10,
background: '#94a3b8', // slate-400 — neutral, since a group mixes connected/open ports
}}
/>
)
}
function ExpandToggle({ expanded, onToggle }: { expanded: boolean; onToggle: () => void }) {
return (
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
title={expanded ? 'Collapse' : 'Expand'}
className="nodrag shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<Chevron expanded={expanded} />
</button>
)
}
/** "3 connected · 2 open" — omits either half when zero, so a fully-open or
* fully-connected group doesn't read as "0 connected · 3 open" noise. */
function GroupSummary({ portInfos }: { portInfos: PortInfo[] }) {
const connected = portInfos.filter((p) => p.connectionCount > 0).length
const open = portInfos.length - connected
const parts: string[] = []
if (connected > 0) parts.push(`${connected} connected`)
if (open > 0) parts.push(`${open} open`)
return <span className="text-[10px] text-slate-400">{parts.join(' · ')}</span>
}
function CountBadge({ count }: { count: number }) {
if (count === 0) return null
return <span className="shrink-0 rounded bg-orange-50 px-1 text-[10px] text-orange-800">{count}</span>
}
export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>) {
const { device, categoryName, portInfos } = data
function CompactDeviceNode({ data }: { data: DeviceNodeData }) {
const { device, portInfos, expanded, onToggleExpanded } = data
const inputs = portInfos.filter((p) => p.port.direction === 'input')
const outputs = portInfos.filter((p) => p.port.direction === 'output')
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
return (
<div
className={`w-72 rounded-lg border bg-white shadow-sm transition-shadow ${
selected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-slate-300'
}`}
>
<div className="rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
<div className="w-56 rounded-lg border border-slate-300 bg-white shadow-sm">
<div className="flex items-center gap-1.5 rounded-lg px-3 py-2">
<ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{device.name}</div>
</div>
{(inputs.length > 0 || outputs.length > 0 || bidirectional.length > 0) && (
<div className="space-y-1 border-t border-slate-100 px-3 py-2">
{inputs.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<GroupHandle direction="input" position={Position.Left} />
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">In</span>
<GroupSummary portInfos={inputs} />
</div>
)}
{outputs.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">Out</span>
<GroupSummary portInfos={outputs} />
<GroupHandle direction="output" position={Position.Right} />
</div>
)}
{bidirectional.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">I/O</span>
<GroupSummary portInfos={bidirectional} />
<GroupHandle direction="bidirectional" position={Position.Bottom} />
</div>
)}
</div>
)}
</div>
)
}
function ExpandedDeviceNode({ data }: { data: DeviceNodeData }) {
const { device, categoryName, manufacturerName, portInfos, expanded, onToggleExpanded } = data
const inputs = portInfos.filter((p) => p.port.direction === 'input')
const outputs = portInfos.filter((p) => p.port.direction === 'output')
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
return (
<div className="w-72 rounded-lg border border-slate-300 bg-white shadow-sm">
<div className="flex items-start gap-1.5 rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
<ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-slate-800">{device.name}</div>
<div className="truncate text-[11px] text-slate-500">
{[device.manufacturer, device.model].filter(Boolean).join(' ') || categoryName}
{[manufacturerName, device.model].filter(Boolean).join(' ') || categoryName}
</div>
</div>
</div>
@@ -131,3 +246,14 @@ export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>
</div>
)
}
export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>) {
return (
<div
className={`transition-shadow ${selected ? 'rounded-lg ring-2 ring-indigo-200' : ''}`}
style={selected ? { boxShadow: '0 0 0 1px #6366f1' } : undefined}
>
{data.expanded ? <ExpandedDeviceNode data={data} /> : <CompactDeviceNode data={data} />}
</div>
)
}
+235 -28
View File
@@ -3,6 +3,7 @@ import {
ConnectionMode,
Controls,
MiniMap,
Panel,
ReactFlow,
ReactFlowProvider,
useReactFlow,
@@ -13,20 +14,44 @@ import {
type NodeTypes,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { validateConnection } from '../../domain/compatibility'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/diagram'
import {
allCableTypes,
allPortTypes,
cableTypesForConnection,
deviceCategoryName,
getPortType,
manufacturerName,
} from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildHandleId, parsePortId } from './handleIds'
import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template'
const nodeTypes: NodeTypes = { device: DeviceNode }
const edgeTypes: EdgeTypes = { cable: CableEdge }
// A virtual (merged) edge's id carries the real connection ids it stands in
// for, so removing or selecting it can be translated back to the underlying
// domain objects without a parallel id→group lookup table.
const VIRTUAL_EDGE_PREFIX = 'virtual:'
function connectionIdsForEdge(edgeId: string): string[] {
return edgeId.startsWith(VIRTUAL_EDGE_PREFIX) ? edgeId.slice(VIRTUAL_EDGE_PREFIX.length).split(',') : [edgeId]
}
// Deterministic per-bundle color (cycling through a fixed palette by the
// bundle's position in the diagram) so every leg of the same stereo
// pair/snake reads as visually one thing on the canvas — see CableEdge.
const BUNDLE_EDGE_COLORS = ['#d97706', '#059669', '#0284c7', '#c026d3', '#e11d48', '#0d9488', '#ea580c', '#7c3aed']
function bundleColorFor(bundleId: string, bundles: { id: string }[]): string {
const index = bundles.findIndex((b) => b.id === bundleId)
return BUNDLE_EDGE_COLORS[Math.max(index, 0) % BUNDLE_EDGE_COLORS.length]
}
function FlowCanvasInner() {
const diagram = useDiagramStore((s) => s.diagram)
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
@@ -48,6 +73,27 @@ function FlowCanvasInner() {
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
const canEdit = access?.myPermission !== 'view'
// Per-device compact/expanded toggle (organized-ideas.md §4) — purely a
// canvas display preference, so it's local component state rather than
// anything persisted: not on the device, not in localStorage. Every device
// starts compact (absent from the set); membership means "expanded".
// Toggling itself is always allowed even in a view-only diagram, since it
// doesn't touch diagram content — only `canEdit`-gated actions below do.
const [expandedDeviceIds, setExpandedDeviceIds] = useState<Set<string>>(new Set())
const toggleExpanded = useCallback((id: string) => {
setExpandedDeviceIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const expandAll = useCallback(
() => setExpandedDeviceIds(new Set(diagram.devices.map((d) => d.id))),
[diagram.devices],
)
const collapseAll = useCallback(() => setExpandedDeviceIds(new Set()), [])
const portTypes = useMemo(() => allPortTypes(catalog), [catalog])
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
@@ -67,34 +113,163 @@ function FlowCanvasInner() {
type: 'device',
position: device.position,
selected: device.id === selectedDeviceId,
data: { device, categoryName: deviceCategoryName(catalog, device.category), portInfos },
}
}),
[diagram, catalog, portTypes, selectedDeviceId],
)
const edges: CableFlowEdge[] = useMemo(
() =>
diagram.connections.map((connection) => {
const cableType = cableTypes.find((ct) => ct.id === connection.cableTypeId)
return {
id: connection.id,
type: 'cable',
source: connection.sourceDeviceId,
sourceHandle: buildHandleId(connection.sourcePortId),
target: connection.targetDeviceId,
targetHandle: buildHandleId(connection.targetPortId),
selected: connection.id === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: connection.distance,
unit: cableType?.unit ?? 'ft',
device,
categoryName: deviceCategoryName(catalog, device.category),
manufacturerName: manufacturerName(catalog, device.manufacturerId),
portInfos,
expanded: expandedDeviceIds.has(device.id),
onToggleExpanded: () => toggleExpanded(device.id),
},
}
}),
[diagram.connections, cableTypes, selectedConnectionId],
[diagram, catalog, portTypes, selectedDeviceId, expandedDeviceIds, toggleExpanded],
)
// Every real connection always gets its own domain object and its own BOM
// line (domain/bom.ts reads diagram.connections directly and never sees
// this component) — what collapses here is purely which handles an edge
// renders between. A connection whose device is expanded attaches to that
// device's real port handle as before; a connection whose device is
// collapsed attaches to that device's one handle for the port's direction
// instead. When *both* ends of two or more connections resolve to the same
// pair of (device, handle) points and the same cable type, they're drawn as
// a single dashed "virtual cable" edge with a ×N marker — the merge is
// undone the instant either device is expanded again, since the handles
// stop matching. A connection touching a built-in/captive port (bom.ts
// excludes these from the shopping list entirely) never merges with
// anything, even another built-in one — there's no quantity-to-buy for a
// ×N badge to mean anything about, so it always keeps its own edge.
const edges: CableFlowEdge[] = useMemo(() => {
type Resolved = {
connectionId: string
cableTypeId: string
distance: number | undefined
source: string
sourceHandle: string
target: string
targetHandle: string
builtIn: boolean
bundleId: string | undefined
}
const resolved: Resolved[] = []
for (const connection of diagram.connections) {
const sourceDevice = diagram.devices.find((d) => d.id === connection.sourceDeviceId)
const targetDevice = diagram.devices.find((d) => d.id === connection.targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === connection.sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === connection.targetPortId)
if (!sourceDevice || !targetDevice || !sourcePort || !targetPort) continue
const sourceHandle = expandedDeviceIds.has(sourceDevice.id)
? buildHandleId(sourcePort.id)
: buildGroupHandleId(sourcePort.direction)
const targetHandle = expandedDeviceIds.has(targetDevice.id)
? buildHandleId(targetPort.id)
: buildGroupHandleId(targetPort.direction)
resolved.push({
connectionId: connection.id,
cableTypeId: connection.cableTypeId,
distance: connection.distance,
source: sourceDevice.id,
sourceHandle,
target: targetDevice.id,
targetHandle,
builtIn: !!sourcePort.builtInCable || !!targetPort.builtInCable,
bundleId: connection.bundleId,
})
}
const edges: CableFlowEdge[] = []
// Group by the unordered pair of (device, handle) endpoints + cable type
// — unordered because which real connection got recorded as "source" vs
// "target" is an accident of creation order, not something two cables
// between the same two collapsed devices should be split on.
const groups = new Map<string, Resolved[]>()
for (const r of resolved) {
// A bundled leg never merges either, same reasoning as built-in — it
// needs to keep its own edge so its bundle color/name actually shows,
// and a ×N aggregate badge wouldn't mean anything for cables that
// aren't interchangeable purchases in the first place.
if (r.builtIn || r.bundleId) continue
const a = `${r.source}::${r.sourceHandle}`
const b = `${r.target}::${r.targetHandle}`
const key = [a, b].sort().join('|') + '|' + r.cableTypeId
const group = groups.get(key)
if (group) group.push(r)
else groups.set(key, [r])
}
for (const group of groups.values()) {
const cableType = cableTypes.find((ct) => ct.id === group[0].cableTypeId)
const isAggregate = group.length > 1
const representative = group[0]
edges.push({
id: isAggregate
? `${VIRTUAL_EDGE_PREFIX}${group.map((r) => r.connectionId).join(',')}`
: representative.connectionId,
type: 'cable',
source: representative.source,
sourceHandle: representative.sourceHandle,
target: representative.target,
targetHandle: representative.targetHandle,
selected: !isAggregate && representative.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: isAggregate ? undefined : representative.distance,
unit: cableType?.unit ?? 'ft',
aggregateCount: isAggregate ? group.length : undefined,
},
})
}
for (const r of resolved) {
if (!r.builtIn) continue
const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId)
edges.push({
id: r.connectionId,
type: 'cable',
source: r.source,
sourceHandle: r.sourceHandle,
target: r.target,
targetHandle: r.targetHandle,
selected: r.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: r.distance,
unit: cableType?.unit ?? 'ft',
builtIn: true,
},
})
}
for (const r of resolved) {
if (!r.bundleId) continue
const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId)
const bundle = diagram.bundles.find((b) => b.id === r.bundleId)
edges.push({
id: r.connectionId,
type: 'cable',
source: r.source,
sourceHandle: r.sourceHandle,
target: r.target,
targetHandle: r.targetHandle,
selected: r.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: r.distance,
unit: cableType?.unit ?? 'ft',
bundleName: bundle?.name ?? 'Bundle',
bundleColor: bundleColorFor(r.bundleId, diagram.bundles),
},
})
}
return edges
}, [diagram.devices, diagram.connections, diagram.bundles, cableTypes, selectedConnectionId, expandedDeviceIds])
const isValidConnection = useCallback(
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
const c = edgeOrConnection as Connection
@@ -125,7 +300,19 @@ function FlowCanvasInner() {
const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
const defaultCableType =
sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0]
if (!defaultCableType) return
if (!defaultCableType) {
// isValidConnection above already confirmed the two port *types* are
// compatible (or bridgeable) — reaching here with no cable type
// means compatibility exists on paper but nobody's ever defined an
// actual cable for it, which otherwise fails completely silently
// (the drag just does nothing, with no clue why).
if (sourcePortType && targetPortType) {
window.alert(
`${sourcePortType.name} and ${targetPortType.name} are compatible, but there's no cable type defined for them yet.\n\nAdd one from the device palette's Library ▾ → Connectors → Cable Types tab, with a family matching one of these two, then try again.`,
)
}
return
}
addConnection(catalog, {
sourceDeviceId: connection.source,
sourcePortId,
@@ -155,7 +342,9 @@ function FlowCanvasInner() {
(changes: EdgeChange<CableFlowEdge>[]) => {
if (!canEdit) return
for (const change of changes) {
if (change.type === 'remove') removeConnection(change.id)
// Deleting a merged edge (the ×N marker) removes every real
// connection it represents, not just a display artifact.
if (change.type === 'remove') connectionIdsForEdge(change.id).forEach(removeConnection)
}
},
[canEdit, removeConnection],
@@ -197,7 +386,11 @@ function FlowCanvasInner() {
nodesConnectable={canEdit}
connectionMode={ConnectionMode.Loose}
onNodeClick={(_, node) => selectDevice(node.id)}
onEdgeClick={(_, edge) => selectConnection(edge.id)}
onEdgeClick={(_, edge) => {
// A merged edge doesn't correspond to one Connection — expand
// either endpoint to select and edit the individual cables.
if (!edge.id.startsWith(VIRTUAL_EDGE_PREFIX)) selectConnection(edge.id)
}}
onPaneClick={() => {
selectDevice(null)
}}
@@ -207,6 +400,20 @@ function FlowCanvasInner() {
<Background />
<Controls />
<MiniMap pannable zoomable className="!bg-white" />
<Panel position="top-right" className="flex gap-1.5">
<button
onClick={expandAll}
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm hover:bg-slate-50"
>
Expand all
</button>
<button
onClick={collapseAll}
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm hover:bg-slate-50"
>
Collapse all
</button>
</Panel>
</ReactFlow>
</div>
)
+20
View File
@@ -1,3 +1,5 @@
import type { PortDirection } from '../../domain/types'
/**
* React Flow handle ids encode which port they belong to. We use a single
* handle per port (not two stacked source/target handles) combined with
@@ -15,3 +17,21 @@ export function parsePortId(handleId: string | null | undefined): string | null
if (!handleId) return null
return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId
}
/**
* A compact device node (organized-ideas.md §4) doesn't render individual
* ports, so there's nothing for an edge to attach to at the per-port level —
* instead each direction group (input/output/bidirectional) gets exactly
* one handle, and every real connection touching that device on that side
* attaches there instead. Distinct prefix from port handles so the two
* schemes can never collide.
*/
const GROUP_PREFIX = 'group:'
export function buildGroupHandleId(direction: PortDirection): string {
return `${GROUP_PREFIX}${direction}`
}
export function isGroupHandleId(handleId: string | null | undefined): boolean {
return !!handleId && handleId.startsWith(GROUP_PREFIX)
}
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useMemo, useState } from 'react'
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
@@ -10,6 +10,14 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
const canEdit = access?.myPermission !== 'view'
const updateConnection = useDiagramStore((s) => s.updateConnection)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const startBundle = useDiagramStore((s) => s.startBundle)
const addConnectionToBundle = useDiagramStore((s) => s.addConnectionToBundle)
const removeConnectionFromBundle = useDiagramStore((s) => s.removeConnectionFromBundle)
const updateBundle = useDiagramStore((s) => s.updateBundle)
const selectConnection = useDiagramStore((s) => s.selectConnection)
const [startingBundle, setStartingBundle] = useState(false)
const [newBundleName, setNewBundleName] = useState('')
const [newBundleCableTypeId, setNewBundleCableTypeId] = useState('')
const connection = diagram.connections.find((c) => c.id === connectionId)
const sourceDevice = diagram.devices.find((d) => d.id === connection?.sourceDeviceId)
@@ -26,6 +34,25 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
// Every cable type, unfiltered by port compatibility — a bundle's own
// cable type is a separate, BOM-only choice from any member's individually
// validated cableTypeId (see CableBundle's own doc comment), so it isn't
// constrained to what fits this specific port pair.
const allBundleCableTypes = useMemo(
() => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const bundle = diagram.bundles.find((b) => b.id === connection?.bundleId)
const otherBundles = diagram.bundles.filter((b) => b.id !== connection?.bundleId)
const otherMembers = diagram.connections.filter((c) => c.bundleId === bundle?.id && c.id !== connectionId)
const describeConnectionEnd = (deviceId: string, portId: string) => {
const device = diagram.devices.find((d) => d.id === deviceId)
const port = device?.ports.find((p) => p.id === portId)
return device && port ? `${device.name} · ${port.name}` : 'Unknown'
}
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
return (
@@ -126,6 +153,165 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
/>
</label>
<div className="border-t border-slate-100 pt-3">
<h4 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">Bundle</h4>
{bundle ? (
<div className="space-y-2 rounded border border-indigo-200 bg-indigo-50/40 p-2 text-xs">
<p className="text-slate-500">
Part of a bundle with {otherMembers.length} other cable{otherMembers.length === 1 ? '' : 's'} — counted
as one item on the BOM, using this bundle's own cable type/length/cost below rather than each leg's.
</p>
<label className="block font-medium text-slate-600">
Name
<input
value={bundle.name}
onChange={(e) => updateBundle(bundle.id, { name: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="block font-medium text-slate-600">
Cable type
<select
value={bundle.cableTypeId}
onChange={(e) => updateBundle(bundle.id, { cableTypeId: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
{allBundleCableTypes.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.name}
</option>
))}
</select>
</label>
<div className="flex gap-2">
<label className="block flex-1 font-medium text-slate-600">
Distance
<input
type="number"
min={0}
step={0.5}
value={bundle.distance ?? ''}
onChange={(e) =>
updateBundle(bundle.id, { distance: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="block flex-1 font-medium text-slate-600">
Cost
<input
type="number"
min={0}
step={0.01}
value={bundle.cost ?? ''}
onChange={(e) =>
updateBundle(bundle.id, { cost: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
</div>
<div>
<p className="font-medium text-slate-600">Other cables in this bundle:</p>
<ul className="mt-1 space-y-0.5">
{otherMembers.map((m) => (
<li key={m.id}>
<button
onClick={() => selectConnection(m.id)}
className="text-left text-indigo-600 hover:underline"
>
{describeConnectionEnd(m.sourceDeviceId, m.sourcePortId)} ↔{' '}
{describeConnectionEnd(m.targetDeviceId, m.targetPortId)}
</button>
</li>
))}
</ul>
</div>
<button
onClick={() => removeConnectionFromBundle(connection.id)}
className="w-full rounded border border-slate-300 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
>
Remove this cable from the bundle
</button>
</div>
) : (
<div className="space-y-2 text-xs">
<p className="text-slate-500">
Group this with other cables that are physically one item — a stereo pair, a snake — so the BOM counts
them as a single purchase instead of one per leg.
</p>
{otherBundles.length > 0 && (
<label className="block font-medium text-slate-600">
Add to an existing bundle
<select
value=""
onChange={(e) => {
if (e.target.value) addConnectionToBundle(connection.id, e.target.value)
}}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
<option value="">Choose a bundle…</option>
{otherBundles.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
)}
{!startingBundle ? (
<button
onClick={() => {
setNewBundleName('')
setNewBundleCableTypeId(connection.cableTypeId)
setStartingBundle(true)
}}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ Start a new bundle
</button>
) : (
<div className="space-y-1.5 rounded border border-slate-200 bg-white p-2">
<input
autoFocus
value={newBundleName}
onChange={(e) => setNewBundleName(e.target.value)}
placeholder="e.g. Stereo Pair"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={newBundleCableTypeId}
onChange={(e) => setNewBundleCableTypeId(e.target.value)}
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
>
{allBundleCableTypes.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.name}
</option>
))}
</select>
<div className="flex justify-end gap-2">
<button onClick={() => setStartingBundle(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={() => {
if (!newBundleName.trim() || !newBundleCableTypeId) return
startBundle(connection.id, { name: newBundleName.trim(), cableTypeId: newBundleCableTypeId })
setStartingBundle(false)
}}
disabled={!newBundleName.trim() || !newBundleCableTypeId}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Create
</button>
</div>
</div>
)}
</div>
)}
</div>
<div className="border-t border-slate-100 pt-3">
<button
onClick={() => removeConnection(connection.id)}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function RightPanel() {
const [tab, setTab] = useState<Tab>('inspector')
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-slate-200 bg-white">
<aside className="flex h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white">
<div className="flex border-b border-slate-200">
<button
onClick={() => setTab('inspector')}
+4 -2
View File
@@ -54,8 +54,10 @@ export async function readDiagramFile(file: File): Promise<Diagram> {
* catalog, not the diagram, so it's just discarded here rather than
* crashing on an unexpected shape. */
export function normalizeDiagram(diagram: Diagram): Diagram {
const { id, name, createdAt, updatedAt, devices, connections } = diagram
return { id, name, createdAt, updatedAt, devices, connections }
const { id, name, createdAt, updatedAt, devices, connections, bundles } = diagram
// Defaults to [] for any diagram saved before bundles existed — same
// "don't crash on an older shape" contract as the rest of this function.
return { id, name, createdAt, updatedAt, devices, connections, bundles: Array.isArray(bundles) ? bundles : [] }
}
export { DiagramImportError }
+82 -56
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: [],
// Represented by its bundle below instead — a bundled leg never gets
// its own BOM line.
if (connection.bundleId) continue
addCableUnit(cableLines, lengthLines, cableTypes, connection)
}
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)
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[]
}
/**
+68 -3
View File
@@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram'
import type { Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { CableBundle, Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
@@ -103,7 +103,7 @@ interface DiagramStoreState {
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (
id: string,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes' | 'cost'>>,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
) => void
removeDevice: (id: string) => void
@@ -130,12 +130,32 @@ interface DiagramStoreState {
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
) => void
removeConnection: (id: string) => void
/** Creates a new bundle containing just this one connection — the first
* leg of a stereo pair, snake, etc. Further legs join via
* addConnectionToBundle. */
startBundle: (connectionId: string, fields: Omit<CableBundle, 'id'>) => void
addConnectionToBundle: (connectionId: string, bundleId: string) => void
/** Clears this connection's bundleId; deletes the bundle itself if that
* was its last remaining member. */
removeConnectionFromBundle: (connectionId: string) => void
updateBundle: (bundleId: string, patch: Partial<Omit<CableBundle, 'id'>>) => void
}
function touch(diagram: Diagram): Diagram {
return { ...diagram, updatedAt: new Date().toISOString() }
}
/** Drops any bundle no connection references anymore — called after
* anything that could zero out a bundle's membership (removing a
* connection, or un-bundling its last remaining leg), so an empty bundle
* never lingers in the diagram for a user to stumble on later. */
function pruneEmptyBundles(diagram: Diagram): Diagram {
const referencedIds = new Set(diagram.connections.map((c) => c.bundleId).filter((id): id is string => !!id))
const bundles = diagram.bundles.filter((b) => referencedIds.has(b.id))
return bundles.length === diagram.bundles.length ? diagram : { ...diagram, bundles }
}
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
diagram: createEmptyDiagram(),
isLoaded: false,
@@ -383,14 +403,59 @@ export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
removeConnection: (id) => {
set((state) => ({
diagram: touch({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.filter((c) => c.id !== id),
}),
),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
},
startBundle: (connectionId, fields) => {
const bundle: CableBundle = { id: uuid(), ...fields }
set((state) => ({
diagram: touch({
...state.diagram,
bundles: [...state.diagram.bundles, bundle],
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: bundle.id } : c,
),
}),
}))
},
addConnectionToBundle: (connectionId, bundleId) => {
set((state) => ({
diagram: touch({
...state.diagram,
connections: state.diagram.connections.map((c) => (c.id === connectionId ? { ...c, bundleId } : c)),
}),
}))
},
removeConnectionFromBundle: (connectionId) => {
set((state) => ({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: undefined } : c,
),
}),
),
}))
},
updateBundle: (bundleId, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
bundles: state.diagram.bundles.map((b) => (b.id === bundleId ? { ...b, ...patch } : b)),
}),
}))
},
}))
// Debounced autosave: fires whenever `diagram` changes (skipped until the