Files
av-planner/src/domain/diagram.ts
T
aarbitandClaude Sonnet 5 29c3ed46a3 Add manufacturer catalog, category/manufacturer library management, and built-in cable flag
Normalizes manufacturer as a shared catalog entity (like device categories)
instead of free text on each device, giving the admin duplicate-detection
nudge a reliable signal. Adds full CRUD (including Admin direct-publish,
bypassing the submission queue) for categories, manufacturers, port types,
and cable types, plus a Categories & Manufacturers library modal and a
browse-by-manufacturer/search view in the device palette. Adds
Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB
lead, a budget AVR's power cord) can be flagged and excluded from the BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:05 -05:00

193 lines
8.8 KiB
TypeScript

import { v4 as uuid } from 'uuid'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
export function allPortTypes(catalog: Catalog): PortType[] {
return catalog.portTypes
}
export function allCableTypes(catalog: Catalog): CableType[] {
return catalog.cableTypes
}
/** `hiddenPublicIds` is a per-user UI preference (see state/catalogStore.ts),
* not part of the catalog data itself — you can hide any public template
* from your own palette without affecting anyone else or the shared entry. */
export function allDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string> = new Set()): DeviceTemplate[] {
return catalog.deviceTemplates.filter((t) => t.custom || !hiddenPublicIds.has(t.id))
}
/** Public templates the user has hidden, for a "restore" list. */
export function hiddenDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string>): DeviceTemplate[] {
return catalog.deviceTemplates.filter((t) => !t.custom && hiddenPublicIds.has(t.id))
}
export function allDeviceCategories(catalog: Catalog): DeviceCategoryDef[] {
return catalog.deviceCategories
}
export function getDeviceCategory(catalog: Catalog, categoryId: string): DeviceCategoryDef | undefined {
return allDeviceCategories(catalog).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(catalog: Catalog, categoryId: string): string {
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
}
export function allManufacturers(catalog: Catalog): Manufacturer[] {
return catalog.manufacturers
}
export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
return allManufacturers(catalog).find((m) => m.id === manufacturerId)
}
/** Friendly name for a manufacturer id — unlike `deviceCategoryName`,
* `manufacturerId` is optional (not every device has a known manufacturer),
* so this returns undefined rather than a fallback string when it's unset
* or, for older data, references a manufacturer that no longer exists. */
export function manufacturerName(catalog: Catalog, manufacturerId: string | undefined): string | undefined {
return manufacturerId ? getManufacturer(catalog, manufacturerId)?.name : undefined
}
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
}
export function getCableType(catalog: Catalog, cableTypeId: string): CableType | undefined {
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
}
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string itself if none matches
* (e.g. a family that only ever existed as hand-typed text, from before
* FamilySelect started sourcing new families from an existing port type). */
export function familyLabel(portTypes: PortType[], family: string): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
/** 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(catalog: Catalog, portType: PortType): CableType[] {
const families = acceptedFamilies(portType)
return allCableTypes(catalog).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(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): CableType[] {
const sourceFamilies = acceptedFamilies(sourcePortType)
const targetFamilies = acceptedFamilies(targetPortType)
return allCableTypes(catalog).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(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): boolean {
return cableTypesForConnection(catalog, 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 createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
const now = new Date().toISOString()
return {
id: uuid(),
name,
createdAt: now,
updatedAt: now,
devices: [],
connections: [],
}
}
/** 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,
manufacturerId: template.manufacturerId,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost,
position,
}
}
/** "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`
}