2026-07-02 23:06:47 +02:00

191 lines
5.9 KiB
TypeScript

/**
* 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<string, PropertyValue> {
const out: Record<string, PropertyValue> = {};
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<string, PropertyDef[]>();
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)! }));
}