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>
This commit is contained in:
2026-10-04 09:12:38 +00:00
co-authored by Claude Opus 5.5
parent 6809a71300
commit 9252b942c4
26 changed files with 7692 additions and 33 deletions
+86 -9
View File
@@ -4,7 +4,8 @@
// in Java — no palette, no sheet, nothing moves — the cables that carry a
// frame light up, and a tap on a computer opens its desktop. Desktops left
// open stay over a return to the design and show again at the next start,
// as Java's do (GUIContainer.closeDialogs, reopenDialogs).
// as Java's do (GUIContainer.closeDialogs, reopenDialogs); so does the data
// exchange window with its tabs (F4.7).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
@@ -12,7 +13,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { addDevice, captureCards, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
import type { Point } from './canvas/viewport.js';
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
@@ -21,6 +22,7 @@ import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureWindow } from './simulation/CaptureWindow.js';
import { ModeBar } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
@@ -75,16 +77,31 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
/** A desktop open on the surface — or a switch's or home router's viewer (`viewer`) —: whose, where its window lies, and how far up (the last one touched on top). */
/** The data exchange window's place among the desktops (it is one window, with a tab per card). */
const CAPTURE_WINDOW = 'capture';
/**
* A desktop open on the surface — or a switch's or home router's viewer
* (`viewer`), or the data exchange window (`capture`) —: whose, where its
* window lies, and how far up (the last one touched on top).
*/
interface OpenDesktop {
readonly id: string;
readonly viewer?: ViewerKind;
readonly viewer?: ViewerKind | 'capture';
readonly x: number;
readonly y: number;
readonly z: number;
readonly program: DesktopWindowName | null;
}
/** A card's tab in the data exchange window: whose card, the frame chosen in it, whether it scrolls along (AggregatedMessageTable's own). */
interface OpenCaptureTab {
readonly node: string;
readonly port: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
const top = Math.max(0, ...open.map((each) => each.z));
@@ -138,13 +155,30 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
const fits = (each: OpenDesktop) => {
const kind = kinds.get(each.id);
if (each.viewer === 'capture') return true;
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
};
setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
}, [view]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => {
// The data exchange window's tabs (Java keeps them when the window is closed); a card that is gone loses its tab
// (Java: clearUnavailableComponents). Without a tab the window shows nothing; it keeps its place, as Java's dialog does.
const [captureTabs, setCaptureTabs] = useState<{ readonly tabs: readonly OpenCaptureTab[]; readonly selected: string | null }>({ tabs: [], selected: null });
const changeCaptureTab = useCallback(
(port: string, change: Partial<OpenCaptureTab>) => setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => (tab.port === port ? { ...tab, ...change } : tab)) })),
[],
);
useEffect(() => {
const cards = new Set(view.nodes.flatMap((node) => captureCards(doc, node.id).map((card) => `${node.id}/${card.portId}`)));
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => cards.has(`${tab.node}/${tab.port}`));
if (tabs.length === before.tabs.length) return before;
return { tabs, selected: tabs.some((tab) => tab.port === before.selected) ? before.selected : (tabs.at(-1)?.port ?? null) };
});
}, [view, doc]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or the data exchange window, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind | 'capture') => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
@@ -200,11 +234,25 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
[doc, origin, manager],
);
/** Shows a card's frames in the data exchange window: its tab chosen, added where it has none (AggregatedExchangePanel.addTable). */
const openCapture = useCallback(
(node: string, port: string) => {
setCaptureTabs((before) => ({ tabs: before.tabs.some((tab) => tab.port === port) ? before.tabs : [...before.tabs, { node, port, chosen: undefined, autoscroll: true }], selected: port }));
openDesktop(CAPTURE_WINDOW, 'capture');
},
[openDesktop],
);
const menuItems = (target: Selection): MenuItem[] => {
if (simulating) {
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7).
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address.
if (target.kind !== 'device') return [];
const node = view.nodes.find((each) => each.id === target.id);
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
const items: MenuItem[] = node?.kind === 'pc' || node?.kind === 'notebook' ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
for (const card of captureCards(doc, target.id)) {
items.push({ key: `showCapture-${card.portId}`, label: `${t('menu.showCapture')} (${card.ipAddress})`, onChoose: () => openCapture(target.id, card.portId) });
}
return items;
}
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
@@ -310,7 +358,36 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
</div>
{simulating
? desktops.map((desktop) =>
desktop.viewer !== undefined ? (
desktop.viewer === 'capture' ? (
captureTabs.selected === null ? null : (
<CaptureWindow
key={desktop.id}
tabs={captureTabs.tabs.map((tab) => ({ ...tab, title: captureCards(doc, tab.node).find((card) => card.portId === tab.port)?.title ?? '' }))}
selected={captureTabs.selected}
simulation={simulation}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onSelect={(port) => setCaptureTabs((before) => ({ ...before, selected: port }))}
onCloseTab={(port) =>
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => tab.port !== port);
return { tabs, selected: before.selected === port ? (tabs.at(-1)?.port ?? null) : before.selected };
})
}
onChoose={(port, number) => changeCaptureTab(port, { chosen: number })}
onAutoscroll={(port, autoscroll) => changeCaptureTab(port, { autoscroll })}
onClear={() => {
// Java's tables begin anew, with nothing chosen.
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
simulation.clearCapture();
}}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
)
) : desktop.viewer !== undefined ? (
<ViewerWindow
key={desktop.id}
kind={desktop.viewer}
+177
View File
@@ -1341,3 +1341,180 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The data exchange window (AggregatedExchangeDialog): Java's is three
* quarters of the screen wide and four fifths high; the tabs, a bar for what
* Java's right mouse button offers, the table (a row per frame in its layer's
* colour, Java's fonts by column) and the chosen frame's details below. */
.capture-window {
width: 960px;
height: 640px;
}
.capture-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px 0;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-default);
}
.capture-tab {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-default);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: var(--surface-sunken);
}
.capture-tab button {
min-height: 36px;
border: none;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.capture-tab button[aria-selected='true'] {
font-weight: 600;
}
.capture-tab:has(button[aria-selected='true']) {
background: var(--surface-card);
}
.capture-tab-close {
min-width: 36px;
color: var(--text-secondary) !important;
font-size: 18px !important;
}
.capture-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--surface-card);
color: var(--text-primary);
}
.capture-bar button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
.capture-bar label {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
}
.capture-bar input {
width: 20px;
height: 20px;
}
.capture-table {
flex: 3;
min-height: 0;
overflow: auto;
background: var(--surface-sunken);
}
.capture-table table {
width: 100%;
min-width: 760px;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 12px;
}
.capture-table th {
position: sticky;
top: 0;
padding: 6px;
background: var(--surface-raised);
color: var(--text-primary);
text-align: left;
font-weight: 600;
}
.capture-row {
color: var(--capture-on-layer);
cursor: pointer;
}
.capture-row td {
padding: 4px 6px;
white-space: nowrap;
}
/* Java's fonts: number and time plain, addresses and comment monospaced, the protocol bold, the layer bold italic. */
.capture-row td:nth-child(3),
.capture-row td:nth-child(4),
.capture-row td:nth-child(7) {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.capture-row td:nth-child(5) {
font-weight: 700;
}
.capture-row td:nth-child(6) {
font-weight: 700;
font-style: italic;
}
.capture-networkAccess {
background: var(--capture-network-access);
}
.capture-internet {
background: var(--capture-internet);
}
.capture-transport {
background: var(--capture-transport);
}
.capture-application {
background: var(--capture-application);
}
.capture-row[aria-selected='true'] {
background: var(--capture-chosen);
color: var(--capture-on-chosen);
}
.capture-row:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
/* MessageDetailsPanel: a tree in bold monospace, a long comment on lines of its own. */
.capture-details {
flex: 2;
min-height: 0;
margin: 0;
padding: 6px;
overflow: auto;
list-style: none;
border-top: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font: 700 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk",
"capture.application": "Anwendung",
"capture.autoscroll": "Automatisches Scrollen",
"capture.clear": "Tabellen löschen",
"capture.closeTab": "Reiter schließen",
"capture.comment": "Bemerkungen / Details",
"capture.destination": "Ziel",
"capture.export": "Als Textdatei exportieren",
"capture.internet": "Vermittlung",
"capture.layer": "Schicht",
"capture.macIs": "MAC ist",
"capture.networkAccess": "Netzzugang",
"capture.number": "Nr.",
"capture.protocol": "Protokoll",
"capture.protocolWord": "Protokoll",
"capture.searchForMac": "Suche nach MAC für",
"capture.source": "Quelle",
"capture.time": "Zeit",
"capture.title": "Datenaustausch",
"capture.transport": "Transport",
"connections.add": "Schnittstelle hinzufügen",
"connections.fewest": "Eine Schnittstelle bleibt immer.",
"connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.",
@@ -272,6 +291,7 @@
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
"menu.showCapture": "Datenaustausch anzeigen",
"menu.showDesktop": "Desktop anzeigen",
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
"mode.design": "Wechselt in den Entwurfsmodus",
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network",
"capture.application": "Application",
"capture.autoscroll": "Autoscroll",
"capture.clear": "Clear tables",
"capture.closeTab": "Close tab",
"capture.comment": "Comment / Details",
"capture.destination": "Destination",
"capture.export": "Export as text file",
"capture.internet": "Internet",
"capture.layer": "Layer",
"capture.macIs": "MAC is",
"capture.networkAccess": "Network",
"capture.number": "No.",
"capture.protocol": "Protocol",
"capture.protocolWord": "Protocol",
"capture.searchForMac": "Search for MAC",
"capture.source": "Source",
"capture.time": "Time",
"capture.title": "Data exchange",
"capture.transport": "Transport",
"connections.add": "Add interface",
"connections.fewest": "One interface always stays.",
"connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.",
@@ -272,6 +291,7 @@
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
"menu.showCapture": "Show data exchange",
"menu.showDesktop": "Show desktop",
"mode.currentSpeed": "Current Simulation Velocity",
"mode.design": "Change to Design Mode",
@@ -0,0 +1,182 @@
// The data exchange window while the simulation runs, after Java's
// AggregatedExchangeDialog (measured with tools/java-oracle's
// ExchangeWindowRun): a tab per card ("<label> - <address>", or the address
// where the label is it), each with a close button; the card's frames, one
// row per frame in its highest layer's colour (Java's LayerColorVar); the
// chosen frame's details below; „Tabellen löschen“ (every card's list),
// „Als Textdatei exportieren“ (named after the tab, as Java offers) and
// „Automatisches Scrollen“ (on, as in Java). The frames are the room's.
//
// Better than Java: the three are buttons and a box in a bar (Java's are in a
// menu of the right mouse button); a frame is chosen by its number (Java's
// details take the row's index for it); an empty comment is exported empty
// (Java writes "null").
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { TraceFrame } from '@mdg-filius/protocol';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
import type { Simulation } from './useSimulation.js';
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */
export interface CaptureTab {
readonly port: string;
readonly title: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
export interface CaptureWindowProps {
readonly tabs: readonly CaptureTab[];
readonly selected: string;
readonly simulation: Simulation;
readonly x: number;
readonly y: number;
readonly z: number;
readonly onSelect: (port: string) => void;
readonly onCloseTab: (port: string) => void;
readonly onChoose: (port: string, number: number) => void;
readonly onAutoscroll: (port: string, on: boolean) => void;
/** "Tabellen löschen": every card's frames, and what was chosen in them. */
readonly onClear: () => void;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
const FrameRow = memo(function FrameRow({
frame,
clock,
words,
chosen,
onChoose,
}: {
readonly frame: TraceFrame;
readonly clock: number;
readonly words: CaptureWords;
readonly chosen: boolean;
readonly onChoose: (number: number) => void;
}) {
const cells = frameCells(frame, clock, words);
const layer = frame.rows.at(-1)!.layer;
return (
<tr
className={`capture-row capture-${layer}`}
aria-selected={chosen}
tabIndex={0}
onClick={() => onChoose(frame.number)}
onKeyDown={(event) => (event.key === 'Enter' || event.key === ' ') && onChoose(frame.number)}
>
{cells.map((cell, column) => (
// Java's cell shows a line break as a space.
<td key={column}>{cell.replaceAll('\n', ' ')}</td>
))}
</tr>
);
});
export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose }: CaptureWindowProps) {
const t = useTranslate();
const language = useLanguage();
const words = useMemo(() => captureWords(t, language), [t, language]);
const { watch, clock } = simulation;
useEffect(() => watch([`trace:${selected}`]), [watch, selected]);
const frames = simulation.traces.get(selected) ?? [];
const scroller = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
const title = t('capture.title');
const tab = tabs.find((each) => each.port === selected);
const chosenFrame = frames.find((frame) => frame.number === tab?.chosen);
const autoscroll = tab?.autoscroll ?? true;
// New frames scroll the table to its end while "Automatisches Scrollen" is on.
useEffect(() => {
if (autoscroll && scroller.current !== null) scroller.current.scrollTop = scroller.current.scrollHeight;
}, [autoscroll, frames.length, selected]);
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
const download = () => {
const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `${tab?.title ?? title}.txt`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
return (
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="capture-tabs" role="tablist">
{tabs.map((each) => (
<span key={each.port} className="capture-tab">
<button type="button" role="tab" aria-selected={each.port === selected} onClick={() => onSelect(each.port)}>
{each.title}
</button>
<button type="button" className="capture-tab-close" aria-label={`${t('capture.closeTab')}: ${each.title}`} title={t('capture.closeTab')} onClick={() => onCloseTab(each.port)}>
×
</button>
</span>
))}
</div>
<div className="capture-bar">
<button type="button" onClick={onClear}>
{t('capture.clear')}
</button>
<button type="button" onClick={download}>
{t('capture.export')}
</button>
<label>
<input type="checkbox" checked={autoscroll} onChange={(event) => onAutoscroll(selected, event.target.checked)} />
{t('capture.autoscroll')}
</label>
</div>
<div className="capture-table" ref={scroller}>
<table aria-label={tab?.title ?? title}>
<thead>
<tr>
{words.header.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{frames.map((frame) => (
<FrameRow key={frame.number} frame={frame} clock={clock} words={words} chosen={frame === chosenFrame} onChoose={choose} />
))}
</tbody>
</table>
</div>
<ul className="capture-details" aria-label={words.detail.comment}>
{chosenFrame === undefined
? null
: frameDetails(chosenFrame, clock, words).map((line, index) => (
<li key={index} style={{ paddingLeft: 4 + 16 * line.depth }}>
{line.text}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,71 @@
// What the data exchange window writes of a frame, in the viewer's words: the
// seven cells of its row (Java's columns: number, time, source, destination,
// protocol, layer, comment), its details, the table as a text file. The
// rules are the core's (captureWindow.ts, measured against Java); the words,
// the number format and the time of day are the viewer's, as Java's are its
// locale's and its wall clock's.
import {
captureCommentText,
captureDetailLines,
exchangeExportText,
exchangeRow,
type CaptureCommentWords,
type CaptureDetailLine,
type CaptureDetailWords,
type CaptureLayer,
type CaptureRow,
} from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { timeOfDay } from './timeOfDay.js';
export interface CaptureWords {
readonly header: readonly string[];
readonly layers: Readonly<Record<CaptureLayer, string>>;
readonly comment: CaptureCommentWords;
readonly detail: CaptureDetailWords;
/** The locale's numbers (Java: NumberFormat of its locale, "1.000.000" in German). */
readonly formatNumber: (value: number) => string;
}
/** The words of the editor's catalog in its language. */
export function captureWords(t: (key: string) => string, language: string): CaptureWords {
const header = ['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`));
const numbers = new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-GB');
return {
header,
layers: { networkAccess: t('capture.networkAccess'), internet: t('capture.internet'), transport: t('capture.transport'), application: t('capture.application') },
comment: { protocol: t('capture.protocolWord'), searchForMac: t('capture.searchForMac'), macIs: t('capture.macIs') },
detail: { number: header[0]!, time: header[1]!, source: header[2]!, destination: header[3]!, protocol: header[4]!, comment: header[6]! },
formatNumber: (value) => numbers.format(value),
};
}
const commentOf = (row: CaptureRow, words: CaptureWords) => captureCommentText(row.comment, words.comment, words.formatNumber);
/** The frame's row, its highest layer's, as seven cells; the comment as it is (with its line breaks). */
export function frameCells(frame: TraceFrame, clock: number, words: CaptureWords): string[] {
const row = exchangeRow(frame.rows);
return [String(row.number), timeOfDay(clock, row.timeMilliseconds, true), row.source ?? '', row.destination ?? '', row.protocol, words.layers[row.layer], commentOf(row, words)];
}
/** The frame's details, from its layered rows. */
export function frameDetails(frame: TraceFrame, clock: number, words: CaptureWords): CaptureDetailLine[] {
const first = frame.layeredRows[0];
if (first === undefined) return [];
return captureDetailLines(
frame.layeredRows,
timeOfDay(clock, first.timeMilliseconds, true),
words.detail,
(row) => words.layers[row.layer],
(row) => commentOf(row, words),
);
}
/** The card's table as Java's text file. */
export function exportText(frames: readonly TraceFrame[], clock: number, words: CaptureWords): string {
return exchangeExportText(
words.header,
frames.map((frame) => frameCells(frame, clock, words)),
);
}
@@ -1,12 +1,13 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs, the lines
// and views of the programs' windows and the actions taken in them. The room
// and views of the programs' windows and the actions taken in them, the
// cards' frames for the data exchange window. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
@@ -44,12 +45,16 @@ export interface Simulation {
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
readonly dropped: readonly DroppedUnit[];
/** Each watched card's frames as the data exchange window shows them, by its port. */
readonly traces: ReadonlyMap<string, readonly TraceFrame[]>;
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void;
/** Java's "Tabellen löschen": every card's frames and the dropped ones are forgotten. */
clearCapture(): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */
@@ -70,6 +75,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
const [traces, setTraces] = useState<ReadonlyMap<string, readonly TraceFrame[]>>(new Map());
useEffect(() => {
const made = createHost(doc);
@@ -97,6 +103,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'trace':
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
return;
case 'dropped':
setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
return;
@@ -125,6 +134,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []);
const clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []);
const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
@@ -147,5 +157,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, start, stop, setSpeed, drop, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, act };
}
+15
View File
@@ -37,6 +37,14 @@
--connection-plugged: rgb(0 255 0);
--connection-free: rgb(255 0 0);
--connection-marked: rgb(255 255 0);
/* Java's layer colours of the data exchange window (LayerColorVar), black text; a chosen row white on black. */
--capture-network-access: #c9c9c9;
--capture-internet: #8faadc;
--capture-transport: #f4b183;
--capture-application: #ffd966;
--capture-on-layer: #000000;
--capture-chosen: #000000;
--capture-on-chosen: #ffffff;
}
:root[data-theme='dark'] {
@@ -71,6 +79,13 @@
--connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0);
--capture-network-access: #3a3f47;
--capture-internet: #2c4369;
--capture-transport: #6b4128;
--capture-application: #5f4f17;
--capture-on-layer: #e6e9ef;
--capture-chosen: #e6e9ef;
--capture-on-chosen: #14171c;
}
html,