Adds first version of the app. Local storage only.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { allCableTypes } from './project'
|
||||
import type { Device, Project } from './types'
|
||||
|
||||
export interface BomCableLine {
|
||||
cableTypeId: string
|
||||
cableTypeName: string
|
||||
unit: string
|
||||
count: number
|
||||
/** Sum of user-entered distances; connections with no distance are excluded. */
|
||||
totalLength: number
|
||||
connectionsMissingLength: number
|
||||
}
|
||||
|
||||
export interface BomDeviceLine {
|
||||
key: string
|
||||
name: string
|
||||
category: string
|
||||
manufacturer?: string
|
||||
model?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Bom {
|
||||
cables: BomCableLine[]
|
||||
devices: BomDeviceLine[]
|
||||
}
|
||||
|
||||
function deviceGroupKey(device: Device): string {
|
||||
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}`
|
||||
}
|
||||
|
||||
export function computeBom(project: Project): Bom {
|
||||
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
|
||||
|
||||
const cableLines = new Map<string, BomCableLine>()
|
||||
for (const connection of project.connections) {
|
||||
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,
|
||||
}
|
||||
existing.count += 1
|
||||
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
|
||||
existing.totalLength += connection.distance
|
||||
} else {
|
||||
existing.connectionsMissingLength += 1
|
||||
}
|
||||
cableLines.set(connection.cableTypeId, existing)
|
||||
}
|
||||
|
||||
const deviceLines = new Map<string, BomDeviceLine>()
|
||||
for (const device of project.devices) {
|
||||
const key = deviceGroupKey(device)
|
||||
const existing = deviceLines.get(key) ?? {
|
||||
key,
|
||||
name: device.name,
|
||||
category: device.category,
|
||||
manufacturer: device.manufacturer,
|
||||
model: device.model,
|
||||
count: 0,
|
||||
}
|
||||
existing.count += 1
|
||||
deviceLines.set(key, existing)
|
||||
}
|
||||
|
||||
return {
|
||||
cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
|
||||
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
|
||||
import type { Connection, Device, PortType, Project } from './types'
|
||||
|
||||
/**
|
||||
* Two ports connect if the sets of families they each accept (their own
|
||||
* family plus anything declared in `compatibleFamilyIds`) overlap at all.
|
||||
* This has to be a full set intersection, not just "does A's family appear
|
||||
* in B's list" — a pairing that only exists via a shared third "bridge"
|
||||
* family (e.g. USB-A Host and USB-B Device both listing 'usb-a-to-b', while
|
||||
* neither's own family is 'usb-a-to-b') would otherwise never match.
|
||||
*/
|
||||
export function portTypesCompatible(a: PortType, b: PortType): boolean {
|
||||
if (a.id === b.id) return true
|
||||
const aFamilies = acceptedFamilies(a)
|
||||
for (const family of acceptedFamilies(b)) {
|
||||
if (aFamilies.has(family)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface ConnectionCandidate {
|
||||
sourceDeviceId: string
|
||||
sourcePortId: string
|
||||
targetDeviceId: string
|
||||
targetPortId: string
|
||||
}
|
||||
|
||||
export interface ConnectionValidation {
|
||||
valid: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Central rule set for "can these two ports be wired together". Used both by
|
||||
* the canvas (to accept/reject a drag-to-connect gesture) and could be reused
|
||||
* server-side later since it only depends on plain domain data.
|
||||
*/
|
||||
export function validateConnection(
|
||||
project: Project,
|
||||
candidate: ConnectionCandidate,
|
||||
existingConnections: Connection[] = project.connections,
|
||||
): ConnectionValidation {
|
||||
const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate
|
||||
|
||||
if (sourceDeviceId === targetDeviceId && sourcePortId === targetPortId) {
|
||||
return { valid: false, reason: 'A port cannot connect to itself.' }
|
||||
}
|
||||
|
||||
const sourceDevice = project.devices.find((d) => d.id === sourceDeviceId)
|
||||
const targetDevice = project.devices.find((d) => d.id === targetDeviceId)
|
||||
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
|
||||
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
|
||||
|
||||
if (!sourceDevice || !targetDevice || !sourcePort || !targetPort) {
|
||||
return { valid: false, reason: 'Unknown device or port.' }
|
||||
}
|
||||
|
||||
if (sourcePort.direction === 'input' && targetPort.direction === 'input') {
|
||||
return { valid: false, reason: 'Cannot connect two input ports.' }
|
||||
}
|
||||
if (sourcePort.direction === 'output' && targetPort.direction === 'output') {
|
||||
return { valid: false, reason: 'Cannot connect two output ports.' }
|
||||
}
|
||||
|
||||
const sourcePortType = getPortType(project, sourcePort.portTypeId)
|
||||
const targetPortType = getPortType(project, targetPort.portTypeId)
|
||||
if (!sourcePortType || !targetPortType) {
|
||||
return { valid: false, reason: 'Unknown port type.' }
|
||||
}
|
||||
// Directly compatible port types, or bridgeable by a specific adapter
|
||||
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the
|
||||
// port types themselves declare no general compatibility.
|
||||
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(project, sourcePortType, targetPortType)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const maxSource = sourcePortType.maxConnections ?? 1
|
||||
const usedSource = existingConnections.filter(
|
||||
(c) => c.sourcePortId === sourcePortId || c.targetPortId === sourcePortId,
|
||||
).length
|
||||
if (usedSource >= maxSource) {
|
||||
return { valid: false, reason: `${sourcePort.name} already has the maximum number of cables.` }
|
||||
}
|
||||
|
||||
const maxTarget = targetPortType.maxConnections ?? 1
|
||||
const usedTarget = existingConnections.filter(
|
||||
(c) => c.sourcePortId === targetPortId || c.targetPortId === targetPortId,
|
||||
).length
|
||||
if (usedTarget >= maxTarget) {
|
||||
return { valid: false, reason: `${targetPort.name} already has the maximum number of cables.` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function findDevicePort(devices: Device[], deviceId: string, portId: string) {
|
||||
const device = devices.find((d) => d.id === deviceId)
|
||||
const port = device?.ports.find((p) => p.id === portId)
|
||||
return device && port ? { device, port } : undefined
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PortCategory, PortDirection } from './types'
|
||||
|
||||
// Device categories are no longer a fixed list — see `domain/project.ts`'s
|
||||
// `allDeviceCategories` (built-ins + project-custom, merged).
|
||||
|
||||
export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [
|
||||
{ value: 'input', label: 'Input' },
|
||||
{ value: 'output', label: 'Output' },
|
||||
{ value: 'bidirectional', label: 'Bidirectional' },
|
||||
]
|
||||
|
||||
export const PORT_CATEGORIES: { value: PortCategory; label: string }[] = [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'audio', label: 'Audio' },
|
||||
{ value: 'network', label: 'Network' },
|
||||
{ value: 'usb', label: 'USB / Data' },
|
||||
{ value: 'power', label: 'Power' },
|
||||
{ value: 'control', label: 'Control' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { CableType, DeviceCategoryDef, DeviceTemplate, PortType } from './types'
|
||||
|
||||
/**
|
||||
* Built-in connector/cable/device libraries. These ship read-only
|
||||
* (`builtIn`/`custom: false`-ish via omission) and are merged with each
|
||||
* project's custom entries at lookup time — see `domain/project.ts`.
|
||||
*/
|
||||
|
||||
export const BUILT_IN_DEVICE_CATEGORIES: DeviceCategoryDef[] = [
|
||||
{ id: 'display', name: 'Display' },
|
||||
{ id: 'video-source', name: 'Video Source' },
|
||||
{ id: 'audio-source', name: 'Audio Source' },
|
||||
{ id: 'synth', name: 'Synthesizer / Sound Module' },
|
||||
{ id: 'mixer', name: 'Mixer' },
|
||||
{ id: 'amplifier', name: 'Amplifier / Receiver' },
|
||||
{ id: 'speaker', name: 'Speaker' },
|
||||
{ id: 'network', name: 'Network' },
|
||||
{ id: 'computer', name: 'Computer' },
|
||||
{ id: 'camera', name: 'Camera' },
|
||||
{ id: 'control', name: 'Control' },
|
||||
{ id: 'power', name: 'Power' },
|
||||
{ id: 'adapter', name: 'Adapter / Converter' },
|
||||
{ id: 'other', name: 'Other' },
|
||||
]
|
||||
|
||||
export const BUILT_IN_PORT_TYPES: PortType[] = [
|
||||
{ id: 'pt-hdmi', name: 'HDMI', category: 'video', family: 'hdmi' },
|
||||
{ id: 'pt-displayport', name: 'DisplayPort', category: 'video', family: 'displayport' },
|
||||
{ id: 'pt-sdi', name: 'SDI (BNC)', category: 'video', family: 'sdi' },
|
||||
{ id: 'pt-vga', name: 'VGA', category: 'video', family: 'vga' },
|
||||
{ id: 'pt-composite', name: 'Composite Video (RCA)', category: 'video', family: 'rca-composite' },
|
||||
{ id: 'pt-xlr', name: 'XLR (Balanced Audio)', category: 'audio', family: 'xlr' },
|
||||
{ id: 'pt-trs-quarter', name: '1/4" TRS (Balanced Audio)', category: 'audio', family: 'trs-quarter' },
|
||||
{ id: 'pt-ts-quarter', name: '1/4" TS (Unbalanced Audio)', category: 'audio', family: 'ts-quarter' },
|
||||
{ id: 'pt-trs-mini', name: '3.5mm TRS (Balanced Audio)', category: 'audio', family: 'trs-mini' },
|
||||
{ id: 'pt-ts-mini', name: '3.5mm (Unbalanced Audio)', category: 'audio', family: 'ts-mini' },
|
||||
{
|
||||
id: 'pt-trs-quarter-combo',
|
||||
name: '1/4" TRS/TS (Balanced or Unbalanced)',
|
||||
category: 'audio',
|
||||
family: 'trs-quarter-combo',
|
||||
compatibleFamilyIds: ['trs-quarter', 'ts-quarter'],
|
||||
},
|
||||
{
|
||||
id: 'pt-trs-mini-combo',
|
||||
name: '3.5mm TRS/TS (Balanced or Unbalanced)',
|
||||
category: 'audio',
|
||||
family: 'trs-mini-combo',
|
||||
compatibleFamilyIds: ['trs-mini', 'ts-mini'],
|
||||
},
|
||||
{ id: 'pt-rca-audio', name: 'RCA (Stereo Audio)', category: 'audio', family: 'rca-audio' },
|
||||
{ id: 'pt-speakon', name: 'Speakon', category: 'audio', family: 'speakon' },
|
||||
{ id: 'pt-speaker-wire', name: 'Speaker Wire (Bare/Terminal)', category: 'audio', family: 'speaker-wire', maxConnections: 1 },
|
||||
{ id: 'pt-toslink', name: 'TOSLINK (Optical)', category: 'audio', family: 'toslink' },
|
||||
{
|
||||
id: 'pt-cat-rj45',
|
||||
name: 'Cat5e/6 (RJ45)',
|
||||
category: 'network',
|
||||
family: 'cat-rj45',
|
||||
compatibleFamilyIds: ['cat-rj45'],
|
||||
},
|
||||
{ id: 'pt-fiber-lc', name: 'Fiber (LC)', category: 'network', family: 'fiber-lc' },
|
||||
{ id: 'pt-usb-a', name: 'USB-A (Unspecified)', category: 'usb', family: 'usb-a' },
|
||||
{ id: 'pt-usb-c', name: 'USB-C (Unspecified)', category: 'usb', family: 'usb-c' },
|
||||
{
|
||||
id: 'pt-usb-a-host',
|
||||
name: 'USB-A (Host)',
|
||||
category: 'usb',
|
||||
family: 'usb-a-host',
|
||||
// 'usb-a-to-b' lets this pair with a USB-B device port via an A-to-B
|
||||
// cable, distinct from the plain A-to-A cable used with usb-a-device.
|
||||
compatibleFamilyIds: ['usb-a-device', 'usb-a-to-b'],
|
||||
},
|
||||
{
|
||||
id: 'pt-usb-a-device',
|
||||
name: 'USB-A (Device)',
|
||||
category: 'usb',
|
||||
family: 'usb-a-device',
|
||||
compatibleFamilyIds: ['usb-a-host'],
|
||||
},
|
||||
{
|
||||
id: 'pt-usb-c-host',
|
||||
name: 'USB-C (Host)',
|
||||
category: 'usb',
|
||||
family: 'usb-c-host',
|
||||
compatibleFamilyIds: ['usb-c-device', 'usb-c-to-b'],
|
||||
},
|
||||
{
|
||||
id: 'pt-usb-c-device',
|
||||
name: 'USB-C (Device)',
|
||||
category: 'usb',
|
||||
family: 'usb-c-device',
|
||||
compatibleFamilyIds: ['usb-c-host'],
|
||||
},
|
||||
{
|
||||
id: 'pt-usb-b-device',
|
||||
name: 'USB-B (Device)',
|
||||
category: 'usb',
|
||||
family: 'usb-b-device',
|
||||
// The classic "printer" connector — pairs with a host's USB-A or USB-C
|
||||
// port via the corresponding shaped cable, not with another USB-B port.
|
||||
compatibleFamilyIds: ['usb-a-to-b', 'usb-c-to-b'],
|
||||
},
|
||||
{ id: 'pt-iec-power', name: 'IEC C13/C14 Power', category: 'power', family: 'iec-power' },
|
||||
{ id: 'pt-nema-power', name: 'NEMA 5-15 Power', category: 'power', family: 'nema-power' },
|
||||
{ id: 'pt-rs232', name: 'RS-232 (Serial)', category: 'control', family: 'rs232' },
|
||||
{ id: 'pt-ir', name: 'IR (3.5mm Emitter/Receiver)', category: 'control', family: 'ir' },
|
||||
{ id: 'pt-midi-din', name: 'MIDI (5-Pin DIN)', category: 'control', family: 'midi-din' },
|
||||
{
|
||||
id: 'pt-midi-trs-a',
|
||||
name: '3.5mm MIDI (Type A)',
|
||||
category: 'control',
|
||||
family: 'midi-trs-a',
|
||||
// Deliberately NOT cross-compatible with Type B — the two standards
|
||||
// swap tip/ring polarity, so a straight cable between them doesn't work.
|
||||
},
|
||||
{ id: 'pt-midi-trs-b', name: '3.5mm MIDI (Type B)', category: 'control', family: 'midi-trs-b' },
|
||||
]
|
||||
|
||||
export const BUILT_IN_CABLE_TYPES: CableType[] = [
|
||||
{ id: 'ct-hdmi', name: 'HDMI Cable', family: 'hdmi', unit: 'ft' },
|
||||
{ id: 'ct-displayport', name: 'DisplayPort Cable', family: 'displayport', unit: 'ft' },
|
||||
{ id: 'ct-sdi', name: 'SDI Coax (BNC)', family: 'sdi', unit: 'ft' },
|
||||
{ id: 'ct-vga', name: 'VGA Cable', family: 'vga', unit: 'ft' },
|
||||
{ id: 'ct-composite', name: 'Composite Video Cable (RCA)', family: 'rca-composite', unit: 'ft' },
|
||||
{ id: 'ct-xlr', name: 'XLR Cable', family: 'xlr', unit: 'ft' },
|
||||
{
|
||||
id: 'ct-xlr-to-trs-quarter',
|
||||
name: 'XLR to 1/4" TRS Adapter Cable',
|
||||
family: 'xlr',
|
||||
family2: 'trs-quarter',
|
||||
unit: 'ft',
|
||||
},
|
||||
{ id: 'ct-trs-quarter', name: '1/4" TRS Cable (Balanced)', family: 'trs-quarter', unit: 'ft' },
|
||||
{ id: 'ct-ts-quarter', name: '1/4" TS Cable (Instrument/Unbalanced)', family: 'ts-quarter', unit: 'ft' },
|
||||
{ id: 'ct-trs-mini', name: '3.5mm TRS Cable (Balanced)', family: 'trs-mini', unit: 'ft' },
|
||||
{ id: 'ct-ts-mini', name: '3.5mm Cable (Unbalanced)', family: 'ts-mini', unit: 'ft' },
|
||||
{ id: 'ct-rca-audio', name: 'RCA Stereo Audio Cable', family: 'rca-audio', unit: 'ft' },
|
||||
{ id: 'ct-speakon', name: 'Speakon Cable', family: 'speakon', unit: 'ft' },
|
||||
{ id: 'ct-speaker-wire', name: 'Speaker Wire (14/2)', family: 'speaker-wire', unit: 'ft' },
|
||||
{ id: 'ct-toslink', name: 'TOSLINK Optical Cable', family: 'toslink', unit: 'ft' },
|
||||
{ id: 'ct-cat6', name: 'Cat6 Cable', family: 'cat-rj45', unit: 'ft' },
|
||||
{ id: 'ct-fiber-lc', name: 'Fiber LC-LC Cable', family: 'fiber-lc', unit: 'ft' },
|
||||
{ id: 'ct-usb-a', name: 'USB-A to USB-A Cable', family: 'usb-a', unit: 'ft' },
|
||||
{ id: 'ct-usb-c', name: 'USB-C Cable', family: 'usb-c', unit: 'ft' },
|
||||
{ id: 'ct-usb-a-host-device', name: 'USB-A Cable (Host ↔ Device)', family: 'usb-a-host', unit: 'ft' },
|
||||
{ id: 'ct-usb-c-host-device', name: 'USB-C Cable (Host ↔ Device)', family: 'usb-c-host', unit: 'ft' },
|
||||
{ id: 'ct-usb-a-to-b', name: 'USB-A to USB-B Cable', family: 'usb-a-to-b', unit: 'ft' },
|
||||
{ id: 'ct-usb-c-to-b', name: 'USB-C to USB-B Cable', family: 'usb-c-to-b', unit: 'ft' },
|
||||
{ id: 'ct-iec-power', name: 'IEC C13 Power Cable', family: 'iec-power', unit: 'ft' },
|
||||
{ id: 'ct-nema-power', name: 'NEMA 5-15 Extension Cord', family: 'nema-power', unit: 'ft' },
|
||||
{ id: 'ct-rs232', name: 'RS-232 Serial Cable', family: 'rs232', unit: 'ft' },
|
||||
{ id: 'ct-ir', name: 'IR Emitter Cable', family: 'ir', unit: 'ft' },
|
||||
{ id: 'ct-midi-din', name: 'MIDI Cable (5-Pin DIN)', family: 'midi-din', unit: 'ft' },
|
||||
{ id: 'ct-midi-trs-a', name: '3.5mm MIDI Cable (Type A)', family: 'midi-trs-a', unit: 'ft' },
|
||||
{ id: 'ct-midi-trs-b', name: '3.5mm MIDI Cable (Type B)', family: 'midi-trs-b', unit: 'ft' },
|
||||
]
|
||||
|
||||
/** Small starter set of common AV device shapes; users add their own via the editor. */
|
||||
export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
|
||||
{
|
||||
id: 'dt-display',
|
||||
name: 'Display (TV / Projector)',
|
||||
category: 'display',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-media-player',
|
||||
name: 'Media Player / Streamer',
|
||||
category: 'video-source',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p2', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-avr',
|
||||
name: 'AV Receiver',
|
||||
category: 'amplifier',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p3', name: 'HDMI In 3', direction: 'input', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p4', name: 'HDMI Out (Display)', direction: 'output', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p5', name: 'Speaker Out L', direction: 'output', portTypeId: 'pt-speaker-wire' },
|
||||
{ id: 'p6', name: 'Speaker Out R', direction: 'output', portTypeId: 'pt-speaker-wire' },
|
||||
{ id: 'p7', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-speaker',
|
||||
name: 'Speaker (Passive)',
|
||||
category: 'speaker',
|
||||
builtIn: true,
|
||||
ports: [{ id: 'p1', name: 'Speaker In', direction: 'input', portTypeId: 'pt-speaker-wire' }],
|
||||
},
|
||||
{
|
||||
id: 'dt-switch',
|
||||
name: 'Network Switch',
|
||||
category: 'network',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'Port 1', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
{ id: 'p2', name: 'Port 2', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
{ id: 'p3', name: 'Port 3', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
{ id: 'p4', name: 'Port 4', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
{ id: 'p5', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-camera',
|
||||
name: 'PTZ Camera',
|
||||
category: 'camera',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'SDI Out', direction: 'output', portTypeId: 'pt-sdi' },
|
||||
{ id: 'p2', name: 'Control (RJ45)', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-computer',
|
||||
name: 'Computer / Laptop',
|
||||
category: 'computer',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
|
||||
{ id: 'p2', name: 'USB-C', direction: 'bidirectional', portTypeId: 'pt-usb-c' },
|
||||
{ id: 'p3', name: 'Ethernet', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
|
||||
],
|
||||
},
|
||||
// Adapters model a real standalone part (a barrel/dongle) as a tiny
|
||||
// 2-port device, so a connection that genuinely needs two physical
|
||||
// cables — e.g. 3.5mm MIDI into a DIN adapter, then a DIN cable onward —
|
||||
// shows up in the BOM as what it actually is: one adapter + two cables,
|
||||
// each with its own length, instead of being squeezed into one cable type.
|
||||
{
|
||||
id: 'dt-adapter-midi-trs-a-to-din',
|
||||
name: '3.5mm-to-DIN MIDI Adapter (Type A)',
|
||||
category: 'adapter',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-a' },
|
||||
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dt-adapter-midi-trs-b-to-din',
|
||||
name: '3.5mm-to-DIN MIDI Adapter (Type B)',
|
||||
category: 'adapter',
|
||||
builtIn: true,
|
||||
ports: [
|
||||
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-b' },
|
||||
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { BUILT_IN_CABLE_TYPES, BUILT_IN_DEVICE_CATEGORIES, BUILT_IN_DEVICE_TEMPLATES, BUILT_IN_PORT_TYPES } from './library'
|
||||
import type { CableType, Device, DeviceCategoryDef, DeviceTemplate, Port, PortType, Project } from './types'
|
||||
|
||||
/** Merges built-in + project-custom entries. Custom entries with a matching
|
||||
* id win, so a project could in principle override a built-in (not exposed
|
||||
* in the UI today, but keeps the merge rule simple and predictable). */
|
||||
function mergeById<T extends { id: string }>(builtIns: T[], custom: T[]): T[] {
|
||||
const byId = new Map(builtIns.map((item) => [item.id, item]))
|
||||
for (const item of custom) byId.set(item.id, item)
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
export function allPortTypes(project: Project): PortType[] {
|
||||
return mergeById(BUILT_IN_PORT_TYPES, project.customPortTypes)
|
||||
}
|
||||
|
||||
export function allCableTypes(project: Project): CableType[] {
|
||||
return mergeById(BUILT_IN_CABLE_TYPES, project.customCableTypes)
|
||||
}
|
||||
|
||||
export function allDeviceTemplates(project: Project): DeviceTemplate[] {
|
||||
const merged = mergeById(BUILT_IN_DEVICE_TEMPLATES, project.customDeviceTemplates)
|
||||
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
|
||||
return merged.filter((t) => !(t.builtIn && hidden.has(t.id)))
|
||||
}
|
||||
|
||||
/** Built-in templates the user has hidden, for a "restore" list — deleting a
|
||||
* custom template is permanent, but hiding a built-in one isn't, since the
|
||||
* built-in still exists in code and can always be shown again. */
|
||||
export function hiddenBuiltInDeviceTemplates(project: Project): DeviceTemplate[] {
|
||||
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
|
||||
return BUILT_IN_DEVICE_TEMPLATES.filter((t) => hidden.has(t.id))
|
||||
}
|
||||
|
||||
export function allDeviceCategories(project: Project): DeviceCategoryDef[] {
|
||||
return mergeById(BUILT_IN_DEVICE_CATEGORIES, project.customDeviceCategories)
|
||||
}
|
||||
|
||||
export function getDeviceCategory(project: Project, categoryId: string): DeviceCategoryDef | undefined {
|
||||
return allDeviceCategories(project).find((c) => c.id === categoryId)
|
||||
}
|
||||
|
||||
/** Friendly label for a category id, falling back to the raw id if it's
|
||||
* somehow unknown (e.g. data from an older export) rather than crashing. */
|
||||
export function deviceCategoryName(project: Project, categoryId: string): string {
|
||||
return getDeviceCategory(project, categoryId)?.name ?? categoryId
|
||||
}
|
||||
|
||||
export function getPortType(project: Project, portTypeId: string): PortType | undefined {
|
||||
return allPortTypes(project).find((pt) => pt.id === portTypeId)
|
||||
}
|
||||
|
||||
export function getCableType(project: Project, cableTypeId: string): CableType | undefined {
|
||||
return allCableTypes(project).find((ct) => ct.id === cableTypeId)
|
||||
}
|
||||
|
||||
/** The set of connector families a port type will mate with: its own family
|
||||
* plus anything it explicitly lists as also-compatible. Shared with
|
||||
* `compatibility.ts` so "does A connect to B" and "which cable fits" agree. */
|
||||
export function acceptedFamilies(portType: PortType): Set<string> {
|
||||
return new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])])
|
||||
}
|
||||
|
||||
/** A cable's two connector ends as families — `family2` falls back to
|
||||
* `family` for a plain (non-adapter) cable, so it always has exactly two. */
|
||||
function cableEnds(cableType: CableType): [string, string] {
|
||||
return [cableType.family, cableType.family2 ?? cableType.family]
|
||||
}
|
||||
|
||||
/** Does this cable type fit between a port that accepts `sourceFamilies` and
|
||||
* one that accepts `targetFamilies`? Checked in both orientations since a
|
||||
* passive cable (including an adapter with different connectors on each
|
||||
* end) can be plugged in either way around. */
|
||||
function cableFits(cableType: CableType, sourceFamilies: Set<string>, targetFamilies: Set<string>): boolean {
|
||||
const [endA, endB] = cableEnds(cableType)
|
||||
if (sourceFamilies.has(endA) && targetFamilies.has(endB)) return true
|
||||
if (sourceFamilies.has(endB) && targetFamilies.has(endA)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Cable types usable with the given port type at (at least) one end — for
|
||||
* a plain cable that means its family matches; for an adapter cable it's
|
||||
* enough that either end matches. */
|
||||
export function cableTypesForPortType(project: Project, portType: PortType): CableType[] {
|
||||
const families = acceptedFamilies(portType)
|
||||
return allCableTypes(project).filter((ct) => {
|
||||
const [endA, endB] = cableEnds(ct)
|
||||
return families.has(endA) || families.has(endB)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cable types valid for a connection between two specific ports. For a plain
|
||||
* cable this is the *intersection* of what each end accepts, not the union:
|
||||
* a "combo" jack (e.g. a patch bay accepting balanced or unbalanced) paired
|
||||
* with a plain balanced port should only offer balanced cable — the plain
|
||||
* end still dictates what's electrically correct, even though the combo end
|
||||
* alone would accept more. An adapter cable (different connector families on
|
||||
* each end — e.g. XLR-to-TRS) is included whenever its two ends line up with
|
||||
* the two ports, in either orientation; that's what lets two port types that
|
||||
* aren't declared generally compatible still be wired with the right cable,
|
||||
* without changing either port type's own compatibility rules.
|
||||
*/
|
||||
export function cableTypesForConnection(
|
||||
project: Project,
|
||||
sourcePortType: PortType,
|
||||
targetPortType: PortType,
|
||||
): CableType[] {
|
||||
const sourceFamilies = acceptedFamilies(sourcePortType)
|
||||
const targetFamilies = acceptedFamilies(targetPortType)
|
||||
return allCableTypes(project).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies))
|
||||
}
|
||||
|
||||
/** Whether any cable type (built-in or custom) could bridge these two port
|
||||
* types — used to allow a connection even when the port types aren't
|
||||
* declared directly compatible, as long as a specific adapter cable exists. */
|
||||
export function hasBridgingCableType(project: Project, sourcePortType: PortType, targetPortType: PortType): boolean {
|
||||
return cableTypesForConnection(project, sourcePortType, targetPortType).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a set of "also compatible with" picks (port type ids, as checked
|
||||
* in the connector-type editor) into the family list to store on a new/edited
|
||||
* port type. Crucially this pulls in each picked type's *full* accepted-family
|
||||
* set, not just its own bare family — so picking an existing combo/hybrid type
|
||||
* (like "1/4" TRS/TS (Balanced or Unbalanced)") inherits everything *that*
|
||||
* type accepts, rather than only matching other ports that are that exact
|
||||
* combo type. Without this, "compatible with a combo type" wouldn't imply
|
||||
* "compatible with what the combo type itself accepts", which is the
|
||||
* confusing gap that makes newly-authored combo connectors fail to mate with
|
||||
* the plain connectors they were meant to cover.
|
||||
*/
|
||||
export function expandCompatibleFamilyIds(selectedPortTypeIds: string[], allTypes: PortType[]): string[] {
|
||||
const families = new Set<string>()
|
||||
for (const id of selectedPortTypeIds) {
|
||||
const portType = allTypes.find((pt) => pt.id === id)
|
||||
if (!portType) continue
|
||||
for (const family of acceptedFamilies(portType)) families.add(family)
|
||||
}
|
||||
return [...families]
|
||||
}
|
||||
|
||||
export function createEmptyProject(name = 'Untitled Project'): Project {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id: uuid(),
|
||||
name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
devices: [],
|
||||
connections: [],
|
||||
customDeviceTemplates: [],
|
||||
customPortTypes: [],
|
||||
customCableTypes: [],
|
||||
customDeviceCategories: [],
|
||||
hiddenBuiltInDeviceTemplateIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Instantiates a device on the canvas from a template, deep-copying ports
|
||||
* (with fresh ids) so editing the instance never mutates the template. */
|
||||
export function createDeviceFromTemplate(
|
||||
template: DeviceTemplate,
|
||||
position: { x: number; y: number },
|
||||
): Device {
|
||||
return {
|
||||
id: uuid(),
|
||||
name: template.name,
|
||||
templateId: template.id,
|
||||
category: template.category,
|
||||
manufacturer: template.manufacturer,
|
||||
model: template.model,
|
||||
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
|
||||
position,
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlankPort(overrides: Partial<Port> = {}): Port {
|
||||
return {
|
||||
id: uuid(),
|
||||
name: overrides.name ?? 'New Port',
|
||||
direction: overrides.direction ?? 'output',
|
||||
portTypeId: overrides.portTypeId ?? BUILT_IN_PORT_TYPES[0].id,
|
||||
}
|
||||
}
|
||||
|
||||
/** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2".
|
||||
* Only increments a number that's the very last thing in the name (trailing
|
||||
* whitespace aside), so labels like `1/4" TS In` aren't misread as ending
|
||||
* in a counter — they just get " 2" appended instead. Used by "clone port". */
|
||||
export function incrementPortName(name: string): string {
|
||||
const match = name.match(/^(.*?)(\d+)\s*$/)
|
||||
if (match) {
|
||||
const [, prefix, digits] = match
|
||||
const next = String(Number(digits) + 1).padStart(digits.length, '0')
|
||||
return `${prefix}${next}`
|
||||
}
|
||||
return `${name.trim()} 2`
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Core domain model. Deliberately framework-agnostic (no React, no React Flow
|
||||
* types) so it can be unit tested and reused if a backend/API layer is added
|
||||
* later — only the `position` field leaks a canvas concept, kept generic.
|
||||
*/
|
||||
|
||||
export type PortDirection = 'input' | 'output' | 'bidirectional'
|
||||
|
||||
export type PortCategory =
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'network'
|
||||
| 'usb'
|
||||
| 'power'
|
||||
| 'control'
|
||||
| 'other'
|
||||
|
||||
/** A device category id (references a DeviceCategoryDef). Kept as a plain
|
||||
* string rather than a fixed union so projects can define their own. */
|
||||
export type DeviceCategory = string
|
||||
|
||||
/** A device grouping shown in the palette and category pickers. Built-ins
|
||||
* ship in `library.ts`; users can add their own, scoped to the project. */
|
||||
export interface DeviceCategoryDef {
|
||||
id: string
|
||||
name: string
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility
|
||||
* between two ports is decided by comparing `family` (plus optional
|
||||
* cross-compatible families), not by PortType identity, so a custom
|
||||
* "HDMI 2.1" type can still mate with the built-in "HDMI" type.
|
||||
*/
|
||||
export interface PortType {
|
||||
id: string
|
||||
name: string
|
||||
category: PortCategory
|
||||
/** Compatibility key. Two ports can connect if their families match
|
||||
* (directly or via `compatibleFamilyIds`). */
|
||||
family: string
|
||||
/** Extra families this type may also mate with (e.g. Cat5e <-> Cat6). */
|
||||
compatibleFamilyIds?: string[]
|
||||
/** How many cables this port can simultaneously accept. Defaults to 1. */
|
||||
maxConnections?: number
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
/** A named connection point on a device or device template. */
|
||||
export interface Port {
|
||||
id: string
|
||||
name: string
|
||||
direction: PortDirection
|
||||
portTypeId: string
|
||||
}
|
||||
|
||||
/** A reusable, non-instantiated device definition (shown in the palette). */
|
||||
export interface DeviceTemplate {
|
||||
id: string
|
||||
name: string
|
||||
category: DeviceCategory
|
||||
manufacturer?: string
|
||||
model?: string
|
||||
ports: Port[]
|
||||
builtIn?: boolean
|
||||
}
|
||||
|
||||
/** A device placed on the canvas. Ports are copied from the template at
|
||||
* creation time so editing one instance never mutates the shared template. */
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
templateId?: string
|
||||
category: DeviceCategory
|
||||
manufacturer?: string
|
||||
model?: string
|
||||
notes?: string
|
||||
ports: Port[]
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
/** A physical cable spec (what you'd actually buy). */
|
||||
export interface CableType {
|
||||
id: string
|
||||
name: string
|
||||
/** Connector family at one end. For a plain cable both ends are this
|
||||
* family. */
|
||||
family: string
|
||||
/** Connector family at the other end, only set for an *adapter* cable with
|
||||
* a different connector on each end (e.g. XLR-to-1/4"-TRS) — this lets a
|
||||
* specific cable bridge two port families that aren't declared generally
|
||||
* compatible, without changing either port type's own compatibility
|
||||
* rules (which would make that bridge apply to every port of that type,
|
||||
* not just where this particular cable is actually used). */
|
||||
family2?: string
|
||||
unit: 'ft' | 'm'
|
||||
costPerUnit?: number
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
/** A wired connection ("cable") between two ports on two devices. */
|
||||
export interface Connection {
|
||||
id: string
|
||||
sourceDeviceId: string
|
||||
sourcePortId: string
|
||||
targetDeviceId: string
|
||||
targetPortId: string
|
||||
cableTypeId: string
|
||||
/** User-entered run length, in the CableType's unit. Optional because
|
||||
* real-world layout isn't modeled (no floor plan / scale). */
|
||||
distance?: number
|
||||
label?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
devices: Device[]
|
||||
connections: Connection[]
|
||||
/** User-defined additions to the built-in libraries, scoped to this project. */
|
||||
customDeviceTemplates: DeviceTemplate[]
|
||||
customPortTypes: PortType[]
|
||||
customCableTypes: CableType[]
|
||||
customDeviceCategories: DeviceCategoryDef[]
|
||||
/** Built-in device template ids the user has removed from their palette.
|
||||
* Hidden rather than deleted since built-ins are shared code, not
|
||||
* per-project data — this lets them be restored later. */
|
||||
hiddenBuiltInDeviceTemplateIds: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user