/** * Typed property system for nodes and edges. * * Supported types: string, int, float, enum, catalogueItem, catalogueItems, * color, listOfValues. Properties are organized into groups (General / * Presentation / Physics / Simulation) rendered as collapsible sections. */ export type PropertyType = | 'string' | 'int' | 'float' | 'enum' | 'catalogueItem' | 'catalogueItems' | 'color' | 'listOfValues'; export type PropertyValue = string | number | string[] | number[] | null; export interface EnumOption { value: string; label: string; } export interface PropertyDef { key: string; label: string; type: PropertyType; /** Group id, see PROPERTY_GROUPS. */ group: string; /** Physical unit shown next to the editor (e.g. "mm", "m³/h"). */ unit?: string; readOnly?: boolean; min?: number; max?: number; /** Options for `enum` type. */ options?: EnumOption[]; /** Catalogue id for `catalogueItem` / `catalogueItems` types. */ catalogueId?: string; /** Element type for `listOfValues`. */ itemType?: 'string' | 'int' | 'float'; default?: PropertyValue; description?: string; } export interface PropertyGroupDef { id: string; label: string; order: number; } export const PROPERTY_GROUPS: PropertyGroupDef[] = [ { id: 'general', label: 'General', order: 0 }, { id: 'presentation', label: 'Presentation', order: 1 }, { id: 'physics', label: 'Physics', order: 2 }, { id: 'simulation', label: 'Simulation results', order: 3 }, ]; export interface ValidationResult { ok: boolean; /** Value coerced to the canonical representation for the type. */ value: PropertyValue; error?: string; } const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function coerceNumber(raw: PropertyValue, integer: boolean): number | null { if (raw === null || raw === '') return null; const n = typeof raw === 'number' ? raw : Number(raw); if (typeof raw !== 'number' && typeof raw !== 'string') return null; if (!Number.isFinite(n)) return null; return integer ? Math.round(n) : n; } /** Validate and coerce a raw value against a property definition. */ export function validateValue(def: PropertyDef, raw: PropertyValue): ValidationResult { switch (def.type) { case 'string': { const value = raw === null ? '' : String(raw); return { ok: true, value }; } case 'int': case 'float': { const n = coerceNumber(raw, def.type === 'int'); if (n === null) { return { ok: false, value: raw, error: `"${def.label}" must be a number` }; } if (def.min !== undefined && n < def.min) { return { ok: false, value: n, error: `"${def.label}" must be ≥ ${def.min}` }; } if (def.max !== undefined && n > def.max) { return { ok: false, value: n, error: `"${def.label}" must be ≤ ${def.max}` }; } return { ok: true, value: n }; } case 'enum': { const value = String(raw ?? ''); const options = def.options ?? []; if (!options.some((o) => o.value === value)) { return { ok: false, value: raw, error: `"${value}" is not one of: ${options.map((o) => o.value).join(', ')}`, }; } return { ok: true, value }; } case 'catalogueItem': { if (raw === null || raw === '') return { ok: true, value: null }; return { ok: true, value: String(raw) }; } case 'catalogueItems': { if (raw === null) return { ok: true, value: [] }; const arr = Array.isArray(raw) ? raw.map(String) : [String(raw)]; return { ok: true, value: arr }; } case 'color': { const value = String(raw ?? ''); if (!COLOR_RE.test(value)) { return { ok: false, value: raw, error: `"${value}" is not a valid hex color` }; } return { ok: true, value }; } case 'listOfValues': { if (raw === null) return { ok: true, value: [] }; const arr = Array.isArray(raw) ? raw : [raw]; if (def.itemType === 'int' || def.itemType === 'float') { const nums: number[] = []; for (const item of arr) { const n = coerceNumber(item as PropertyValue, def.itemType === 'int'); if (n === null) { return { ok: false, value: raw, error: `"${item}" is not a number` }; } nums.push(n); } return { ok: true, value: nums }; } return { ok: true, value: arr.map(String) }; } } } /** Build the default property bag for a list of definitions. */ export function defaultValues(defs: PropertyDef[]): Record { const out: Record = {}; for (const def of defs) { if (def.default !== undefined) { out[def.key] = def.default; } else { switch (def.type) { case 'string': case 'color': out[def.key] = def.type === 'color' ? '#000000' : ''; break; case 'int': case 'float': out[def.key] = def.min ?? 0; break; case 'enum': out[def.key] = def.options?.[0]?.value ?? ''; break; case 'catalogueItem': out[def.key] = null; break; case 'catalogueItems': case 'listOfValues': out[def.key] = []; break; } } } return out; } /** Group property definitions by group id, ordered by PROPERTY_GROUPS. */ export function groupDefs(defs: PropertyDef[]): { group: PropertyGroupDef; defs: PropertyDef[] }[] { const byId = new Map(); for (const def of defs) { const list = byId.get(def.group) ?? []; list.push(def); byId.set(def.group, list); } const known = PROPERTY_GROUPS.filter((g) => byId.has(g.id)).sort((a, b) => a.order - b.order); const unknown = [...byId.keys()] .filter((id) => !PROPERTY_GROUPS.some((g) => g.id === id)) .map((id) => ({ id, label: id, order: 99 })); return [...known, ...unknown].map((group) => ({ group, defs: byId.get(group.id)! })); }