246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
import type { Interface, Widget, SignalRef, PlotLayout, StateVar, LogicGraph } from './types';
|
|
|
|
/** Escape a string for use as an XML attribute value. */
|
|
function xmlEsc(s: string): string {
|
|
return s
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
/** Serialize a panel-logic flow graph to indented XML lines. */
|
|
function serializeLogic(graph: LogicGraph, lines: string[]): void {
|
|
lines.push(` <logic>`);
|
|
for (const node of graph.nodes) {
|
|
lines.push(
|
|
` <node id="${xmlEsc(node.id)}" kind="${xmlEsc(node.kind)}" ` +
|
|
`x="${node.x}" y="${node.y}">`
|
|
);
|
|
for (const [key, value] of Object.entries(node.params)) {
|
|
lines.push(` <param key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
|
}
|
|
lines.push(` </node>`);
|
|
}
|
|
for (const wire of graph.wires) {
|
|
const portAttr = wire.fromPort && wire.fromPort !== 'out'
|
|
? ` port="${xmlEsc(wire.fromPort)}"` : '';
|
|
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
|
|
}
|
|
lines.push(` </logic>`);
|
|
}
|
|
|
|
/** Serialize a PlotLayout subtree to indented XML lines. */
|
|
function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void {
|
|
if (node.type === 'leaf') {
|
|
lines.push(`${indent}<leaf widget="${xmlEsc(node.widget)}"/>`);
|
|
return;
|
|
}
|
|
lines.push(`${indent}<split dir="${node.dir}" ratio="${node.ratio}">`);
|
|
serializeLayout(node.a, indent + ' ', lines);
|
|
serializeLayout(node.b, indent + ' ', lines);
|
|
lines.push(`${indent}</split>`);
|
|
}
|
|
|
|
/** Serialize an Interface object to an XML string. */
|
|
export function serializeInterface(iface: Interface): string {
|
|
const lines: string[] = [];
|
|
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
|
const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : '';
|
|
lines.push(
|
|
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
|
|
`version="${iface.version}" width="${iface.w}" height="${iface.h}"${kindAttr}>`
|
|
);
|
|
if (iface.kind === 'plot' && iface.layout) {
|
|
lines.push(` <layout>`);
|
|
serializeLayout(iface.layout, ' ', lines);
|
|
lines.push(` </layout>`);
|
|
}
|
|
for (const sv of iface.statevars ?? []) {
|
|
const attrs = [
|
|
`name="${xmlEsc(sv.name)}"`,
|
|
`type="${xmlEsc(sv.type ?? 'number')}"`,
|
|
`initial="${xmlEsc(sv.initial ?? '')}"`,
|
|
];
|
|
if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`);
|
|
if (sv.low !== undefined) attrs.push(`low="${sv.low}"`);
|
|
if (sv.high !== undefined) attrs.push(`high="${sv.high}"`);
|
|
lines.push(` <statevar ${attrs.join(' ')}/>`);
|
|
}
|
|
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
|
|
serializeLogic(iface.logic, lines);
|
|
}
|
|
for (const w of iface.widgets) {
|
|
lines.push(
|
|
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
|
|
`x="${w.x}" y="${w.y}" w="${w.w}" h="${w.h}">`
|
|
);
|
|
for (const sig of w.signals) {
|
|
const colorAttr = sig.color ? ` color="${xmlEsc(sig.color)}"` : '';
|
|
lines.push(` <signal ds="${xmlEsc(sig.ds)}" name="${xmlEsc(sig.name)}"${colorAttr}/>`);
|
|
}
|
|
for (const [key, value] of Object.entries(w.options)) {
|
|
lines.push(` <option key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
|
}
|
|
lines.push(` </widget>`);
|
|
}
|
|
lines.push(`</interface>`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Parse an interface XML string into an Interface object.
|
|
*
|
|
* Expected XML format:
|
|
* <interface name="..." version="1">
|
|
* <widget id="w1" type="textview" x="100" y="50" w="200" h="60">
|
|
* <signal ds="stub" name="sine_1hz" color="#ff0000"/>
|
|
* <option key="label" value="Sine 1Hz"/>
|
|
* </widget>
|
|
* </interface>
|
|
*/
|
|
/** Parse a <split>/<leaf> element into a PlotLayout node. */
|
|
function parseLayout(el: Element): PlotLayout {
|
|
if (el.tagName === 'leaf') {
|
|
return { type: 'leaf', widget: el.getAttribute('widget') ?? '' };
|
|
}
|
|
// split
|
|
const dir = el.getAttribute('dir') === 'v' ? 'v' : 'h';
|
|
const ratio = parseFloat(el.getAttribute('ratio') ?? '0.5');
|
|
const children = Array.from(el.children).filter(
|
|
c => c.tagName === 'split' || c.tagName === 'leaf'
|
|
);
|
|
const a = children[0] ? parseLayout(children[0]) : { type: 'leaf', widget: '' } as PlotLayout;
|
|
const b = children[1] ? parseLayout(children[1]) : { type: 'leaf', widget: '' } as PlotLayout;
|
|
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
|
|
}
|
|
|
|
/** Parse a <logic> element into a LogicGraph (nodes + wires). */
|
|
function parseLogic(logicEl: Element): LogicGraph {
|
|
const nodes: LogicGraph['nodes'] = [];
|
|
const wires: LogicGraph['wires'] = [];
|
|
|
|
for (const el of Array.from(logicEl.children)) {
|
|
if (el.tagName === 'node') {
|
|
const id = el.getAttribute('id') ?? '';
|
|
if (!id) continue;
|
|
const params: Record<string, string> = {};
|
|
for (const child of Array.from(el.children)) {
|
|
if (child.tagName !== 'param') continue;
|
|
const key = child.getAttribute('key');
|
|
const value = child.getAttribute('value');
|
|
if (key !== null && value !== null) params[key] = value;
|
|
}
|
|
nodes.push({
|
|
id,
|
|
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
|
|
x: parseFloat(el.getAttribute('x') ?? '0'),
|
|
y: parseFloat(el.getAttribute('y') ?? '0'),
|
|
params,
|
|
});
|
|
} else if (el.tagName === 'wire') {
|
|
const from = el.getAttribute('from');
|
|
const to = el.getAttribute('to');
|
|
const port = el.getAttribute('port');
|
|
if (from && to) wires.push({ from, to, ...(port ? { fromPort: port } : {}) });
|
|
}
|
|
}
|
|
|
|
return { nodes, wires };
|
|
}
|
|
|
|
export function parseInterface(xml: string): Interface {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xml, 'application/xml');
|
|
|
|
// Check for parse errors
|
|
const parseError = doc.querySelector('parsererror');
|
|
if (parseError) {
|
|
throw new Error(`XML parse error: ${parseError.textContent}`);
|
|
}
|
|
|
|
const root = doc.documentElement;
|
|
if (root.tagName !== 'interface') {
|
|
throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
|
|
}
|
|
|
|
const ifaceId = root.getAttribute('id') ?? '';
|
|
const name = root.getAttribute('name') ?? 'Untitled';
|
|
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
|
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
|
|
const h = parseInt(root.getAttribute('height') ?? '800', 10);
|
|
const kind = root.getAttribute('kind') === 'plot' ? 'plot' : undefined;
|
|
|
|
let layout: PlotLayout | undefined;
|
|
if (kind === 'plot') {
|
|
const layoutEl = Array.from(root.children).find(el => el.tagName === 'layout');
|
|
const rootNode = layoutEl
|
|
? Array.from(layoutEl.children).find(el => el.tagName === 'split' || el.tagName === 'leaf')
|
|
: undefined;
|
|
if (rootNode) layout = parseLayout(rootNode);
|
|
}
|
|
|
|
const statevars: StateVar[] = [];
|
|
for (const el of Array.from(root.children)) {
|
|
if (el.tagName !== 'statevar') continue;
|
|
const name = el.getAttribute('name') ?? '';
|
|
if (!name) continue;
|
|
const type = el.getAttribute('type');
|
|
const low = el.getAttribute('low');
|
|
const high = el.getAttribute('high');
|
|
const unit = el.getAttribute('unit');
|
|
statevars.push({
|
|
name,
|
|
type: type === 'bool' || type === 'string' ? type : 'number',
|
|
initial: el.getAttribute('initial') ?? '',
|
|
...(unit ? { unit } : {}),
|
|
...(low !== null ? { low: parseFloat(low) } : {}),
|
|
...(high !== null ? { high: parseFloat(high) } : {}),
|
|
});
|
|
}
|
|
|
|
const logicEl = Array.from(root.children).find(el => el.tagName === 'logic');
|
|
const logic = logicEl ? parseLogic(logicEl) : null;
|
|
|
|
const widgets: Widget[] = [];
|
|
|
|
for (const el of Array.from(root.children)) {
|
|
if (el.tagName !== 'widget') continue;
|
|
|
|
const id = el.getAttribute('id') ?? '';
|
|
const type = el.getAttribute('type') ?? 'unknown';
|
|
const x = parseFloat(el.getAttribute('x') ?? '0');
|
|
const y = parseFloat(el.getAttribute('y') ?? '0');
|
|
const w = parseFloat(el.getAttribute('w') ?? '100');
|
|
const h = parseFloat(el.getAttribute('h') ?? '60');
|
|
|
|
const signals: Array<SignalRef & { color?: string }> = [];
|
|
const options: Record<string, string> = {};
|
|
|
|
for (const child of Array.from(el.children)) {
|
|
if (child.tagName === 'signal') {
|
|
const ds = child.getAttribute('ds') ?? '';
|
|
const sigName = child.getAttribute('name') ?? '';
|
|
const color = child.getAttribute('color') ?? undefined;
|
|
signals.push({ ds, name: sigName, ...(color ? { color } : {}) });
|
|
} else if (child.tagName === 'option') {
|
|
const key = child.getAttribute('key');
|
|
const value = child.getAttribute('value');
|
|
if (key !== null && value !== null) {
|
|
options[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
widgets.push({ id, type, x, y, w, h, signals, options });
|
|
}
|
|
|
|
return {
|
|
id: ifaceId, name, version, w, h, widgets,
|
|
...(kind ? { kind } : {}),
|
|
...(layout ? { layout } : {}),
|
|
...(statevars.length > 0 ? { statevars } : {}),
|
|
...(logic && (logic.nodes.length > 0 || logic.wires.length > 0) ? { logic } : {}),
|
|
};
|
|
}
|