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
|
cableTypeName: string
|
||||||
distance?: number
|
distance?: number
|
||||||
unit: string
|
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'>
|
export type CableFlowEdge = Edge<CableEdgeData, 'cable'>
|
||||||
@@ -29,8 +49,15 @@ export default function CableEdge({
|
|||||||
targetPosition,
|
targetPosition,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isAggregate = !!data?.aggregateCount && data.aggregateCount > 1
|
||||||
|
const isBuiltIn = !!data?.builtIn
|
||||||
|
const bundleColor = data?.bundleName ? data.bundleColor : undefined
|
||||||
const label = data
|
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 (
|
return (
|
||||||
@@ -39,13 +66,22 @@ export default function CableEdge({
|
|||||||
id={id}
|
id={id}
|
||||||
path={edgePath}
|
path={edgePath}
|
||||||
markerEnd={markerEnd}
|
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 && (
|
{label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<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"
|
className={`nodrag nopan absolute rounded border px-1.5 py-0.5 text-[10px] shadow-sm ${
|
||||||
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }}
|
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}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
|
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
|
||||||
import type { Device, Port, PortType } from '../../domain/types'
|
import type { Device, Port, PortDirection, PortType } from '../../domain/types'
|
||||||
import { buildHandleId } from './handleIds'
|
import Chevron from '../common/Chevron'
|
||||||
|
import { buildGroupHandleId, buildHandleId } from './handleIds'
|
||||||
|
|
||||||
export interface PortInfo {
|
export interface PortInfo {
|
||||||
port: Port
|
port: Port
|
||||||
@@ -14,7 +15,14 @@ export type DeviceNodeData = {
|
|||||||
/** Resolved from the device's category id — see FlowCanvas — since a
|
/** Resolved from the device's category id — see FlowCanvas — since a
|
||||||
* custom category's id is an opaque uuid, not something to show raw. */
|
* custom category's id is an opaque uuid, not something to show raw. */
|
||||||
categoryName: string
|
categoryName: string
|
||||||
|
/** Resolved from the device's manufacturerId, same reasoning as
|
||||||
|
* categoryName — undefined when no manufacturer is set. */
|
||||||
|
manufacturerName: string | undefined
|
||||||
portInfos: PortInfo[]
|
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'>
|
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 }) {
|
function CountBadge({ count }: { count: number }) {
|
||||||
if (count === 0) return null
|
if (count === 0) return null
|
||||||
return <span className="shrink-0 rounded bg-orange-50 px-1 text-[10px] text-orange-800">{count}</span>
|
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>) {
|
function CompactDeviceNode({ data }: { data: DeviceNodeData }) {
|
||||||
const { device, categoryName, portInfos } = data
|
const { device, portInfos, expanded, onToggleExpanded } = data
|
||||||
const inputs = portInfos.filter((p) => p.port.direction === 'input')
|
const inputs = portInfos.filter((p) => p.port.direction === 'input')
|
||||||
const outputs = portInfos.filter((p) => p.port.direction === 'output')
|
const outputs = portInfos.filter((p) => p.port.direction === 'output')
|
||||||
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
|
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-56 rounded-lg border border-slate-300 bg-white shadow-sm">
|
||||||
className={`w-72 rounded-lg border bg-white shadow-sm transition-shadow ${
|
<div className="flex items-center gap-1.5 rounded-lg px-3 py-2">
|
||||||
selected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-slate-300'
|
<ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
|
||||||
}`}
|
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{device.name}</div>
|
||||||
>
|
</div>
|
||||||
<div className="rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
|
{(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-sm font-semibold text-slate-800">{device.name}</div>
|
||||||
<div className="truncate text-[11px] text-slate-500">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,3 +246,14 @@ export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>
|
|||||||
</div>
|
</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,
|
ConnectionMode,
|
||||||
Controls,
|
Controls,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
|
Panel,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
@@ -13,20 +14,44 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { validateConnection } from '../../domain/compatibility'
|
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 { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import CableEdge, { type CableFlowEdge } from './CableEdge'
|
import CableEdge, { type CableFlowEdge } from './CableEdge'
|
||||||
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
|
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'
|
export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template'
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = { device: DeviceNode }
|
const nodeTypes: NodeTypes = { device: DeviceNode }
|
||||||
const edgeTypes: EdgeTypes = { cable: CableEdge }
|
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() {
|
function FlowCanvasInner() {
|
||||||
const diagram = useDiagramStore((s) => s.diagram)
|
const diagram = useDiagramStore((s) => s.diagram)
|
||||||
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
|
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
|
||||||
@@ -48,6 +73,27 @@ function FlowCanvasInner() {
|
|||||||
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
|
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
|
||||||
const canEdit = access?.myPermission !== 'view'
|
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 portTypes = useMemo(() => allPortTypes(catalog), [catalog])
|
||||||
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
|
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
|
||||||
|
|
||||||
@@ -67,34 +113,163 @@ function FlowCanvasInner() {
|
|||||||
type: 'device',
|
type: 'device',
|
||||||
position: device.position,
|
position: device.position,
|
||||||
selected: device.id === selectedDeviceId,
|
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: {
|
data: {
|
||||||
cableTypeName: cableType?.name ?? 'Unknown Cable',
|
device,
|
||||||
distance: connection.distance,
|
categoryName: deviceCategoryName(catalog, device.category),
|
||||||
unit: cableType?.unit ?? 'ft',
|
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(
|
const isValidConnection = useCallback(
|
||||||
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
|
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
|
||||||
const c = edgeOrConnection as Connection
|
const c = edgeOrConnection as Connection
|
||||||
@@ -125,7 +300,19 @@ function FlowCanvasInner() {
|
|||||||
const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
|
const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
|
||||||
const defaultCableType =
|
const defaultCableType =
|
||||||
sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0]
|
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, {
|
addConnection(catalog, {
|
||||||
sourceDeviceId: connection.source,
|
sourceDeviceId: connection.source,
|
||||||
sourcePortId,
|
sourcePortId,
|
||||||
@@ -155,7 +342,9 @@ function FlowCanvasInner() {
|
|||||||
(changes: EdgeChange<CableFlowEdge>[]) => {
|
(changes: EdgeChange<CableFlowEdge>[]) => {
|
||||||
if (!canEdit) return
|
if (!canEdit) return
|
||||||
for (const change of changes) {
|
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],
|
[canEdit, removeConnection],
|
||||||
@@ -197,7 +386,11 @@ function FlowCanvasInner() {
|
|||||||
nodesConnectable={canEdit}
|
nodesConnectable={canEdit}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
onNodeClick={(_, node) => selectDevice(node.id)}
|
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={() => {
|
onPaneClick={() => {
|
||||||
selectDevice(null)
|
selectDevice(null)
|
||||||
}}
|
}}
|
||||||
@@ -207,6 +400,20 @@ function FlowCanvasInner() {
|
|||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
<MiniMap pannable zoomable className="!bg-white" />
|
<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>
|
</ReactFlow>
|
||||||
</div>
|
</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
|
* 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
|
* 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
|
if (!handleId) return null
|
||||||
return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId
|
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 { useMemo, useState } from 'react'
|
||||||
import { cableTypesForConnection, getPortType } from '../../domain/diagram'
|
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
|
|
||||||
@@ -10,6 +10,14 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
const canEdit = access?.myPermission !== 'view'
|
const canEdit = access?.myPermission !== 'view'
|
||||||
const updateConnection = useDiagramStore((s) => s.updateConnection)
|
const updateConnection = useDiagramStore((s) => s.updateConnection)
|
||||||
const removeConnection = useDiagramStore((s) => s.removeConnection)
|
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 connection = diagram.connections.find((c) => c.id === connectionId)
|
||||||
const sourceDevice = diagram.devices.find((d) => d.id === connection?.sourceDeviceId)
|
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
|
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
|
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,6 +153,165 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="border-t border-slate-100 pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => removeConnection(connection.id)}
|
onClick={() => removeConnection(connection.id)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function RightPanel() {
|
|||||||
const [tab, setTab] = useState<Tab>('inspector')
|
const [tab, setTab] = useState<Tab>('inspector')
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex border-b border-slate-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab('inspector')}
|
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
|
* catalog, not the diagram, so it's just discarded here rather than
|
||||||
* crashing on an unexpected shape. */
|
* crashing on an unexpected shape. */
|
||||||
export function normalizeDiagram(diagram: Diagram): Diagram {
|
export function normalizeDiagram(diagram: Diagram): Diagram {
|
||||||
const { id, name, createdAt, updatedAt, devices, connections } = diagram
|
const { id, name, createdAt, updatedAt, devices, connections, bundles } = diagram
|
||||||
return { id, name, createdAt, updatedAt, devices, connections }
|
// 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 }
|
export { DiagramImportError }
|
||||||
|
|||||||
+82
-56
@@ -1,5 +1,5 @@
|
|||||||
import { allCableTypes, manufacturerName } from './diagram'
|
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`
|
/** 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
|
* 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}`
|
return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A connection's cost for BOM purposes: its own override if set, else
|
/** A connection's (or a bundle's — same shape, just its own distance/cost
|
||||||
* `costPerUnit * distance` if both the cable type's rate and the
|
* standing in for a member's) cost for BOM purposes: its own override if
|
||||||
* connection's distance are known. Undefined means "not computable" —
|
* set, else `costPerUnit * distance` if both the cable type's rate and the
|
||||||
* callers must exclude it from totals rather than treating it as zero. */
|
* distance are known. Undefined means "not computable" — callers must
|
||||||
function connectionCost(connection: Connection, cableType: CableType | undefined): number | undefined {
|
* exclude it from totals rather than treating it as zero. */
|
||||||
if (typeof connection.cost === 'number' && !Number.isNaN(connection.cost)) {
|
function runCost(run: { cost?: number; distance?: number }, cableType: CableType | undefined): number | undefined {
|
||||||
return connection.cost
|
if (typeof run.cost === 'number' && !Number.isNaN(run.cost)) {
|
||||||
|
return run.cost
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
cableType &&
|
cableType &&
|
||||||
typeof cableType.costPerUnit === 'number' &&
|
typeof cableType.costPerUnit === 'number' &&
|
||||||
!Number.isNaN(cableType.costPerUnit) &&
|
!Number.isNaN(cableType.costPerUnit) &&
|
||||||
typeof connection.distance === 'number' &&
|
typeof run.distance === 'number' &&
|
||||||
!Number.isNaN(connection.distance)
|
!Number.isNaN(run.distance)
|
||||||
) {
|
) {
|
||||||
return cableType.costPerUnit * connection.distance
|
return cableType.costPerUnit * run.distance
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,65 @@ function builtInPortIds(devices: Device[]): Set<string> {
|
|||||||
return ids
|
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 {
|
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
|
||||||
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
|
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
|
||||||
const builtIn = builtInPortIds(diagram.devices)
|
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
|
// 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.
|
// connection's built-in-ness is visible, not the shopping list.
|
||||||
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
|
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
|
||||||
const cableType = cableTypes.get(connection.cableTypeId)
|
// Represented by its bundle below instead — a bundled leg never gets
|
||||||
const name = cableType?.name ?? 'Unknown Cable'
|
// its own BOM line.
|
||||||
const unit = cableType?.unit ?? 'ft'
|
if (connection.bundleId) continue
|
||||||
const existing = cableLines.get(connection.cableTypeId) ?? {
|
addCableUnit(cableLines, lengthLines, cableTypes, connection)
|
||||||
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)
|
|
||||||
|
|
||||||
const byLength = lengthLines.get(connection.cableTypeId) ?? new Map<number | undefined, BomCableLengthLine>()
|
for (const bundle of diagram.bundles) {
|
||||||
const lengthLine = byLength.get(distance) ?? {
|
// A bundle with no remaining members (every leg was rewired or deleted)
|
||||||
distance,
|
// shouldn't normally exist — removeConnection/removeConnectionFromBundle
|
||||||
count: 0,
|
// prune it — but skip defensively rather than phantom-charging for it.
|
||||||
totalLength: 0,
|
const hasMember = diagram.connections.some((c) => c.bundleId === bundle.id)
|
||||||
totalCost: 0,
|
if (!hasMember) continue
|
||||||
connectionsMissingCost: 0,
|
addCableUnit(cableLines, lengthLines, cableTypes, bundle)
|
||||||
}
|
|
||||||
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 [cableTypeId, line] of cableLines) {
|
for (const [cableTypeId, line] of cableLines) {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
devices: [],
|
devices: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
|
bundles: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,34 @@ export interface Connection {
|
|||||||
cost?: number
|
cost?: number
|
||||||
label?: string
|
label?: string
|
||||||
notes?: 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 {
|
export interface Diagram {
|
||||||
@@ -164,6 +192,7 @@ export interface Diagram {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
devices: Device[]
|
devices: Device[]
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
|
bundles: CableBundle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { validateConnection } from '../domain/compatibility'
|
import { validateConnection } from '../domain/compatibility'
|
||||||
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram'
|
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 type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
||||||
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ interface DiagramStoreState {
|
|||||||
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
|
||||||
updateDevice: (
|
updateDevice: (
|
||||||
id: string,
|
id: string,
|
||||||
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes' | 'cost'>>,
|
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
|
||||||
) => void
|
) => void
|
||||||
removeDevice: (id: string) => void
|
removeDevice: (id: string) => void
|
||||||
|
|
||||||
@@ -130,12 +130,32 @@ interface DiagramStoreState {
|
|||||||
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
|
||||||
) => void
|
) => void
|
||||||
removeConnection: (id: string) => 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 {
|
function touch(diagram: Diagram): Diagram {
|
||||||
return { ...diagram, updatedAt: new Date().toISOString() }
|
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) => ({
|
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
|
||||||
diagram: createEmptyDiagram(),
|
diagram: createEmptyDiagram(),
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -383,14 +403,59 @@ export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
|
|||||||
|
|
||||||
removeConnection: (id) => {
|
removeConnection: (id) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
diagram: touch({
|
diagram: touch(
|
||||||
|
pruneEmptyBundles({
|
||||||
...state.diagram,
|
...state.diagram,
|
||||||
connections: state.diagram.connections.filter((c) => c.id !== id),
|
connections: state.diagram.connections.filter((c) => c.id !== id),
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
|
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
|
// Debounced autosave: fires whenever `diagram` changes (skipped until the
|
||||||
|
|||||||
Reference in New Issue
Block a user