Adds first version of the app. Local storage only.
This commit is contained in:
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user