Add collapsible device nodes, cable bundling, and aggregated edges

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-28 10:04:37 -05:00
co-authored by Claude Sonnet 5
parent 29c3ed46a3
commit 143a96aed1
11 changed files with 811 additions and 113 deletions
+71 -6
View File
@@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram'
import type { Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { CableBundle, Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
@@ -103,7 +103,7 @@ interface DiagramStoreState {
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (
id: string,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes' | 'cost'>>,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
) => void
removeDevice: (id: string) => void
@@ -130,12 +130,32 @@ interface DiagramStoreState {
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
) => void
removeConnection: (id: string) => void
/** Creates a new bundle containing just this one connection — the first
* leg of a stereo pair, snake, etc. Further legs join via
* addConnectionToBundle. */
startBundle: (connectionId: string, fields: Omit<CableBundle, 'id'>) => void
addConnectionToBundle: (connectionId: string, bundleId: string) => void
/** Clears this connection's bundleId; deletes the bundle itself if that
* was its last remaining member. */
removeConnectionFromBundle: (connectionId: string) => void
updateBundle: (bundleId: string, patch: Partial<Omit<CableBundle, 'id'>>) => void
}
function touch(diagram: Diagram): Diagram {
return { ...diagram, updatedAt: new Date().toISOString() }
}
/** Drops any bundle no connection references anymore — called after
* anything that could zero out a bundle's membership (removing a
* connection, or un-bundling its last remaining leg), so an empty bundle
* never lingers in the diagram for a user to stumble on later. */
function pruneEmptyBundles(diagram: Diagram): Diagram {
const referencedIds = new Set(diagram.connections.map((c) => c.bundleId).filter((id): id is string => !!id))
const bundles = diagram.bundles.filter((b) => referencedIds.has(b.id))
return bundles.length === diagram.bundles.length ? diagram : { ...diagram, bundles }
}
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
diagram: createEmptyDiagram(),
isLoaded: false,
@@ -383,14 +403,59 @@ export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
removeConnection: (id) => {
set((state) => ({
diagram: touch({
...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