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:
@@ -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>
|
||||
|
||||
@@ -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="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}
|
||||
<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">
|
||||
{[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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
...state.diagram,
|
||||
connections: state.diagram.connections.filter((c) => c.id !== id),
|
||||
}),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user