feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written

packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's
default hardware icons, the device button of JSidebarButton, cables as
JCablePanel's quadratic curve between the buttons' middles, wireless cables
dashed, documentation items below. The surface moves with mouse and finger
(the pointer captured only once it moved) and zooms with Ctrl and the
wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows
all. German and English catalogs with Java's device names, the dashboard's
colour tokens, the bridge with its origin check. packages/doc gets the
drawing's light view of a document.

Java's Switch.cloud (the Internet in the examples) was lost on reading; it
is now read, kept in the model and written, with FlsOracle and the corpus,
written and exported checks renewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:05:25 +00:00
co-authored by Claude Opus 5.5
parent 0fd42f10e2
commit 63273916b5
49 changed files with 2442 additions and 8 deletions
+1
View File
@@ -3,3 +3,4 @@ export * from './janitor.js';
export * from './projectDoc.js';
export * from './snapshot.js';
export * from './values.js';
export * from './view.js';
+117
View File
@@ -0,0 +1,117 @@
// What the editor draws, read straight from the document: the devices with
// their labels, the cables, the documentation items. Lighter than
// readProject (no file systems, no settings beyond the few a drawing needs),
// so that it can run on every change while someone drags a device.
import * as Y from 'yjs';
import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core';
import { inOrder, ROOTS } from './projectDoc.js';
export interface NodeView {
readonly id: string;
readonly kind: NodeKind;
readonly name: string;
/** What Java writes under the icon (Host.holeAnzeigeName): the name, or the first card's address. */
readonly label: string;
/** Java's position of the device's button: its top left corner. */
readonly x: number;
readonly y: number;
/** A switch drawn as a cloud. */
readonly cloud: boolean;
readonly ports: readonly { readonly id: string; readonly label: string }[];
}
export interface LinkView {
readonly id: string;
readonly sourceNode: string;
readonly sourcePort: string;
readonly targetNode: string;
readonly targetPort: string;
/** A host's wireless card joining a switch: Java draws it dashed and grey. */
readonly wireless: boolean;
}
export interface DocumentationView {
readonly id: string;
readonly kind: DocumentationItemKind;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly text: string;
/** Components 0 to 1. */
readonly color: { readonly red: number; readonly green: number; readonly blue: number; readonly alpha: number };
readonly font: string;
readonly size: number;
readonly bold: boolean;
readonly order: number;
}
export interface TopologyView {
readonly nodes: readonly NodeView[];
readonly links: readonly LinkView[];
readonly docs: readonly DocumentationView[];
}
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<Y.Map<unknown>> | undefined;
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks. */
function labelOf(node: Y.Map<unknown>, kind: NodeKind, name: string): string {
const policy = node.get('labelPolicy') as HostLabelPolicy;
if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name;
const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? '';
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).get(0);
const mac = port === undefined ? '' : ((port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(port.get('id') as string));
if (policy === 'ipAddress') return ip;
if (policy === 'macAddress') return mac;
return `${ip} (${mac})`;
}
/** The drawing of the document, in the order of its devices and cables. */
export function topologyView(doc: Y.Doc): TopologyView {
const kinds = new Map<string, NodeKind>();
const wirelessHosts = new Set<string>();
const nodes = inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes)).map(([id, node]): NodeView => {
const kind = node.get('kind') as NodeKind;
const name = node.get('name') as string;
kinds.set(id, kind);
if (settingsOf(node)?.get('wireless')?.get('isEnabled') === true && (kind === 'pc' || kind === 'notebook')) wirelessHosts.add(id);
return {
id,
kind,
name,
label: labelOf(node, kind, name),
x: node.get('x') as number,
y: node.get('y') as number,
cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true,
ports: (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => ({ id: port.get('id') as string, label: port.get('label') as string })),
};
});
// exportProject's joinsWirelessly: a host with a wireless card and a switch.
const wireless = (first: string, second: string) => (wirelessHosts.has(first) && kinds.get(second) === 'networkSwitch') || (wirelessHosts.has(second) && kinds.get(first) === 'networkSwitch');
const links = inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.links)).map(([id, link]): LinkView => {
const sourceNode = link.get('sourceNode') as string;
const targetNode = link.get('targetNode') as string;
return { id, sourceNode, sourcePort: link.get('sourcePort') as string, targetNode, targetPort: link.get('targetPort') as string, wireless: wireless(sourceNode, targetNode) };
});
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(
([id, item]): DocumentationView => ({
id,
kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number,
y: item.get('y') as number,
width: item.get('width') as number,
height: item.get('height') as number,
text: (item.get('text') as Y.Text).toString(),
color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number },
font: item.get('font') as string,
size: item.get('size') as number,
bold: item.get('bold') as boolean,
order: item.get('order') as number,
}),
);
// The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id.
const layer = (item: DocumentationView) => (item.kind === 'rectangle' ? 0 : 1);
docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id));
return { nodes, links, docs };
}
+72
View File
@@ -0,0 +1,72 @@
// The drawing of a document against the project it holds, for every file of
// the corpus: the same devices in the same order at the same places, Java's
// labels, the cables as the export draws them (wireless or not), the
// documentation items in the model's render order.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { stableGeneratedMacAddress } from '@mdg-filius/core';
import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import { readProject, topologyView, writeProject } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) =>
readdirSync(join(corpus, folder))
.filter((name) => name.endsWith('.fls'))
.map((name) => join(folder, name)),
);
function docOf(file: string, change: (project: ImportedProject) => void = () => {}): Y.Doc {
let counter = 0;
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(0xffff_ffff - ++counter).toString(16).padStart(12, '0')}`);
change(project);
const doc = new Y.Doc();
writeProject(doc, project);
return doc;
}
describe('the drawing of a document', () => {
for (const file of files) {
it(file, () => {
const doc = docOf(file);
const project = readProject(doc);
const view = topologyView(doc);
expect(view.nodes.map((node) => [node.id, node.kind, node.name, node.x, node.y])).toEqual(
project.graph.nodes.map((node) => [node.id, node.kind, node.displayName, node.position.x, node.position.y]),
);
expect(view.nodes.map((node) => node.ports)).toEqual(project.graph.nodes.map((node) => node.ports.map((port) => ({ id: port.id, label: port.label }))));
expect(view.links.map((link) => [link.id, link.sourceNode, link.sourcePort, link.targetNode, link.targetPort])).toEqual(
project.graph.links.map((link) => [link.id, link.sourceNodeId, link.sourcePortId, link.targetNodeId, link.targetPortId]),
);
expect(view.links.map((link) => link.wireless)).toEqual(exportProject(project).topology.links.map((link) => link.wireless));
expect(view.docs.map(({ id, kind, x, y, width, height, text, color, font, size, bold, order }) => ({ id, kind, frame: { x, y, width, height }, text, color, fontName: font, fontSize: size, isBold: bold, order }))).toEqual(project.documentationItems);
});
}
it('draws the Internet of the NAT example as a cloud', () => {
const view = topologyView(docOf('java-examples/nat.fls'));
expect(view.nodes.filter((node) => node.cloud).map((node) => node.name)).toEqual(['Internet']);
});
it("writes a host's label as Java does: its name, address, MAC address or both", () => {
const labels = (policy: 'manual' | 'ipAddress' | 'macAddress' | 'ipAndMAC') => {
const doc = docOf('java-examples/zwei_rechnernetze_komplett.fls', (project) => {
for (const node of project.graph.nodes) node.hostLabelPolicy = policy;
});
const project = readProject(doc);
const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!;
const view = topologyView(doc);
return { label: view.nodes.find((node) => node.id === notebook.id)!.label, mac: notebook.ports[0]!.importedMacAddress ?? stableGeneratedMacAddress(notebook.ports[0]!.id), switchLabel: view.nodes.find((node) => node.kind === 'networkSwitch')!.label };
};
expect(labels('manual').label).toBe('Notebook 1.10');
expect(labels('ipAddress').label).toBe('141.99.1.10');
const { label, mac, switchLabel } = labels('ipAndMAC');
expect(label).toBe(`141.99.1.10 (${mac})`);
expect(labels('macAddress').label).toBe(mac);
// Only hosts show an address (Host.holeAnzeigeName).
expect(switchLabel).toBe('Switch 1');
});
});