feat(domain): add relations, properties, catalogue, symbols and flow model
Pure, framework-free core: port media + color schemes with compatibility rules, typed/grouped property model with coercion and validation, catalogue store with physics seeds, SVG symbol registry with port layouts, and a mass-conserving mock flow solver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a1d2d0da6
commit
372c2c25c4
103
src/renderer/diagram/catalogue.ts
Normal file
103
src/renderer/diagram/catalogue.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Catalogues are reusable lookup tables (pipe materials, pump models ...).
|
||||
* A `catalogueItem` property stores an item id; selecting an item may seed
|
||||
* physics defaults on the owning element via `seeds`.
|
||||
*/
|
||||
|
||||
export interface CatalogueItem {
|
||||
id: string
|
||||
label: string
|
||||
/** Arbitrary typed fields describing the item. */
|
||||
fields: Record<string, string | number>
|
||||
/** Property keys → values applied to an element when this item is chosen. */
|
||||
seeds?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface Catalogue {
|
||||
id: string
|
||||
label: string
|
||||
items: CatalogueItem[]
|
||||
}
|
||||
|
||||
export const CATALOGUES: Record<string, Catalogue> = {
|
||||
pipeMaterial: {
|
||||
id: 'pipeMaterial',
|
||||
label: 'Pipe material',
|
||||
items: [
|
||||
{
|
||||
id: 'steel',
|
||||
label: 'Steel (welded)',
|
||||
fields: { roughness: 0.045, maxPressure: 40 },
|
||||
seeds: { roughness: 0.045 }
|
||||
},
|
||||
{
|
||||
id: 'pvc',
|
||||
label: 'PVC',
|
||||
fields: { roughness: 0.0015, maxPressure: 16 },
|
||||
seeds: { roughness: 0.0015 }
|
||||
},
|
||||
{
|
||||
id: 'copper',
|
||||
label: 'Copper',
|
||||
fields: { roughness: 0.0015, maxPressure: 25 },
|
||||
seeds: { roughness: 0.0015 }
|
||||
},
|
||||
{
|
||||
id: 'castIron',
|
||||
label: 'Cast iron',
|
||||
fields: { roughness: 0.26, maxPressure: 25 },
|
||||
seeds: { roughness: 0.26 }
|
||||
}
|
||||
]
|
||||
},
|
||||
pumpModel: {
|
||||
id: 'pumpModel',
|
||||
label: 'Pump model',
|
||||
items: [
|
||||
{
|
||||
id: 'grundfos-cr5',
|
||||
label: 'Grundfos CR 5',
|
||||
fields: { ratedFlow: 5, ratedHead: 40, power: 1.1 },
|
||||
seeds: { flowRate: 5 }
|
||||
},
|
||||
{
|
||||
id: 'grundfos-cr10',
|
||||
label: 'Grundfos CR 10',
|
||||
fields: { ratedFlow: 10, ratedHead: 60, power: 2.2 },
|
||||
seeds: { flowRate: 10 }
|
||||
},
|
||||
{
|
||||
id: 'wilo-mhi',
|
||||
label: 'Wilo MHI 400',
|
||||
fields: { ratedFlow: 8, ratedHead: 50, power: 1.5 },
|
||||
seeds: { flowRate: 8 }
|
||||
}
|
||||
]
|
||||
},
|
||||
valveType: {
|
||||
id: 'valveType',
|
||||
label: 'Valve type',
|
||||
items: [
|
||||
{ id: 'gate', label: 'Gate valve', fields: { kv: 120 } },
|
||||
{ id: 'ball', label: 'Ball valve', fields: { kv: 200 } },
|
||||
{ id: 'butterfly', label: 'Butterfly valve', fields: { kv: 90 } },
|
||||
{ id: 'check', label: 'Check valve', fields: { kv: 80 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function getCatalogue(id: string): Catalogue | undefined {
|
||||
return CATALOGUES[id]
|
||||
}
|
||||
|
||||
export function getCatalogueItem(catalogueId: string, itemId: string): CatalogueItem | undefined {
|
||||
return CATALOGUES[catalogueId]?.items.find((i) => i.id === itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a chosen catalogue item, return the property seeds to merge into the
|
||||
* owning element's values (empty object when the item has none).
|
||||
*/
|
||||
export function seedsFromItem(catalogueId: string, itemId: string): Record<string, string | number> {
|
||||
return getCatalogueItem(catalogueId, itemId)?.seeds ?? {}
|
||||
}
|
||||
121
src/renderer/diagram/flow.ts
Normal file
121
src/renderer/diagram/flow.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Mock hydraulic solver. Real pipe-network solving (Hardy-Cross etc.) is out
|
||||
* of scope; instead we distribute a conserved flow from sources to sinks over
|
||||
* a spanning tree of the network. On a tree the result is exact (Kirchhoff /
|
||||
* mass conservation holds at every node); extra loop edges are assigned zero
|
||||
* flow. This is enough to drive a believable animated visualization.
|
||||
*/
|
||||
|
||||
export interface FlowNodeInput {
|
||||
id: string
|
||||
/** Net supply: > 0 source, < 0 demand, 0 transit. */
|
||||
supply: number
|
||||
}
|
||||
|
||||
export interface FlowEdgeInput {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface EdgeFlow {
|
||||
id: string
|
||||
/** Non-negative magnitude (m³/h). */
|
||||
magnitude: number
|
||||
/** true → flows source→target, false → target→source. */
|
||||
forward: boolean
|
||||
}
|
||||
|
||||
export interface FlowSolution {
|
||||
edges: Map<string, EdgeFlow>
|
||||
maxMagnitude: number
|
||||
/** Sum of |imbalance| across nodes — 0 means perfect conservation. */
|
||||
residual: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance supplies so total supply === total demand (scales demands to match
|
||||
* available supply). Returns a new map; the input is not mutated.
|
||||
*/
|
||||
export function balanceSupplies(nodes: FlowNodeInput[]): Map<string, number> {
|
||||
const supply = nodes.filter((n) => n.supply > 0).reduce((a, n) => a + n.supply, 0)
|
||||
const demand = nodes.filter((n) => n.supply < 0).reduce((a, n) => a - n.supply, 0)
|
||||
const out = new Map<string, number>()
|
||||
const scale = demand > 0 && supply > 0 ? supply / demand : 1
|
||||
for (const n of nodes) out.set(n.id, n.supply < 0 ? n.supply * scale : n.supply)
|
||||
return out
|
||||
}
|
||||
|
||||
export function solveFlow(nodes: FlowNodeInput[], edges: FlowEdgeInput[]): FlowSolution {
|
||||
const net = balanceSupplies(nodes)
|
||||
const result = new Map<string, EdgeFlow>()
|
||||
for (const e of edges) result.set(e.id, { id: e.id, magnitude: 0, forward: true })
|
||||
|
||||
// Adjacency over undirected edges (skip dangling endpoints).
|
||||
const adj = new Map<string, { edge: FlowEdgeInput; other: string }[]>()
|
||||
for (const n of nodes) adj.set(n.id, [])
|
||||
for (const e of edges) {
|
||||
if (!net.has(e.source) || !net.has(e.target)) continue
|
||||
adj.get(e.source)!.push({ edge: e, other: e.target })
|
||||
adj.get(e.target)!.push({ edge: e, other: e.source })
|
||||
}
|
||||
|
||||
// Build a spanning forest with BFS; record the tree edge used to reach a node.
|
||||
const parentEdge = new Map<string, FlowEdgeInput>()
|
||||
const parentOf = new Map<string, string>()
|
||||
const order: string[] = []
|
||||
const visited = new Set<string>()
|
||||
// Visit sources first so trees are rooted at supply where possible.
|
||||
const roots = [...nodes].sort((a, b) => (net.get(b.id)! - net.get(a.id)!)).map((n) => n.id)
|
||||
|
||||
for (const root of roots) {
|
||||
if (visited.has(root)) continue
|
||||
visited.add(root)
|
||||
const queue = [root]
|
||||
while (queue.length) {
|
||||
const u = queue.shift()!
|
||||
order.push(u)
|
||||
for (const { edge, other } of adj.get(u) ?? []) {
|
||||
if (visited.has(other)) continue
|
||||
visited.add(other)
|
||||
parentEdge.set(other, edge)
|
||||
parentOf.set(other, u)
|
||||
queue.push(other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-order accumulation: subtree net supply flows through the parent edge.
|
||||
const subtreeNet = new Map<string, number>()
|
||||
for (const id of order) subtreeNet.set(id, net.get(id) ?? 0)
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i]
|
||||
const parent = parentOf.get(node)
|
||||
if (parent === undefined) continue
|
||||
const s = subtreeNet.get(node)!
|
||||
subtreeNet.set(parent, subtreeNet.get(parent)! + s)
|
||||
const edge = parentEdge.get(node)!
|
||||
// Positive subtree surplus flows node → parent.
|
||||
const forwardIsSourceToTarget = edge.source === node ? s < 0 : s > 0
|
||||
result.set(edge.id, { id: edge.id, magnitude: Math.abs(s), forward: forwardIsSourceToTarget })
|
||||
}
|
||||
|
||||
// Metrics.
|
||||
let maxMagnitude = 0
|
||||
for (const f of result.values()) maxMagnitude = Math.max(maxMagnitude, f.magnitude)
|
||||
|
||||
const inflow = new Map<string, number>()
|
||||
for (const id of net.keys()) inflow.set(id, net.get(id) ?? 0)
|
||||
for (const e of edges) {
|
||||
const f = result.get(e.id)!
|
||||
if (f.magnitude === 0) continue
|
||||
const from = f.forward ? e.source : e.target
|
||||
const to = f.forward ? e.target : e.source
|
||||
if (inflow.has(from)) inflow.set(from, inflow.get(from)! - f.magnitude)
|
||||
if (inflow.has(to)) inflow.set(to, inflow.get(to)! + f.magnitude)
|
||||
}
|
||||
let residual = 0
|
||||
for (const v of inflow.values()) residual += Math.abs(v)
|
||||
|
||||
return { edges: result, maxMagnitude, residual }
|
||||
}
|
||||
149
src/renderer/diagram/properties.ts
Normal file
149
src/renderer/diagram/properties.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Typed, grouped property model shared by nodes and edges. The property panel
|
||||
* renders an editor per `PropertyType`; the model stores plain JSON values.
|
||||
*/
|
||||
|
||||
export type PropertyType =
|
||||
| 'string'
|
||||
| 'int'
|
||||
| 'float'
|
||||
| 'enum'
|
||||
| 'color'
|
||||
| 'catalogueItem'
|
||||
| 'catalogueItems'
|
||||
| 'listOfValues'
|
||||
|
||||
export interface PropertyDef {
|
||||
key: string
|
||||
label: string
|
||||
type: PropertyType
|
||||
group: string
|
||||
/** Options for `enum` and item labels for `listOfValues`. */
|
||||
options?: { value: string; label: string }[]
|
||||
/** Catalogue id for `catalogueItem` / `catalogueItems`. */
|
||||
catalogue?: string
|
||||
unit?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
required?: boolean
|
||||
default?: PropertyValue
|
||||
/** Element type of a `listOfValues`. */
|
||||
itemType?: 'string' | 'int' | 'float'
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type PropertyValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| string[]
|
||||
| number[]
|
||||
| null
|
||||
|
||||
export interface PropertyGroup {
|
||||
id: string
|
||||
label: string
|
||||
defs: PropertyDef[]
|
||||
}
|
||||
|
||||
/** A full schema is an ordered list of groups. */
|
||||
export type PropertySchema = PropertyGroup[]
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
/** Flatten a schema into a key → def lookup. */
|
||||
export function schemaIndex(schema: PropertySchema): Map<string, PropertyDef> {
|
||||
const map = new Map<string, PropertyDef>()
|
||||
for (const g of schema) for (const d of g.defs) map.set(d.key, d)
|
||||
return map
|
||||
}
|
||||
|
||||
/** Build a values object from the schema defaults. */
|
||||
export function defaultsFor(schema: PropertySchema): Record<string, PropertyValue> {
|
||||
const out: Record<string, PropertyValue> = {}
|
||||
for (const g of schema) {
|
||||
for (const d of g.defs) {
|
||||
out[d.key] = d.default !== undefined ? d.default : emptyValue(d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function emptyValue(d: PropertyDef): PropertyValue {
|
||||
switch (d.type) {
|
||||
case 'int':
|
||||
case 'float':
|
||||
return d.min ?? 0
|
||||
case 'catalogueItems':
|
||||
case 'listOfValues':
|
||||
return []
|
||||
case 'enum':
|
||||
return d.options?.[0]?.value ?? ''
|
||||
case 'color':
|
||||
return '#888888'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce a raw input value into the type dictated by the definition. */
|
||||
export function coerce(def: PropertyDef, raw: unknown): PropertyValue {
|
||||
switch (def.type) {
|
||||
case 'int': {
|
||||
const n = Math.round(Number(raw))
|
||||
return Number.isFinite(n) ? clampNumber(def, n) : (def.min ?? 0)
|
||||
}
|
||||
case 'float': {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? clampNumber(def, n) : (def.min ?? 0)
|
||||
}
|
||||
case 'catalogueItems':
|
||||
return Array.isArray(raw) ? (raw as string[]).map(String) : []
|
||||
case 'listOfValues':
|
||||
if (!Array.isArray(raw)) return []
|
||||
return def.itemType && def.itemType !== 'string'
|
||||
? (raw as unknown[]).map((v) => Number(v)).filter((v) => Number.isFinite(v))
|
||||
: (raw as unknown[]).map(String)
|
||||
case 'color':
|
||||
case 'enum':
|
||||
case 'catalogueItem':
|
||||
case 'string':
|
||||
default:
|
||||
return raw == null ? '' : String(raw)
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(def: PropertyDef, n: number): number {
|
||||
if (def.min != null && n < def.min) return def.min
|
||||
if (def.max != null && n > def.max) return def.max
|
||||
return n
|
||||
}
|
||||
|
||||
/** Validate a values object against the schema. */
|
||||
export function validate(
|
||||
schema: PropertySchema,
|
||||
values: Record<string, PropertyValue>
|
||||
): ValidationResult {
|
||||
const errors: Record<string, string> = {}
|
||||
for (const g of schema) {
|
||||
for (const d of g.defs) {
|
||||
const v = values[d.key]
|
||||
if (d.required && (v === '' || v == null || (Array.isArray(v) && v.length === 0))) {
|
||||
errors[d.key] = `${d.label} is required`
|
||||
continue
|
||||
}
|
||||
if ((d.type === 'int' || d.type === 'float') && typeof v === 'number') {
|
||||
if (d.min != null && v < d.min) errors[d.key] = `${d.label} must be ≥ ${d.min}`
|
||||
else if (d.max != null && v > d.max) errors[d.key] = `${d.label} must be ≤ ${d.max}`
|
||||
}
|
||||
if (d.type === 'enum' && d.options && v !== '' && !d.options.some((o) => o.value === v)) {
|
||||
errors[d.key] = `${d.label} has an invalid option`
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
106
src/renderer/diagram/relations.ts
Normal file
106
src/renderer/diagram/relations.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Port "relations" describe the medium a port carries (water, power, gas ...).
|
||||
* Two ports may be connected only when their media are compatible. Colors are
|
||||
* provided by swappable color schemes so the whole diagram can be re-themed.
|
||||
*/
|
||||
|
||||
export type MediumId =
|
||||
| 'water'
|
||||
| 'hotWater'
|
||||
| 'sewage'
|
||||
| 'gas'
|
||||
| 'steam'
|
||||
| 'air'
|
||||
| 'power'
|
||||
| 'signal'
|
||||
|
||||
export interface Medium {
|
||||
id: MediumId
|
||||
label: string
|
||||
/** Media (besides itself) this medium may connect to. */
|
||||
compatibleWith: MediumId[]
|
||||
}
|
||||
|
||||
export const MEDIA: Record<MediumId, Medium> = {
|
||||
water: { id: 'water', label: 'Cold water', compatibleWith: ['hotWater'] },
|
||||
hotWater: { id: 'hotWater', label: 'Hot water', compatibleWith: ['water', 'steam'] },
|
||||
sewage: { id: 'sewage', label: 'Sewage', compatibleWith: [] },
|
||||
gas: { id: 'gas', label: 'Gas', compatibleWith: [] },
|
||||
steam: { id: 'steam', label: 'Steam', compatibleWith: ['hotWater'] },
|
||||
air: { id: 'air', label: 'Compressed air', compatibleWith: [] },
|
||||
power: { id: 'power', label: 'Power line', compatibleWith: [] },
|
||||
signal: { id: 'signal', label: 'Signal / control', compatibleWith: [] }
|
||||
}
|
||||
|
||||
export const MEDIUM_IDS = Object.keys(MEDIA) as MediumId[]
|
||||
|
||||
/** True when a port of medium `a` may be connected to a port of medium `b`. */
|
||||
export function canConnectMedia(a: MediumId, b: MediumId): boolean {
|
||||
if (a === b) return true
|
||||
return (
|
||||
MEDIA[a]?.compatibleWith.includes(b) === true ||
|
||||
MEDIA[b]?.compatibleWith.includes(a) === true
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Color schemes --------------------------------------------------------
|
||||
|
||||
export interface ColorScheme {
|
||||
id: string
|
||||
label: string
|
||||
colors: Record<MediumId, string>
|
||||
}
|
||||
|
||||
export const COLOR_SCHEMES: ColorScheme[] = [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
colors: {
|
||||
water: '#2f7fe0',
|
||||
hotWater: '#e0562f',
|
||||
sewage: '#7a5b34',
|
||||
gas: '#e0b62f',
|
||||
steam: '#d14fd1',
|
||||
air: '#3fb6c0',
|
||||
power: '#e0d030',
|
||||
signal: '#9aa0a6'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'colorblind',
|
||||
label: 'Color-blind safe',
|
||||
colors: {
|
||||
water: '#0072B2',
|
||||
hotWater: '#D55E00',
|
||||
sewage: '#8c6d31',
|
||||
gas: '#E69F00',
|
||||
steam: '#CC79A7',
|
||||
air: '#56B4E9',
|
||||
power: '#F0E442',
|
||||
signal: '#999999'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'print',
|
||||
label: 'Print (mono-friendly)',
|
||||
colors: {
|
||||
water: '#1f3a93',
|
||||
hotWater: '#7b241c',
|
||||
sewage: '#4d3b1f',
|
||||
gas: '#7d6608',
|
||||
steam: '#4a235a',
|
||||
air: '#0e6251',
|
||||
power: '#7d6608',
|
||||
signal: '#424949'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export function getColorScheme(id: string): ColorScheme {
|
||||
return COLOR_SCHEMES.find((s) => s.id === id) ?? COLOR_SCHEMES[0]
|
||||
}
|
||||
|
||||
export function mediumColor(mediumId: MediumId | string, schemeId = 'default'): string {
|
||||
const scheme = getColorScheme(schemeId)
|
||||
return scheme.colors[mediumId as MediumId] ?? '#9aa0a6'
|
||||
}
|
||||
137
src/renderer/diagram/schemas.ts
Normal file
137
src/renderer/diagram/schemas.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Reusable property-schema fragments for nodes and edges. Each symbol composes
|
||||
* a base (Identity + Presentation) with its own Physics group.
|
||||
*/
|
||||
import type { PropertyGroup, PropertySchema, PropertyDef } from './properties'
|
||||
|
||||
const yesNo: PropertyDef['options'] = [
|
||||
{ value: 'yes', label: 'Show' },
|
||||
{ value: 'no', label: 'Hide' }
|
||||
]
|
||||
|
||||
export function identityGroup(defaultTitle: string): PropertyGroup {
|
||||
return {
|
||||
id: 'identity',
|
||||
label: 'Identity',
|
||||
defs: [
|
||||
{ key: 'title', label: 'Title', type: 'string', group: 'identity', default: defaultTitle },
|
||||
{ key: 'tag', label: 'Tag / ID', type: 'string', group: 'identity', default: '' },
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
type: 'string',
|
||||
group: 'identity',
|
||||
default: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function nodePresentationGroup(color: string): PropertyGroup {
|
||||
return {
|
||||
id: 'presentation',
|
||||
label: 'Presentation',
|
||||
defs: [
|
||||
{ key: 'color', label: 'Accent color', type: 'color', group: 'presentation', default: color },
|
||||
{
|
||||
key: 'rotation',
|
||||
label: 'Rotation',
|
||||
type: 'int',
|
||||
group: 'presentation',
|
||||
unit: '°',
|
||||
min: 0,
|
||||
max: 270,
|
||||
step: 90,
|
||||
default: 0
|
||||
},
|
||||
{ key: 'width', label: 'Width', type: 'int', group: 'presentation', unit: 'px', min: 24, max: 240, default: 72 },
|
||||
{ key: 'height', label: 'Height', type: 'int', group: 'presentation', unit: 'px', min: 24, max: 240, default: 72 },
|
||||
{ key: 'showLabel', label: 'Label', type: 'enum', group: 'presentation', options: yesNo, default: 'yes' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function physicsGroup(defs: PropertyDef[]): PropertyGroup {
|
||||
return { id: 'physics', label: 'Physics', defs: defs.map((d) => ({ ...d, group: 'physics' })) }
|
||||
}
|
||||
|
||||
/** Build a node schema from a physics-group definition list. */
|
||||
export function nodeSchema(
|
||||
defaultTitle: string,
|
||||
color: string,
|
||||
physics: PropertyDef[]
|
||||
): PropertySchema {
|
||||
return [identityGroup(defaultTitle), nodePresentationGroup(color), physicsGroup(physics)]
|
||||
}
|
||||
|
||||
// ---- Edge schema ----------------------------------------------------------
|
||||
|
||||
export const MARKER_OPTIONS: PropertyDef['options'] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'arrow', label: 'Arrow' },
|
||||
{ value: 'openArrow', label: 'Open arrow' },
|
||||
{ value: 'circle', label: 'Circle' },
|
||||
{ value: 'diamond', label: 'Diamond' },
|
||||
{ value: 'bar', label: 'Bar' }
|
||||
]
|
||||
|
||||
export const LINE_STYLE_OPTIONS: PropertyDef['options'] = [
|
||||
{ value: 'solid', label: 'Solid' },
|
||||
{ value: 'dashed', label: 'Dashed' },
|
||||
{ value: 'dotted', label: 'Dotted' }
|
||||
]
|
||||
|
||||
export function edgeSchema(): PropertySchema {
|
||||
return [
|
||||
{
|
||||
id: 'identity',
|
||||
label: 'Identity',
|
||||
defs: [
|
||||
{ key: 'title', label: 'Title', type: 'string', group: 'identity', default: '' },
|
||||
{ key: 'tag', label: 'Tag / ID', type: 'string', group: 'identity', default: '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'presentation',
|
||||
label: 'Presentation',
|
||||
defs: [
|
||||
{ key: 'color', label: 'Line color', type: 'color', group: 'presentation', default: '#2f7fe0' },
|
||||
{ key: 'lineStyle', label: 'Line style', type: 'enum', group: 'presentation', options: LINE_STYLE_OPTIONS, default: 'solid' },
|
||||
{ key: 'strokeWidth', label: 'Width', type: 'int', group: 'presentation', unit: 'px', min: 1, max: 12, default: 3 },
|
||||
{ key: 'tailMarker', label: 'Tail marker', type: 'enum', group: 'presentation', options: MARKER_OPTIONS, default: 'none' },
|
||||
{ key: 'headMarker', label: 'Head marker', type: 'enum', group: 'presentation', options: MARKER_OPTIONS, default: 'arrow' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'physics',
|
||||
label: 'Physics',
|
||||
defs: [
|
||||
{ key: 'length', label: 'Length', type: 'float', group: 'physics', unit: 'm', min: 0, step: 0.1, default: 10 },
|
||||
{ key: 'diameter', label: 'Diameter', type: 'float', group: 'physics', unit: 'mm', min: 1, step: 1, default: 100 },
|
||||
{ key: 'material', label: 'Material', type: 'catalogueItem', group: 'physics', catalogue: 'pipeMaterial', default: 'steel' },
|
||||
{ key: 'roughness', label: 'Roughness', type: 'float', group: 'physics', unit: 'mm', min: 0, step: 0.001, default: 0.045 },
|
||||
{
|
||||
key: 'flowType',
|
||||
label: 'Flow type',
|
||||
type: 'enum',
|
||||
group: 'physics',
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'laminar', label: 'Laminar' },
|
||||
{ value: 'turbulent', label: 'Turbulent' }
|
||||
],
|
||||
default: 'auto'
|
||||
},
|
||||
{ key: 'designFlow', label: 'Design flow', type: 'float', group: 'physics', unit: 'm³/h', min: 0, step: 0.1, default: 0 },
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Zone tags',
|
||||
type: 'listOfValues',
|
||||
group: 'physics',
|
||||
itemType: 'string',
|
||||
default: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
292
src/renderer/diagram/symbols.ts
Normal file
292
src/renderer/diagram/symbols.ts
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Symbol registry. Each symbol pairs an inline SVG icon (drawn in a 0..100
|
||||
* viewBox) with a port layout and a property schema. Ports carry a medium
|
||||
* (see relations.ts) that governs which connections are legal.
|
||||
*/
|
||||
import type { MediumId } from './relations'
|
||||
import type { PropertySchema, PropertyDef } from './properties'
|
||||
import { nodeSchema } from './schemas'
|
||||
|
||||
export interface PortSpec {
|
||||
id: string
|
||||
/** Position as a fraction of the symbol box (0..1). */
|
||||
x: number
|
||||
y: number
|
||||
medium: MediumId
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface SymbolDef {
|
||||
id: string
|
||||
label: string
|
||||
category: string
|
||||
width: number
|
||||
height: number
|
||||
/** SVG body drawn inside a `0 0 100 100` viewBox (no outer <svg>). */
|
||||
svg: string
|
||||
ports: PortSpec[]
|
||||
schema: PropertySchema
|
||||
}
|
||||
|
||||
const stroke = '#d7dae0'
|
||||
|
||||
// Common physics fragments -------------------------------------------------
|
||||
const elevation: PropertyDef = { key: 'elevation', label: 'Elevation', type: 'float', group: 'physics', unit: 'm', default: 0, step: 0.1 }
|
||||
const capacity: PropertyDef = { key: 'capacity', label: 'Capacity', type: 'float', group: 'physics', unit: 'm³', min: 0, default: 10 }
|
||||
|
||||
function icon(body: string): string {
|
||||
return `<g fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">${body}</g>`
|
||||
}
|
||||
|
||||
export const SYMBOLS: SymbolDef[] = [
|
||||
// ---- Sources ----
|
||||
{
|
||||
id: 'reservoir',
|
||||
label: 'Reservoir',
|
||||
category: 'Sources',
|
||||
width: 76,
|
||||
height: 76,
|
||||
svg: icon(
|
||||
'<path d="M20 30 h60 v50 h-60 z"/><path d="M20 44 q15 -10 30 0 t30 0" fill="none"/><path d="M50 30 v-14"/>'
|
||||
),
|
||||
ports: [{ id: 'out', x: 1, y: 0.6, medium: 'water', label: 'Outlet' }],
|
||||
schema: nodeSchema('Reservoir', '#2f7fe0', [
|
||||
elevation,
|
||||
{ ...capacity, default: 500 },
|
||||
{ key: 'head', label: 'Static head', type: 'float', unit: 'm', default: 20, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'well',
|
||||
label: 'Well / Source',
|
||||
category: 'Sources',
|
||||
width: 64,
|
||||
height: 64,
|
||||
svg: icon('<circle cx="50" cy="50" r="30"/><path d="M50 30 v40 M30 50 h40"/>'),
|
||||
ports: [{ id: 'out', x: 1, y: 0.5, medium: 'water', label: 'Outlet' }],
|
||||
schema: nodeSchema('Source', '#2f7fe0', [
|
||||
{ key: 'supplyFlow', label: 'Supply flow', type: 'float', unit: 'm³/h', min: 0, default: 20, group: 'physics' },
|
||||
{ key: 'pressure', label: 'Pressure', type: 'float', unit: 'bar', min: 0, default: 4, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'grid-supply',
|
||||
label: 'Grid supply',
|
||||
category: 'Sources',
|
||||
width: 64,
|
||||
height: 64,
|
||||
svg: icon('<path d="M55 20 L35 55 h20 L45 80"/><circle cx="50" cy="50" r="30"/>'),
|
||||
ports: [{ id: 'out', x: 1, y: 0.5, medium: 'power', label: 'Line' }],
|
||||
schema: nodeSchema('Grid supply', '#e0d030', [
|
||||
{ key: 'voltage', label: 'Voltage', type: 'float', unit: 'kV', min: 0, default: 0.4, group: 'physics' }
|
||||
])
|
||||
},
|
||||
// ---- Consumers ----
|
||||
{
|
||||
id: 'outlet',
|
||||
label: 'Outlet / Demand',
|
||||
category: 'Consumers',
|
||||
width: 60,
|
||||
height: 60,
|
||||
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M40 50 h20 M52 42 l8 8 -8 8"/>'),
|
||||
ports: [{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Inlet' }],
|
||||
schema: nodeSchema('Outlet', '#2f7fe0', [
|
||||
{ key: 'demand', label: 'Demand', type: 'float', unit: 'm³/h', min: 0, default: 5, group: 'physics' },
|
||||
{ key: 'minPressure', label: 'Min pressure', type: 'float', unit: 'bar', min: 0, default: 1.5, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'load',
|
||||
label: 'Electrical load',
|
||||
category: 'Consumers',
|
||||
width: 60,
|
||||
height: 60,
|
||||
svg: icon('<rect x="25" y="30" width="50" height="40" rx="4"/><path d="M50 20 v10 M40 45 h20 M40 55 h20"/>'),
|
||||
ports: [{ id: 'in', x: 0, y: 0.5, medium: 'power', label: 'Line' }],
|
||||
schema: nodeSchema('Load', '#e0d030', [
|
||||
{ key: 'power', label: 'Power', type: 'float', unit: 'kW', min: 0, default: 5, group: 'physics' }
|
||||
])
|
||||
},
|
||||
// ---- Fittings ----
|
||||
{
|
||||
id: 'tee',
|
||||
label: 'Tee junction',
|
||||
category: 'Fittings',
|
||||
width: 56,
|
||||
height: 56,
|
||||
svg: icon('<path d="M10 50 h80 M50 50 v40"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' },
|
||||
{ id: 'c', x: 0.5, y: 1, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Tee', '#7f8894', [])
|
||||
},
|
||||
{
|
||||
id: 'cross',
|
||||
label: 'Cross junction',
|
||||
category: 'Fittings',
|
||||
width: 56,
|
||||
height: 56,
|
||||
svg: icon('<path d="M10 50 h80 M50 10 v80"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' },
|
||||
{ id: 'c', x: 0.5, y: 0, medium: 'water' },
|
||||
{ id: 'd', x: 0.5, y: 1, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Cross', '#7f8894', [])
|
||||
},
|
||||
{
|
||||
id: 'elbow',
|
||||
label: 'Elbow',
|
||||
category: 'Fittings',
|
||||
width: 52,
|
||||
height: 52,
|
||||
svg: icon('<path d="M50 90 V50 H90"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0.5, y: 1, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Elbow', '#7f8894', [])
|
||||
},
|
||||
{
|
||||
id: 'reducer',
|
||||
label: 'Reducer',
|
||||
category: 'Fittings',
|
||||
width: 60,
|
||||
height: 44,
|
||||
svg: icon('<path d="M15 30 L45 30 L85 45 L85 70 L45 70 L15 70 Z"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Reducer', '#7f8894', [
|
||||
{ key: 'dIn', label: 'Inlet Ø', type: 'float', unit: 'mm', min: 1, default: 150, group: 'physics' },
|
||||
{ key: 'dOut', label: 'Outlet Ø', type: 'float', unit: 'mm', min: 1, default: 100, group: 'physics' }
|
||||
])
|
||||
},
|
||||
// ---- Equipment ----
|
||||
{
|
||||
id: 'pump',
|
||||
label: 'Pump',
|
||||
category: 'Equipment',
|
||||
width: 68,
|
||||
height: 68,
|
||||
svg: icon('<circle cx="50" cy="50" r="30"/><path d="M50 50 L74 38 M50 50 L50 20"/>'),
|
||||
ports: [
|
||||
{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Suction' },
|
||||
{ id: 'out', x: 1, y: 0.5, medium: 'water', label: 'Discharge' }
|
||||
],
|
||||
schema: nodeSchema('Pump', '#3fb6c0', [
|
||||
{ key: 'model', label: 'Model', type: 'catalogueItem', catalogue: 'pumpModel', default: 'grundfos-cr5', group: 'physics' },
|
||||
{ key: 'flowRate', label: 'Flow rate', type: 'float', unit: 'm³/h', min: 0, default: 5, group: 'physics' },
|
||||
{ key: 'head', label: 'Head', type: 'float', unit: 'm', min: 0, default: 40, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'valve',
|
||||
label: 'Valve',
|
||||
category: 'Equipment',
|
||||
width: 60,
|
||||
height: 48,
|
||||
svg: icon('<path d="M20 30 L20 70 L50 50 Z M80 30 L80 70 L50 50 Z"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Valve', '#e0562f', [
|
||||
{ key: 'valveType', label: 'Type', type: 'catalogueItem', catalogue: 'valveType', default: 'gate', group: 'physics' },
|
||||
{ key: 'opening', label: 'Opening', type: 'int', unit: '%', min: 0, max: 100, default: 100, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'tank',
|
||||
label: 'Storage tank',
|
||||
category: 'Equipment',
|
||||
width: 72,
|
||||
height: 84,
|
||||
svg: icon('<rect x="25" y="20" width="50" height="60" rx="6"/><path d="M25 62 h50"/>'),
|
||||
ports: [
|
||||
{ id: 'in', x: 0.5, y: 0, medium: 'water', label: 'Fill' },
|
||||
{ id: 'out', x: 0.5, y: 1, medium: 'water', label: 'Drain' }
|
||||
],
|
||||
schema: nodeSchema('Tank', '#2f7fe0', [elevation, capacity, { key: 'level', label: 'Level', type: 'int', unit: '%', min: 0, max: 100, default: 60, group: 'physics' }])
|
||||
},
|
||||
{
|
||||
id: 'heat-exchanger',
|
||||
label: 'Heat exchanger',
|
||||
category: 'Equipment',
|
||||
width: 84,
|
||||
height: 60,
|
||||
svg: icon('<rect x="15" y="30" width="70" height="40" rx="6"/><path d="M25 50 q10 -12 20 0 t20 0 t20 0"/>'),
|
||||
ports: [
|
||||
{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Cold in' },
|
||||
{ id: 'out', x: 1, y: 0.5, medium: 'hotWater', label: 'Hot out' }
|
||||
],
|
||||
schema: nodeSchema('Heat exchanger', '#e0562f', [
|
||||
{ key: 'duty', label: 'Duty', type: 'float', unit: 'kW', min: 0, default: 50, group: 'physics' }
|
||||
])
|
||||
},
|
||||
// ---- Instruments ----
|
||||
{
|
||||
id: 'flow-meter',
|
||||
label: 'Flow meter',
|
||||
category: 'Instruments',
|
||||
width: 56,
|
||||
height: 56,
|
||||
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M35 55 h30 M40 45 l10 -8 10 8"/>'),
|
||||
ports: [
|
||||
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
|
||||
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
|
||||
],
|
||||
schema: nodeSchema('Flow meter', '#9aa0a6', [
|
||||
{ key: 'reading', label: 'Reading', type: 'float', unit: 'm³/h', default: 0, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'gauge',
|
||||
label: 'Pressure gauge',
|
||||
category: 'Instruments',
|
||||
width: 52,
|
||||
height: 52,
|
||||
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M50 50 L64 38 M50 78 v6"/>'),
|
||||
ports: [{ id: 'a', x: 0.5, y: 1, medium: 'water' }],
|
||||
schema: nodeSchema('Gauge', '#9aa0a6', [
|
||||
{ key: 'reading', label: 'Reading', type: 'float', unit: 'bar', default: 0, group: 'physics' }
|
||||
])
|
||||
},
|
||||
{
|
||||
id: 'sensor',
|
||||
label: 'Signal sensor',
|
||||
category: 'Instruments',
|
||||
width: 48,
|
||||
height: 48,
|
||||
svg: icon('<rect x="28" y="28" width="44" height="44" rx="6"/><path d="M40 50 h20 M50 40 v20"/>'),
|
||||
ports: [{ id: 'sig', x: 1, y: 0.5, medium: 'signal', label: 'Signal' }],
|
||||
schema: nodeSchema('Sensor', '#9aa0a6', [])
|
||||
}
|
||||
]
|
||||
|
||||
export const SYMBOL_CATEGORIES = Array.from(new Set(SYMBOLS.map((s) => s.category)))
|
||||
|
||||
const byId = new Map(SYMBOLS.map((s) => [s.id, s]))
|
||||
|
||||
export function getSymbol(id: string): SymbolDef | undefined {
|
||||
return byId.get(id)
|
||||
}
|
||||
|
||||
export function symbolsByCategory(category: string): SymbolDef[] {
|
||||
return SYMBOLS.filter((s) => s.category === category)
|
||||
}
|
||||
|
||||
/** Full inline SVG string (with outer <svg>) for previews/thumbnails. */
|
||||
export function symbolSvgMarkup(symbol: SymbolDef, size = symbol.width): string {
|
||||
return `<svg viewBox="0 0 100 100" width="${size}" height="${(size * symbol.height) / symbol.width}" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
|
||||
}
|
||||
|
||||
/** data: URI encoding of a symbol icon, used as the node's <image> href. */
|
||||
export function symbolDataUri(symbol: SymbolDef): string {
|
||||
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user