Files
mdg-filius/packages/doc/src/view.ts
T
julian.kiedaischandClaude Opus 5.5 9252b942c4 feat: F4.7 the data exchange window, measured against Java's
One row per frame (its highest layer's), Java's detail tree, the table as
Java's text file, "Tabellen löschen" for every card (clearCapture in the
room), a tab per card with Java's title, Java's layer colours as tokens,
the chosen frame and autoscroll per tab. New oracle ExchangeWindowRun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:38 +00:00

188 lines
8.5 KiB
TypeScript

// 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 InstallableProgram, 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; any other device's name. */
export function nodeLabel(node: Y.Map<unknown>): string {
const kind = node.get('kind') as NodeKind;
const name = node.get('name') as 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 address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
export function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
const text = (map: Y.Map<unknown> | undefined) => (map?.get('ipAddress') as string | undefined) ?? '';
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(settingsOf(node)?.get('device'));
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined);
}
default:
return null;
}
}
/** A card whose frames the data exchange window shows: its port, address and the title of its tab. */
export interface CaptureCard {
readonly portId: string;
readonly ipAddress: string;
readonly title: string;
}
/**
* A device's cards in the order of its ports, as Java's menu offers their
* data exchange (GUIEvents.kontextMenueAktionsmodus); empty for a device
* without cards. A tab's title is the card's address where the label is the
* address (Host.isUseIPAsName), else the label, " - " and the address
* (AggregatedExchangePanel.updateTabTitle).
*/
export function captureCards(doc: Y.Doc, id: string): CaptureCard[] {
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
if (node === undefined) return [];
const policy = node.get('labelPolicy') as HostLabelPolicy;
const kind = node.get('kind') as NodeKind;
const addressAsName = (kind === 'pc' || kind === 'notebook') && (policy === 'ipAddress' || policy === 'ipAndMAC');
return (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().flatMap((port) => {
const portId = port.get('id') as string;
const ipAddress = cardAddress(node, portId);
if (ipAddress === null) return [];
return [{ portId, ipAddress, title: addressAsName ? ipAddress : `${nodeLabel(node)} - ${ipAddress}` }];
});
}
/** 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: nodeLabel(node),
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 };
}
/** What a computer's desktop needs of the document (Java's GUIDesktopWindow, GUIDesktopPanel). */
export interface DesktopView {
readonly kind: 'pc' | 'notebook';
/** Its label (Host.holeAnzeigeName). */
readonly label: string;
/** Whether the label is its address: Java's title then leaves the address out (isUseIPAsName). */
readonly addressAsName: boolean;
readonly programs: readonly InstallableProgram[];
}
/** A PC's or notebook's desktop; undefined for any other device. */
export function desktopView(doc: Y.Doc, id: string): DesktopView | undefined {
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
const kind = node?.get('kind') as NodeKind | undefined;
if (node === undefined || (kind !== 'pc' && kind !== 'notebook')) return undefined;
const policy = node.get('labelPolicy') as HostLabelPolicy;
return {
kind,
label: nodeLabel(node),
addressAsName: policy === 'ipAddress' || policy === 'ipAndMAC',
programs: [...(node.get('programs') as Y.Map<unknown>).keys()] as InstallableProgram[],
};
}