// 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) => node.get('settings') as Y.Map> | 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): 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>).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, portId: string): string | null { const text = (map: Y.Map | 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>).toArray().find((each) => each.get('id') === portId); return text(port?.get('config') as Y.Map | 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>(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>).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(); const wirelessHosts = new Set(); const nodes = inOrder(doc.getMap>(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>).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>(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>(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>(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).keys()] as InstallableProgram[], }; }