191 lines
5.9 KiB
TypeScript
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)! }));
|
|
}
|