feat: F4.8c the documentation mode's image export, as Java's
ExportRun measures Java's print panel (GUIContainer.prepareExportPanel) and image on four projects and an empty one: 10 to spare around, a light grey footer 20 below the lowest element, 20 below it, a transparent background, the documentation painted as in the design. The sidebar gains Java's "Exportieren": the surface painted with Canvas 2D (an SVG with a foreignObject taints the canvas in WebKit) and saved under the project's name; Java's message when saving fails. Not adopted: Java's empty image of an empty project (its footer overflows). The editor takes a projectName; the capture export shares the download helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,13 @@
|
||||
// pointer), moved, resized to Java's smallest size, coloured from Java's
|
||||
// list; the sidebar's text tapped into the view, typed into, made large and
|
||||
// italic; Java's paint in the documentation mode and in the design; Delete;
|
||||
// a simulation started ends the mode; and a phone.
|
||||
// a simulation started ends the mode; „Exportieren“ (F4.8c): the image
|
||||
// saved under the project's name, cut and painted as Java's, and Java's
|
||||
// message when saving fails; and a phone.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
|
||||
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
@@ -50,7 +54,7 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
||||
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
|
||||
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
|
||||
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
|
||||
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche']);
|
||||
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren']);
|
||||
|
||||
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
|
||||
const surface = (await canvas.boundingBox())!;
|
||||
@@ -159,11 +163,84 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
||||
await expect(page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test("exports the surface as Java's image, under the project's name", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
const button = page.getByRole('navigation', { name: 'Dokumentation' }).getByRole('button', { name: 'Exportieren' });
|
||||
await expect(button).toHaveAttribute('title', 'Als Bild exportieren');
|
||||
const saved = page.waitForEvent('download');
|
||||
await button.click();
|
||||
const download = await saved;
|
||||
expect(download.suggestedFilename()).toBe('nat_with_mapped_port.png');
|
||||
// Ready for the next one.
|
||||
await expect(button).toBeEnabled();
|
||||
const base64 = readFileSync((await download.path())!).toString('base64');
|
||||
|
||||
// What it should show, from the surface: every device's button and every documentation element, 10 around, the footer 20 below.
|
||||
const boxes = await page.locator('g.device').evaluateAll((devices) => devices.map((device) => device.getAttribute('data-box')!.split(' ').map(Number)));
|
||||
const docs = (await drawing(page)).docs;
|
||||
const parts = [...boxes.map(([x, y, width, height]) => ({ x: x!, y: y!, width: width!, height: height! })), ...docs];
|
||||
const left = Math.min(...parts.map((part) => part.x));
|
||||
const top = Math.min(...parts.map((part) => part.y));
|
||||
const right = Math.max(...parts.map((part) => part.x + part.width));
|
||||
const bottom = Math.max(...parts.map((part) => part.y + part.height));
|
||||
const footerWidth = await page.evaluate(
|
||||
({ font, text }) => {
|
||||
const context = document.createElement('canvas').getContext('2d')!;
|
||||
context.font = font;
|
||||
return Math.round(context.measureText(text).width) + 20;
|
||||
},
|
||||
{ font: JAVA_LABEL_FONT, text: 'nat_with_mapped_port.fls' },
|
||||
);
|
||||
const rectangle = docs.find((item) => item.kind === 'rectangle')!;
|
||||
const image = await page.evaluate(
|
||||
async ({ base64, rectangle, footer }) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const pixel = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data];
|
||||
const footerPixels = context.getImageData(footer.x, footer.y, footer.width, footer.height).data;
|
||||
let grey = 0;
|
||||
for (let index = 0; index < footerPixels.length; index += 4) {
|
||||
if (footerPixels[index] === 192 && footerPixels[index + 1] === 192 && footerPixels[index + 2] === 192 && footerPixels[index + 3] === 255) grey += 1;
|
||||
}
|
||||
return { width: bitmap.width, height: bitmap.height, corner: pixel(0, 0), rectangle: pixel(rectangle.x, rectangle.y), grey };
|
||||
},
|
||||
{
|
||||
base64,
|
||||
rectangle: { x: rectangle.x + 8 - (left - 10), y: rectangle.y + 8 - (top - 10) },
|
||||
footer: { x: 10, y: bottom + 20 - (top - 10), width: footerWidth, height: 17 },
|
||||
},
|
||||
);
|
||||
expect([image.width, image.height]).toEqual([Math.max(right, left + 10 + footerWidth) + 10 - (left - 10), bottom + 20 + 17 + 20 - (top - 10)]);
|
||||
// Transparent around; a grey rectangle (204) as the design paints it, a fifth of the way to white; the footer light grey.
|
||||
expect(image.corner).toEqual([0, 0, 0, 0]);
|
||||
expect(image.rectangle).toEqual([215, 215, 215, 255]);
|
||||
expect(image.grey).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test("shows Java's message when the image cannot be saved", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
await page.evaluate(() => {
|
||||
HTMLCanvasElement.prototype.toBlob = function (callback: BlobCallback) {
|
||||
callback(null);
|
||||
};
|
||||
});
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
const button = page.getByRole('button', { name: 'Exportieren' });
|
||||
await button.click();
|
||||
await expect(page.getByRole('alert')).toHaveText('Fehler beim Speichern aufgetreten!');
|
||||
await expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
test('keeps the documentation mode within reach on a phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByRole('button', { name: 'Exportieren' })).toBeInViewport({ ratio: 1 });
|
||||
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
|
||||
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
|
||||
import { DocumentationMenu } from './design/DocumentationMenu.js';
|
||||
import { DocumentationSidebar } from './design/DocumentationSidebar.js';
|
||||
import { DocumentationText } from './design/DocumentationText.js';
|
||||
import { networkImage } from './design/exportImage.js';
|
||||
import { exportNames } from './canvas/networkImage.js';
|
||||
import { saveFile } from './download.js';
|
||||
import { Palette, type PaletteKind } from './design/Palette.js';
|
||||
import { RouterDialog } from './design/RouterDialog.js';
|
||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
@@ -132,7 +135,14 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
|
||||
return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each));
|
||||
};
|
||||
|
||||
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
|
||||
export interface EditorProps {
|
||||
readonly doc: Y.Doc;
|
||||
readonly createSimHost?: SimHostFactory;
|
||||
/** The project's name (its file's), for what is exported. */
|
||||
readonly projectName?: string;
|
||||
}
|
||||
|
||||
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useTopologyView(doc);
|
||||
@@ -281,6 +291,12 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
setSelection({ kind: 'doc', id });
|
||||
};
|
||||
|
||||
/** „Exportieren“: the surface as Java's image, saved under the project's name. */
|
||||
const exportImage = async () => {
|
||||
const names = exportNames(projectName);
|
||||
saveFile(await networkImage(view, names.footer), names.file);
|
||||
};
|
||||
|
||||
const changeMode = (mode: EditorMode) => {
|
||||
setDocumenting(mode === 'documentation');
|
||||
if (mode === 'simulation') simulation.start(language);
|
||||
@@ -347,7 +363,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} />
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} />
|
||||
) : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -25,7 +25,7 @@ import type { Point, Rect } from './viewport.js';
|
||||
export const JAVA_LABEL_FONT = '14px Arial, Helvetica, "Liberation Sans", "DejaVu Sans", sans-serif';
|
||||
/** Its FontMetrics.getHeight, as most of the files' bounds have it. */
|
||||
export const JAVA_LABEL_FONT_HEIGHT = 17;
|
||||
const JAVA_LABEL_ASCENT = 14;
|
||||
export const JAVA_LABEL_ASCENT = 14;
|
||||
const BORDER = 8;
|
||||
|
||||
/** Java's default icons (IconMode.DEFAULT) and their sizes. */
|
||||
@@ -88,16 +88,28 @@ export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boole
|
||||
};
|
||||
}
|
||||
|
||||
/** JCablePanel's curve between two devices' middles, as an SVG path. */
|
||||
export function cablePath(first: Point, second: Point): string {
|
||||
/** JCablePanel's bounds: the rectangle the two ends span, with 2 to spare (updateBounds). */
|
||||
export function cableFrame(first: Point, second: Point): Rect {
|
||||
const left = Math.min(first.x, second.x) - 2;
|
||||
const top = Math.min(first.y, second.y) - 2;
|
||||
return { x: left, y: top, width: Math.abs(first.x - second.x) + 4, height: Math.abs(first.y - second.y) + 4 };
|
||||
}
|
||||
|
||||
/** JCablePanel's quadratic curve between two devices' middles: its control point. */
|
||||
export function cableControl(first: Point, second: Point): Point {
|
||||
const [x1, y1, x2, y2] = [first.x, first.y, second.x, second.y];
|
||||
// The panel spans the two ends with 2 to spare (updateBounds); the control point is relative to it.
|
||||
const left = Math.min(x1, x2) - 2;
|
||||
const top = Math.min(y1, y2) - 2;
|
||||
// The control point is relative to the panel's bounds.
|
||||
const { x: left, y: top } = cableFrame(first, second);
|
||||
let controlX = Math.trunc((x1 - left + x2 - left) / 4);
|
||||
if ((x1 > x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX;
|
||||
const controlY = Math.trunc((y1 - top + y2 - top) / 4);
|
||||
return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`;
|
||||
return { x: left + controlX, y: top + controlY };
|
||||
}
|
||||
|
||||
/** JCablePanel's curve, as an SVG path. */
|
||||
export function cablePath(first: Point, second: Point): string {
|
||||
const control = cableControl(first, second);
|
||||
return `M ${first.x} ${first.y} Q ${control.x} ${control.y} ${second.x} ${second.y}`;
|
||||
}
|
||||
|
||||
/** The rectangle around all of them. */
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// FILIUS' „Exportieren“ in the documentation mode (GUIContainer.createNetworkImage,
|
||||
// GUIPrintPanel; measured with tools/java-oracle's ExportRun): the network
|
||||
// and its documentation as a PNG, painted as the design shows them — the
|
||||
// documentation below, an opaque rectangle a fifth of the way to white and
|
||||
// a text without its border; cables over it, devices over the cables;
|
||||
// nothing chosen — on a transparent background. The image is cut to what
|
||||
// is there: 10 to spare on the left, above and on the right; 20 below the
|
||||
// lowest element a light grey footer with the project's name, flush with
|
||||
// the leftmost element, and 20 to spare below it. Java's own measure: a
|
||||
// footer as wide as its words and 20 more, as high as its font.
|
||||
//
|
||||
// Not adopted from Java: an empty project gives Java an image of 20 × 35
|
||||
// with nothing in it (its footer lands beyond reach, an integer overflow);
|
||||
// here it is the footer alone. Java cuts at its surface's edges (nothing
|
||||
// lies beyond them there); the editor has no edges, so nothing is cut. The
|
||||
// footer is the project's name: a browser knows no file's path.
|
||||
|
||||
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
|
||||
import { rectangleBorder, rectangleFill, textColor, fontFamily } from './documentationPaint.js';
|
||||
import { bounds, cableControl, cableFrame, deviceBox, JAVA_LABEL_ASCENT, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type DeviceBox, type IconName } from './layout.js';
|
||||
import type { Rect, Size } from './viewport.js';
|
||||
|
||||
/** GUIPrintPanel.EMPTY_BORDER: what is left around the elements. */
|
||||
export const EXPORT_MARGIN = 10;
|
||||
/** How far below the lowest element the footer begins, and what is left below it. */
|
||||
export const FOOTER_GAP = 20;
|
||||
/** GUIFooterPanel: Color.lightGray. */
|
||||
export const FOOTER_COLOR = 'rgb(192, 192, 192)';
|
||||
/** JCablePanel's colours: a cable, a wireless link (dashed). */
|
||||
export const CABLE_COLOR = 'rgb(64, 64, 64)';
|
||||
export const WIRELESS_COLOR = 'rgb(192, 192, 192)';
|
||||
/** A device's label, as Java's look and feel writes it. */
|
||||
export const LABEL_COLOR = 'rgb(0, 0, 0)';
|
||||
|
||||
export interface ExportLayout {
|
||||
/** The part of the surface the image shows. */
|
||||
readonly image: Rect;
|
||||
/** The footer's label. */
|
||||
readonly footer: Rect;
|
||||
}
|
||||
|
||||
/** Where the image and its footer lie, around the elements' rectangles (GUIPrintPanel.getClipX … getClipHeight). */
|
||||
export function exportLayout(parts: readonly Rect[], footer: Size): ExportLayout {
|
||||
const content = parts.length === 0 ? null : bounds(parts);
|
||||
const left = content?.x ?? 0;
|
||||
const footerTop = content === null ? 0 : content.y + content.height + FOOTER_GAP;
|
||||
const top = content?.y ?? footerTop;
|
||||
// The footer's label already holds 20 more than its words; Java leaves 10 beyond its own clip of it.
|
||||
const right = Math.max(content === null ? -Infinity : content.x + content.width, left + EXPORT_MARGIN + footer.width) + EXPORT_MARGIN;
|
||||
const bottom = footerTop + footer.height + FOOTER_GAP;
|
||||
return {
|
||||
image: { x: left - EXPORT_MARGIN, y: top - EXPORT_MARGIN, width: right - (left - EXPORT_MARGIN), height: bottom - (top - EXPORT_MARGIN) },
|
||||
footer: { x: left, y: footerTop, width: footer.width, height: footer.height },
|
||||
};
|
||||
}
|
||||
|
||||
/** The lines a text is written in within a width: its own lines, each broken after a space, a word wider than the width broken where it reaches it. */
|
||||
export function wrapLines(text: string, width: number, measure: (text: string) => number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split('\n')) {
|
||||
let line = '';
|
||||
// Each word with the spaces after it: they stay with it, at a line's end too.
|
||||
for (const word of paragraph.match(/\S*\s*/g)!.filter((each) => each !== '')) {
|
||||
if (measure((line + word).trimEnd()) <= width) {
|
||||
line += word;
|
||||
continue;
|
||||
}
|
||||
if (line !== '') lines.push(line);
|
||||
line = '';
|
||||
for (const character of word) {
|
||||
if (line !== '' && measure((line + character).trimEnd()) > width) {
|
||||
lines.push(line);
|
||||
line = '';
|
||||
}
|
||||
line += character;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** What of a 2D context the export paints with. */
|
||||
export type Painter = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
| 'save'
|
||||
| 'restore'
|
||||
| 'translate'
|
||||
| 'beginPath'
|
||||
| 'moveTo'
|
||||
| 'quadraticCurveTo'
|
||||
| 'roundRect'
|
||||
| 'rect'
|
||||
| 'clip'
|
||||
| 'fill'
|
||||
| 'stroke'
|
||||
| 'fillText'
|
||||
| 'measureText'
|
||||
| 'drawImage'
|
||||
| 'setLineDash'
|
||||
| 'font'
|
||||
| 'fillStyle'
|
||||
| 'strokeStyle'
|
||||
| 'lineWidth'
|
||||
| 'textAlign'
|
||||
| 'textBaseline'
|
||||
>;
|
||||
|
||||
/** The devices' buttons and the rectangles of everything in the image (devices, cables, documentation), as Java's panels count them. */
|
||||
export function networkParts(view: TopologyView, labelWidth: (text: string) => number): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly parts: readonly Rect[] } {
|
||||
const boxes = new Map(view.nodes.map((node) => [node.id, deviceBox(node, labelWidth(node.label))]));
|
||||
const parts: Rect[] = [...boxes.values()];
|
||||
for (const link of view.links) {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
if (source !== undefined && target !== undefined) parts.push(cableFrame(source.center, target.center));
|
||||
}
|
||||
for (const item of view.docs) parts.push({ x: item.x, y: item.y, width: item.width, height: item.height });
|
||||
return { boxes, parts };
|
||||
}
|
||||
|
||||
function paintDocumentation(context: Painter, item: DocumentationView) {
|
||||
if (item.kind === 'rectangle') {
|
||||
// As the surface draws it without the focus: inset by 1, its border 1 wide, corners of 5.
|
||||
context.beginPath();
|
||||
context.roundRect(item.x + 1, item.y + 1, Math.max(0, item.width - 2), Math.max(0, item.height - 2), 5);
|
||||
context.fillStyle = rectangleFill(item.color, false);
|
||||
context.fill();
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = rectangleBorder(item.color);
|
||||
context.stroke();
|
||||
return;
|
||||
}
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(item.x, item.y, item.width, item.height);
|
||||
context.clip();
|
||||
context.font = `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`;
|
||||
context.fillStyle = textColor(item.color);
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'alphabetic';
|
||||
// The surface's line height ("normal"): the font's own ascent and descent.
|
||||
const metrics = context.measureText(item.text);
|
||||
const ascent = metrics.fontBoundingBoxAscent;
|
||||
const height = ascent + metrics.fontBoundingBoxDescent;
|
||||
// Inside a padding of 1, as the surface writes it.
|
||||
wrapLines(item.text, item.width - 2, (text) => context.measureText(text).width).forEach((line, index) => {
|
||||
context.fillText(line, item.x + 1, item.y + 1 + ascent + index * height);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** The devices' buttons and the image's layout, with the labels measured in Java's font on the context. */
|
||||
export function measureNetworkImage(context: Painter, view: TopologyView, footerText: string): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly layout: ExportLayout } {
|
||||
context.font = JAVA_LABEL_FONT;
|
||||
const labelWidth = (text: string) => context.measureText(text).width;
|
||||
const { boxes, parts } = networkParts(view, labelWidth);
|
||||
return { boxes, layout: exportLayout(parts, { width: Math.round(labelWidth(footerText)) + 20, height: JAVA_LABEL_FONT_HEIGHT }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the image onto a context as large as its layout's image: the
|
||||
* documentation, the cables, the devices, the footer. The icons are Java's,
|
||||
* loaded already.
|
||||
*/
|
||||
export function paintNetworkImage(context: Painter, view: TopologyView, icons: Readonly<Record<IconName, CanvasImageSource>>, footerText: string): ExportLayout {
|
||||
const { boxes, layout } = measureNetworkImage(context, view, footerText);
|
||||
context.save();
|
||||
context.translate(-layout.image.x, -layout.image.y);
|
||||
|
||||
for (const item of view.docs) paintDocumentation(context, item);
|
||||
|
||||
context.lineWidth = 2;
|
||||
for (const link of view.links) {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
if (source === undefined || target === undefined) continue;
|
||||
const control = cableControl(source.center, target.center);
|
||||
context.beginPath();
|
||||
context.moveTo(source.center.x, source.center.y);
|
||||
context.quadraticCurveTo(control.x, control.y, target.center.x, target.center.y);
|
||||
context.strokeStyle = link.wireless ? WIRELESS_COLOR : CABLE_COLOR;
|
||||
context.setLineDash(link.wireless ? [10] : []);
|
||||
context.stroke();
|
||||
}
|
||||
context.setLineDash([]);
|
||||
|
||||
context.font = JAVA_LABEL_FONT;
|
||||
context.textBaseline = 'alphabetic';
|
||||
for (const node of view.nodes) {
|
||||
const box = boxes.get(node.id)!;
|
||||
context.drawImage(icons[box.icon], box.iconRect.x, box.iconRect.y, box.iconRect.width, box.iconRect.height);
|
||||
context.fillStyle = LABEL_COLOR;
|
||||
context.textAlign = 'center';
|
||||
context.fillText(node.label, box.label.x, box.label.y);
|
||||
}
|
||||
|
||||
// A JLabel's words: at its left, its font's height high.
|
||||
context.fillStyle = FOOTER_COLOR;
|
||||
context.textAlign = 'left';
|
||||
context.fillText(footerText, layout.footer.x, layout.footer.y + JAVA_LABEL_ASCENT);
|
||||
context.restore();
|
||||
return layout;
|
||||
}
|
||||
|
||||
/** The footer's words and the image's file name: the project's name and that name as a PNG, as Java names it after its file; without a name „Filius“. */
|
||||
export function exportNames(projectName: string | undefined): { readonly footer: string; readonly file: string } {
|
||||
const name = projectName?.trim() ?? '';
|
||||
if (name === '') return { footer: 'Filius', file: 'Filius.png' };
|
||||
const dot = name.lastIndexOf('.');
|
||||
return { footer: name, file: `${dot > 0 ? name.slice(0, dot) : name}.png` };
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// and „Gliederungsfläche“ with Java's icons. An item is dragged onto the
|
||||
// surface, where it lands with its middle under the pointer, as Java's does;
|
||||
// tapped, it lands in the middle of the view (Java's tap makes nothing).
|
||||
// „Exportieren“ saves the surface as an image (F4.8c); if that fails, Java's
|
||||
// „Fehler beim Speichern aufgetreten!“ shows below it.
|
||||
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import type { DocumentationItemKind } from '@mdg-filius/core';
|
||||
@@ -9,10 +11,13 @@ import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import addRectangle from '../assets/documentation/add_small.png';
|
||||
import addText from '../assets/documentation/add_text_small.png';
|
||||
import exportIcon from '../assets/documentation/download_small.png';
|
||||
|
||||
export interface DocumentationSidebarProps {
|
||||
/** An item dropped at a point of the screen, or tapped (no point). */
|
||||
readonly onItem: (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
|
||||
/** „Exportieren“: the surface saved as an image; rejected, it failed. */
|
||||
readonly onExport: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||
@@ -39,12 +44,25 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
|
||||
export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
/** The click the browser sends after a drag ends on the button: not a tap. */
|
||||
const afterDrag = useRef(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const exportImage = () => {
|
||||
setExporting(true);
|
||||
setFailed(false);
|
||||
onExport().then(
|
||||
() => setExporting(false),
|
||||
() => {
|
||||
setExporting(false);
|
||||
setFailed(true);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onPointerDown = (kind: DocumentationItemKind) => (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
press.current = { kind, x: event.clientX, y: event.clientY, dragging: false };
|
||||
@@ -106,6 +124,15 @@ export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
|
||||
{t(each.label)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" style={{ ...item, cursor: exporting ? 'progress' : 'pointer' }} title={t('docu.exportHint')} disabled={exporting} onClick={exportImage}>
|
||||
<img src={exportIcon} alt="" width={40} height={40} draggable={false} />
|
||||
{t('docu.export')}
|
||||
</button>
|
||||
{failed ? (
|
||||
<p role="alert" style={{ margin: 0, fontSize: 12, color: 'var(--danger)' }}>
|
||||
{t('docu.saveFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
{ghost === null ? null : (
|
||||
// The item's own size, its middle under the pointer, as Java drags it.
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The image „Exportieren“ makes, in the browser: Java's icons loaded, a
|
||||
// canvas as large as the image, painted (canvas/networkImage.ts), a PNG.
|
||||
|
||||
import type { TopologyView } from '@mdg-filius/doc';
|
||||
import { ICON_URLS } from '../canvas/icons.js';
|
||||
import type { IconName } from '../canvas/layout.js';
|
||||
import { measureNetworkImage, paintNetworkImage } from '../canvas/networkImage.js';
|
||||
|
||||
async function loadIcons(): Promise<Record<IconName, HTMLImageElement>> {
|
||||
const loaded = await Promise.all(
|
||||
Object.entries(ICON_URLS).map(async ([name, url]) => {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
return [name, image] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(loaded) as Record<IconName, HTMLImageElement>;
|
||||
}
|
||||
|
||||
/** The network and its documentation as a PNG, with the footer's words. */
|
||||
export async function networkImage(view: TopologyView, footerText: string): Promise<Blob> {
|
||||
const icons = await loadIcons();
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (context === null) throw new Error('no 2D context');
|
||||
const { layout } = measureNetworkImage(context, view, footerText);
|
||||
// Setting the size starts the context afresh: the painting measures again.
|
||||
canvas.width = Math.ceil(layout.image.width);
|
||||
canvas.height = Math.ceil(layout.image.height);
|
||||
paintNetworkImage(context, view, icons, footerText);
|
||||
return new Promise((resolve, reject) => canvas.toBlob((blob) => (blob === null ? reject(new Error('no image')) : resolve(blob)), 'image/png'));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// A file handed to the person: the browser saves it under the name given.
|
||||
|
||||
export function saveFile(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -104,9 +104,12 @@
|
||||
"dnsServer.tabNS": "Nameserver (NS)",
|
||||
"dnsServer.tables": "Einträge",
|
||||
"docu.color": "Farbe",
|
||||
"docu.export": "Exportieren",
|
||||
"docu.exportHint": "Als Bild exportieren",
|
||||
"docu.fontSize": "Schriftgröße",
|
||||
"docu.fontStyle": "Schriftstil",
|
||||
"docu.rectangle": "Gliederungsfläche",
|
||||
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
|
||||
"docu.sidebar": "Dokumentation",
|
||||
"docu.sizeLarge": "Groß",
|
||||
"docu.sizeNormal": "Normal",
|
||||
|
||||
@@ -104,9 +104,12 @@
|
||||
"dnsServer.tabNS": "Name server (NS)",
|
||||
"dnsServer.tables": "Records",
|
||||
"docu.color": "Colour",
|
||||
"docu.export": "Export",
|
||||
"docu.exportHint": "Export as Image",
|
||||
"docu.fontSize": "Font Size",
|
||||
"docu.fontStyle": "Font Style",
|
||||
"docu.rectangle": "Structure Field",
|
||||
"docu.saveFailed": "Error occurred!",
|
||||
"docu.sidebar": "Documentation",
|
||||
"docu.sizeLarge": "Large",
|
||||
"docu.sizeNormal": "Normal",
|
||||
|
||||
@@ -29,6 +29,8 @@ function App() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [language, setLanguage] = useState<Language>(params.get('lang') === 'en' ? 'en' : 'de');
|
||||
const [doc, setDoc] = useState<Y.Doc | null>(null);
|
||||
/** The open file's name, for what the editor exports. */
|
||||
const [fileName, setFileName] = useState<string | undefined>(undefined);
|
||||
const [example, setExample] = useState(params.get('example') ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,13 +53,17 @@ function App() {
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
setDoc(doc);
|
||||
setFileName(undefined);
|
||||
return;
|
||||
}
|
||||
const url = exampleUrl(example);
|
||||
if (url === undefined) return;
|
||||
void fetch(url)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
|
||||
.then((buffer) => {
|
||||
setDoc(docOf(new Uint8Array(buffer)));
|
||||
setFileName(example);
|
||||
});
|
||||
}, [example]);
|
||||
|
||||
// The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard).
|
||||
@@ -102,13 +108,17 @@ function App() {
|
||||
accept=".fls"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file !== undefined) void file.arrayBuffer().then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
|
||||
if (file !== undefined)
|
||||
void file.arrayBuffer().then((buffer) => {
|
||||
setDoc(docOf(new Uint8Array(buffer)));
|
||||
setFileName(file.name);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
<main style={{ flex: 1, minHeight: 0 }}>
|
||||
{doc === null ? null : <Editor key={doc.guid} doc={doc} />}
|
||||
{doc === null ? null : <Editor key={doc.guid} doc={doc} {...(fileName === undefined ? {} : { projectName: fileName })} />}
|
||||
</main>
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||
import { saveFile } from '../download.js';
|
||||
import { useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
|
||||
import type { Simulation } from './useSimulation.js';
|
||||
@@ -111,14 +112,7 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o
|
||||
|
||||
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);
|
||||
};
|
||||
const download = () => saveFile(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }), `${tab?.title ?? title}.txt`);
|
||||
|
||||
return (
|
||||
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
|
||||
|
||||
@@ -256,6 +256,8 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'docu.styleItalic': 'guievents_msg20',
|
||||
'docu.styleBold': 'guievents_msg21',
|
||||
'docu.color': 'guievents_msg13',
|
||||
'docu.export': 'docusidebar_msg5',
|
||||
'docu.exportHint': 'docusidebar_msg6',
|
||||
'capture.title': 'lauscherdialog_msg1',
|
||||
'capture.number': 'rp_lauscher_msg1',
|
||||
'capture.time': 'rp_lauscher_msg2',
|
||||
@@ -361,6 +363,8 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string) => string]>> = {
|
||||
// Java's English misspells it.
|
||||
'docu.saveFailed': ['guimainmemu_msg11', (java) => java.replace('occured', 'occurred')],
|
||||
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
|
||||
// A label above its list, without the colon of Java's label beside it.
|
||||
'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')],
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
{
|
||||
"projects": {
|
||||
"nat_with_mapped_port.fls": {
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Switch",
|
||||
"bounds": [
|
||||
300,
|
||||
131,
|
||||
91,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Switch",
|
||||
"bounds": [
|
||||
676,
|
||||
161,
|
||||
91,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vermittlungsrechner",
|
||||
"bounds": [
|
||||
551,
|
||||
90,
|
||||
167,
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vermittlungsrechner",
|
||||
"bounds": [
|
||||
559,
|
||||
263,
|
||||
167,
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vermittlungsrechner",
|
||||
"bounds": [
|
||||
562,
|
||||
426,
|
||||
167,
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DNS-Server",
|
||||
"bounds": [
|
||||
837,
|
||||
119,
|
||||
101,
|
||||
114
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "192.168.0.100",
|
||||
"bounds": [
|
||||
152,
|
||||
54,
|
||||
116,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "192.168.0.101",
|
||||
"bounds": [
|
||||
157,
|
||||
202,
|
||||
116,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Webserver www.jupiter.de",
|
||||
"bounds": [
|
||||
804,
|
||||
307,
|
||||
213,
|
||||
114
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Webserver - 192.168.0.10",
|
||||
"bounds": [
|
||||
99,
|
||||
363,
|
||||
198,
|
||||
114
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Switch",
|
||||
"bounds": [
|
||||
313,
|
||||
444,
|
||||
91,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "192.168.0.100",
|
||||
"bounds": [
|
||||
260,
|
||||
538,
|
||||
116,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Heimrouter Vincent",
|
||||
"bounds": [
|
||||
384,
|
||||
438,
|
||||
158,
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Heimrouter Paul",
|
||||
"bounds": [
|
||||
375,
|
||||
122,
|
||||
136,
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Switch",
|
||||
"bounds": [
|
||||
694,
|
||||
342,
|
||||
91,
|
||||
72
|
||||
]
|
||||
}
|
||||
],
|
||||
"cables": [
|
||||
[
|
||||
208,
|
||||
102,
|
||||
139,
|
||||
67
|
||||
],
|
||||
[
|
||||
213,
|
||||
165,
|
||||
134,
|
||||
89
|
||||
],
|
||||
[
|
||||
632,
|
||||
127,
|
||||
91,
|
||||
72
|
||||
],
|
||||
[
|
||||
719,
|
||||
174,
|
||||
170,
|
||||
25
|
||||
],
|
||||
[
|
||||
640,
|
||||
195,
|
||||
83,
|
||||
109
|
||||
],
|
||||
[
|
||||
196,
|
||||
418,
|
||||
164,
|
||||
64
|
||||
],
|
||||
[
|
||||
316,
|
||||
478,
|
||||
44,
|
||||
112
|
||||
],
|
||||
[
|
||||
343,
|
||||
159,
|
||||
102,
|
||||
10
|
||||
],
|
||||
[
|
||||
441,
|
||||
127,
|
||||
195,
|
||||
36
|
||||
],
|
||||
[
|
||||
356,
|
||||
475,
|
||||
109,
|
||||
7
|
||||
],
|
||||
[
|
||||
461,
|
||||
463,
|
||||
186,
|
||||
16
|
||||
],
|
||||
[
|
||||
640,
|
||||
300,
|
||||
101,
|
||||
80
|
||||
],
|
||||
[
|
||||
643,
|
||||
376,
|
||||
98,
|
||||
91
|
||||
],
|
||||
[
|
||||
737,
|
||||
362,
|
||||
175,
|
||||
18
|
||||
]
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"type": 1,
|
||||
"bounds": [
|
||||
94,
|
||||
34,
|
||||
428,
|
||||
292
|
||||
],
|
||||
"color": "204,204,204,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
316,
|
||||
42,
|
||||
142,
|
||||
26
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"bounds": [
|
||||
95,
|
||||
349,
|
||||
428,
|
||||
296
|
||||
],
|
||||
"color": "204,204,204,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
322,
|
||||
358,
|
||||
169,
|
||||
27
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
388,
|
||||
523,
|
||||
129,
|
||||
73
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
113,
|
||||
483,
|
||||
167,
|
||||
108
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"bounds": [
|
||||
792,
|
||||
52,
|
||||
224,
|
||||
489
|
||||
],
|
||||
"color": "204,204,204,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
828,
|
||||
430,
|
||||
148,
|
||||
50
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
802,
|
||||
57,
|
||||
187,
|
||||
50
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
}
|
||||
],
|
||||
"path": "nat_with_mapped_port.fls",
|
||||
"panelSize": [
|
||||
2020,
|
||||
1620
|
||||
],
|
||||
"network": {
|
||||
"minX": 99.0,
|
||||
"maxX": 1017.0,
|
||||
"minY": 54.0,
|
||||
"maxY": 638.0
|
||||
},
|
||||
"documentation": {
|
||||
"minX": 94.0,
|
||||
"maxX": 1016.0,
|
||||
"minY": 34.0,
|
||||
"maxY": 645.0
|
||||
},
|
||||
"footerArea": {
|
||||
"minX": 104.0,
|
||||
"maxX": 814.0,
|
||||
"minY": 675.0,
|
||||
"maxY": 690.0
|
||||
},
|
||||
"footer": {
|
||||
"text": "<path>",
|
||||
"bounds": [
|
||||
104,
|
||||
675,
|
||||
710,
|
||||
15
|
||||
],
|
||||
"stringWidth": 690,
|
||||
"fontHeight": 15,
|
||||
"foreground": "192,192,192,255"
|
||||
},
|
||||
"clip": [
|
||||
94,
|
||||
34,
|
||||
943,
|
||||
676
|
||||
],
|
||||
"imageSize": [
|
||||
943,
|
||||
676
|
||||
],
|
||||
"cornerPixel": "0,0,0,0",
|
||||
"footerColours": [
|
||||
[
|
||||
"0,0,0,0",
|
||||
8267
|
||||
],
|
||||
[
|
||||
"192,192,192,255",
|
||||
2383
|
||||
]
|
||||
],
|
||||
"rectangleCentre": [
|
||||
[
|
||||
"215,215,215,255",
|
||||
1
|
||||
]
|
||||
],
|
||||
"rectangleCentreColor": "204,204,204,255"
|
||||
},
|
||||
"zwei_rechnernetze_komplett.fls": {
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Switch 1",
|
||||
"bounds": [
|
||||
164,
|
||||
178,
|
||||
91,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Switch 2",
|
||||
"bounds": [
|
||||
612,
|
||||
104,
|
||||
91,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DNS-Server 2.10",
|
||||
"bounds": [
|
||||
694,
|
||||
19,
|
||||
134,
|
||||
114
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Notebook 1.10",
|
||||
"bounds": [
|
||||
53,
|
||||
100,
|
||||
123,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Notebook 1.11",
|
||||
"bounds": [
|
||||
70,
|
||||
235,
|
||||
123,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vermittlungsrechner",
|
||||
"bounds": [
|
||||
373,
|
||||
106,
|
||||
167,
|
||||
78
|
||||
]
|
||||
}
|
||||
],
|
||||
"cables": [
|
||||
[
|
||||
112,
|
||||
148,
|
||||
99,
|
||||
68
|
||||
],
|
||||
[
|
||||
129,
|
||||
212,
|
||||
82,
|
||||
75
|
||||
],
|
||||
[
|
||||
655,
|
||||
74,
|
||||
108,
|
||||
68
|
||||
],
|
||||
[
|
||||
207,
|
||||
143,
|
||||
251,
|
||||
73
|
||||
],
|
||||
[
|
||||
454,
|
||||
138,
|
||||
205,
|
||||
9
|
||||
]
|
||||
],
|
||||
"docs": [],
|
||||
"path": "zwei_rechnernetze_komplett.fls",
|
||||
"panelSize": [
|
||||
2020,
|
||||
1620
|
||||
],
|
||||
"network": {
|
||||
"minX": 53.0,
|
||||
"maxX": 828.0,
|
||||
"minY": 19.0,
|
||||
"maxY": 335.0
|
||||
},
|
||||
"documentation": {
|
||||
"minX": 2.147483647E9,
|
||||
"maxX": 0.0,
|
||||
"minY": 2.147483647E9,
|
||||
"maxY": 0.0
|
||||
},
|
||||
"footerArea": {
|
||||
"minX": 63.0,
|
||||
"maxX": 817.0,
|
||||
"minY": 365.0,
|
||||
"maxY": 380.0
|
||||
},
|
||||
"footer": {
|
||||
"text": "<path>",
|
||||
"bounds": [
|
||||
63,
|
||||
365,
|
||||
754,
|
||||
15
|
||||
],
|
||||
"stringWidth": 734,
|
||||
"fontHeight": 15,
|
||||
"foreground": "192,192,192,255"
|
||||
},
|
||||
"clip": [
|
||||
53,
|
||||
19,
|
||||
795,
|
||||
381
|
||||
],
|
||||
"imageSize": [
|
||||
795,
|
||||
381
|
||||
],
|
||||
"cornerPixel": "0,0,0,0",
|
||||
"footerColours": [
|
||||
[
|
||||
"0,0,0,0",
|
||||
8805
|
||||
],
|
||||
[
|
||||
"192,192,192,255",
|
||||
2505
|
||||
]
|
||||
]
|
||||
},
|
||||
"documentation.fls": {
|
||||
"nodes": [],
|
||||
"cables": [],
|
||||
"docs": [
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
40,
|
||||
40,
|
||||
160,
|
||||
60
|
||||
],
|
||||
"color": "79,130,189,255"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"bounds": [
|
||||
40,
|
||||
140,
|
||||
120,
|
||||
50
|
||||
],
|
||||
"color": "0,0,0,255"
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"bounds": [
|
||||
20,
|
||||
20,
|
||||
300,
|
||||
200
|
||||
],
|
||||
"color": "255,201,201,255"
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"bounds": [
|
||||
400,
|
||||
20,
|
||||
100,
|
||||
50
|
||||
],
|
||||
"color": "204,204,204,255"
|
||||
}
|
||||
],
|
||||
"path": "documentation.fls",
|
||||
"panelSize": [
|
||||
2020,
|
||||
1620
|
||||
],
|
||||
"network": {
|
||||
"minX": 2.147483647E9,
|
||||
"maxX": 0.0,
|
||||
"minY": 2.147483647E9,
|
||||
"maxY": 0.0
|
||||
},
|
||||
"documentation": {
|
||||
"minX": 20.0,
|
||||
"maxX": 500.0,
|
||||
"minY": 20.0,
|
||||
"maxY": 220.0
|
||||
},
|
||||
"footerArea": {
|
||||
"minX": 30.0,
|
||||
"maxX": 399.0,
|
||||
"minY": 250.0,
|
||||
"maxY": 265.0
|
||||
},
|
||||
"footer": {
|
||||
"text": "<path>",
|
||||
"bounds": [
|
||||
30,
|
||||
250,
|
||||
369,
|
||||
15
|
||||
],
|
||||
"stringWidth": 349,
|
||||
"fontHeight": 15,
|
||||
"foreground": "192,192,192,255"
|
||||
},
|
||||
"clip": [
|
||||
20,
|
||||
20,
|
||||
500,
|
||||
265
|
||||
],
|
||||
"imageSize": [
|
||||
500,
|
||||
265
|
||||
],
|
||||
"cornerPixel": "0,0,0,0",
|
||||
"footerColours": [
|
||||
[
|
||||
"0,0,0,0",
|
||||
4393
|
||||
],
|
||||
[
|
||||
"192,192,192,255",
|
||||
1142
|
||||
]
|
||||
],
|
||||
"rectangleCentre": [
|
||||
[
|
||||
"255,212,212,255",
|
||||
1
|
||||
]
|
||||
],
|
||||
"rectangleCentreColor": "255,201,201,255"
|
||||
},
|
||||
"modem_beispiel.fls": {
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Rechner 0.11",
|
||||
"bounds": [
|
||||
586,
|
||||
46,
|
||||
112,
|
||||
114
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Notebook 0.10",
|
||||
"bounds": [
|
||||
42,
|
||||
182,
|
||||
123,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Modem (Zugreifend)",
|
||||
"bounds": [
|
||||
237,
|
||||
212,
|
||||
165,
|
||||
76
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Modem (Zugangspunkt)",
|
||||
"bounds": [
|
||||
396,
|
||||
79,
|
||||
190,
|
||||
76
|
||||
]
|
||||
}
|
||||
],
|
||||
"cables": [
|
||||
[
|
||||
101,
|
||||
230,
|
||||
220,
|
||||
22
|
||||
],
|
||||
[
|
||||
489,
|
||||
101,
|
||||
155,
|
||||
18
|
||||
]
|
||||
],
|
||||
"docs": [],
|
||||
"path": "modem_beispiel.fls",
|
||||
"panelSize": [
|
||||
2020,
|
||||
1620
|
||||
],
|
||||
"network": {
|
||||
"minX": 42.0,
|
||||
"maxX": 698.0,
|
||||
"minY": 46.0,
|
||||
"maxY": 288.0
|
||||
},
|
||||
"documentation": {
|
||||
"minX": 2.147483647E9,
|
||||
"maxX": 0.0,
|
||||
"minY": 2.147483647E9,
|
||||
"maxY": 0.0
|
||||
},
|
||||
"footerArea": {
|
||||
"minX": 52.0,
|
||||
"maxX": 715.0,
|
||||
"minY": 318.0,
|
||||
"maxY": 333.0
|
||||
},
|
||||
"footer": {
|
||||
"text": "<path>",
|
||||
"bounds": [
|
||||
52,
|
||||
318,
|
||||
663,
|
||||
15
|
||||
],
|
||||
"stringWidth": 643,
|
||||
"fontHeight": 15,
|
||||
"foreground": "192,192,192,255"
|
||||
},
|
||||
"clip": [
|
||||
42,
|
||||
46,
|
||||
693,
|
||||
307
|
||||
],
|
||||
"imageSize": [
|
||||
693,
|
||||
307
|
||||
],
|
||||
"cornerPixel": "0,0,0,0",
|
||||
"footerColours": [
|
||||
[
|
||||
"0,0,0,0",
|
||||
7708
|
||||
],
|
||||
[
|
||||
"192,192,192,255",
|
||||
2237
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"nodes": [],
|
||||
"cables": [],
|
||||
"docs": [],
|
||||
"path": null,
|
||||
"panelSize": [
|
||||
2020,
|
||||
1620
|
||||
],
|
||||
"network": {
|
||||
"minX": 2.147483647E9,
|
||||
"maxX": 0.0,
|
||||
"minY": 2.147483647E9,
|
||||
"maxY": 0.0
|
||||
},
|
||||
"documentation": {
|
||||
"minX": 2.147483647E9,
|
||||
"maxX": 0.0,
|
||||
"minY": 2.147483647E9,
|
||||
"maxY": 0.0
|
||||
},
|
||||
"footerArea": {
|
||||
"minX": 0.0,
|
||||
"maxX": 0.0,
|
||||
"minY": 30.0,
|
||||
"maxY": 45.0
|
||||
},
|
||||
"footer": {
|
||||
"text": "Filius",
|
||||
"bounds": [
|
||||
-2147483639,
|
||||
30,
|
||||
57,
|
||||
15
|
||||
],
|
||||
"stringWidth": 37,
|
||||
"fontHeight": 15,
|
||||
"foreground": "192,192,192,255"
|
||||
},
|
||||
"clip": [
|
||||
0,
|
||||
30,
|
||||
20,
|
||||
35
|
||||
],
|
||||
"imageSize": [
|
||||
20,
|
||||
35
|
||||
],
|
||||
"cornerPixel": "0,0,0,0",
|
||||
"footerColours": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// FILIUS' „Exportieren“ (GUIContainer.createNetworkImage), against Java's own
|
||||
// print panel (tools/java-oracle's ExportRun on four projects and an empty
|
||||
// one, networkImage.java.json): the part of the surface the image shows and
|
||||
// where its footer lies, from the bounds of Java's own elements and footer
|
||||
// (their sizes depend on the font of the machine Java runs on); Java's
|
||||
// cable bounds; the colours Java paints (a transparent background, the
|
||||
// footer light grey, a rectangle as the design shows it); and the painting
|
||||
// itself on a recording context.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
|
||||
import { rectangleBorder, rectangleFill } from '../src/canvas/documentationPaint.js';
|
||||
import { cableControl, cableFrame, deviceBox, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type IconName } from '../src/canvas/layout.js';
|
||||
import {
|
||||
CABLE_COLOR,
|
||||
exportLayout,
|
||||
exportNames,
|
||||
FOOTER_COLOR,
|
||||
LABEL_COLOR,
|
||||
measureNetworkImage,
|
||||
networkParts,
|
||||
paintNetworkImage,
|
||||
WIRELESS_COLOR,
|
||||
wrapLines,
|
||||
type Painter,
|
||||
} from '../src/canvas/networkImage.js';
|
||||
|
||||
type Box = [number, number, number, number];
|
||||
interface JavaProject {
|
||||
readonly nodes: readonly { readonly name: string; readonly bounds: Box }[];
|
||||
readonly cables: readonly Box[];
|
||||
readonly docs: readonly { readonly type: number; readonly bounds: Box; readonly color: string }[];
|
||||
readonly footer: { readonly text: string; readonly bounds: Box; readonly stringWidth: number; readonly fontHeight: number; readonly foreground: string };
|
||||
readonly clip: Box;
|
||||
readonly imageSize: [number, number];
|
||||
readonly cornerPixel: string;
|
||||
readonly footerColours: readonly [string, number][];
|
||||
readonly rectangleCentre?: readonly [string, number][];
|
||||
readonly rectangleCentreColor?: string;
|
||||
}
|
||||
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'networkImage.java.json'), 'utf8')) as {
|
||||
readonly projects: Readonly<Record<string, JavaProject>>;
|
||||
readonly empty: JavaProject;
|
||||
};
|
||||
|
||||
const rect = ([x, y, width, height]: Box) => ({ x, y, width, height });
|
||||
const rgb = (text: string) => {
|
||||
const [red, green, blue, alpha] = text.split(',').map(Number);
|
||||
return `rgb(${red}, ${green}, ${blue})${alpha === 255 ? '' : ` alpha ${alpha}`}`;
|
||||
};
|
||||
|
||||
describe("the exported image's layout, as Java's print panel", () => {
|
||||
it.each(Object.keys(java.projects))('cuts %s as Java does: 10 around, the footer 20 below the lowest element, 20 below the footer', (name) => {
|
||||
const project = java.projects[name]!;
|
||||
// Java's footer: its words and 20 more wide, its font high.
|
||||
expect(project.footer.bounds[2]).toBe(project.footer.stringWidth + 20);
|
||||
expect(project.footer.bounds[3]).toBe(project.footer.fontHeight);
|
||||
expect(project.footer.text).toBe('<path>');
|
||||
const parts = [...project.nodes.map((node) => rect(node.bounds)), ...project.cables.map(rect), ...project.docs.map((doc) => rect(doc.bounds))];
|
||||
const layout = exportLayout(parts, { width: project.footer.bounds[2], height: project.footer.bounds[3] });
|
||||
// Java's panel holds the elements 10 to the right and down (EMPTY_BORDER); its clip is in the panel's terms.
|
||||
const [x, y, width, height] = project.clip;
|
||||
expect(layout.image).toEqual({ x: x - 10, y: y - 10, width, height });
|
||||
expect(layout.footer).toEqual({ x: project.footer.bounds[0] - 10, y: project.footer.bounds[1] - 10, width: project.footer.bounds[2], height: project.footer.bounds[3] });
|
||||
expect(project.imageSize).toEqual([width, height]);
|
||||
});
|
||||
|
||||
it('is as wide as the footer and 20 more where the footer is wider than the elements', () => {
|
||||
const layout = exportLayout([{ x: 50, y: 40, width: 100, height: 60 }], { width: 300, height: 17 });
|
||||
expect(layout.footer).toEqual({ x: 50, y: 120, width: 300, height: 17 });
|
||||
expect(layout.image).toEqual({ x: 40, y: 30, width: 330, height: 127 });
|
||||
});
|
||||
|
||||
it("shows an empty project's footer alone (Java's image of 20 × 35 shows nothing: its footer overflows)", () => {
|
||||
expect(java.empty.imageSize).toEqual([20, 35]);
|
||||
expect(java.empty.footerColours).toEqual([]);
|
||||
expect(java.empty.footer.text).toBe('Filius');
|
||||
const layout = exportLayout([], { width: 57, height: 17 });
|
||||
expect(layout.footer).toEqual({ x: 0, y: 0, width: 57, height: 17 });
|
||||
expect(layout.image).toEqual({ x: -10, y: -10, width: 87, height: 47 });
|
||||
});
|
||||
|
||||
it("does not cut at the surface's edges, which the editor does not have", () => {
|
||||
expect(exportLayout([{ x: -40, y: -30, width: 20, height: 10 }], { width: 10, height: 17 }).image).toEqual({ x: -50, y: -40, width: 40, height: 77 });
|
||||
});
|
||||
|
||||
it("takes Java's cable bounds: the two middles with 2 to spare", () => {
|
||||
const project = java.projects['nat_with_mapped_port.fls']!;
|
||||
const middles = project.nodes.map(({ bounds: [x, y, width, height] }) => ({ x: Math.trunc(x + 0.5 * width), y: Math.trunc(y + 0.5 * height) }));
|
||||
for (const cable of project.cables) {
|
||||
const matches = middles.flatMap((first, index) => middles.slice(index + 1).filter((second) => JSON.stringify(cableFrame(first, second)) === JSON.stringify(rect(cable))));
|
||||
expect(matches.length, JSON.stringify(cable)).toBeGreaterThan(0);
|
||||
}
|
||||
expect(cableFrame({ x: 50, y: 5 }, { x: 10, y: 20 })).toEqual({ x: 8, y: 3, width: 44, height: 19 });
|
||||
});
|
||||
|
||||
it("draws cables in JCablePanel's colours: dark grey (64), a wireless link light grey (192)", () => {
|
||||
expect([CABLE_COLOR, WIRELESS_COLOR]).toEqual(['rgb(64, 64, 64)', 'rgb(192, 192, 192)']);
|
||||
});
|
||||
|
||||
it("paints as Java's image does: transparent around, the footer light grey, a rectangle as the design shows it", () => {
|
||||
for (const project of [...Object.values(java.projects), java.empty]) expect(project.cornerPixel).toBe('0,0,0,0');
|
||||
for (const project of Object.values(java.projects)) {
|
||||
expect(rgb(project.footer.foreground)).toBe(FOOTER_COLOR);
|
||||
expect(project.footerColours.map(([colour]) => colour)).toContain(project.footer.foreground);
|
||||
if (project.rectangleCentre === undefined) continue;
|
||||
const [red, green, blue] = project.rectangleCentreColor!.split(',').map((part) => Number(part) / 255);
|
||||
expect(rgb(project.rectangleCentre[0]![0])).toBe(rectangleFill({ red: red!, green: green!, blue: blue!, alpha: 1 }, false));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the words of a text in the image', () => {
|
||||
const measure = (text: string) => text.length * 10;
|
||||
|
||||
it('keeps lines that fit, and its own lines, empty ones too', () => {
|
||||
expect(wrapLines('Netz A', 100, measure)).toEqual(['Netz A']);
|
||||
expect(wrapLines('eins\n\nzwei', 100, measure)).toEqual(['eins', '', 'zwei']);
|
||||
expect(wrapLines('', 100, measure)).toEqual(['']);
|
||||
});
|
||||
|
||||
it('breaks after a space, the space staying at the end of its line', () => {
|
||||
expect(wrapLines('Netz A und B', 80, measure)).toEqual(['Netz A ', 'und B']);
|
||||
// A trailing space does not count: "abcd efgh " fits in 90.
|
||||
expect(wrapLines('abcd efgh ijk', 90, measure)).toEqual(['abcd efgh ', 'ijk']);
|
||||
expect(wrapLines(' eingerückt', 200, measure)).toEqual([' eingerückt']);
|
||||
});
|
||||
|
||||
it('breaks after a tab as after a space', () => {
|
||||
expect(wrapLines('abc\tdefghij', 70, measure)).toEqual(['abc\t', 'defghij']);
|
||||
});
|
||||
|
||||
it('holds a character a line, where one is wider than the line', () => {
|
||||
expect(wrapLines('ab', 5, measure)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('breaks a word wider than the line where it reaches the edge', () => {
|
||||
expect(wrapLines('Vermittlungsrechner', 50, measure)).toEqual(['Vermi', 'ttlun', 'gsrec', 'hner']);
|
||||
expect(wrapLines('ab Vermittlung', 50, measure)).toEqual(['ab ', 'Vermi', 'ttlun', 'g']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the image's names", () => {
|
||||
it("writes the project's name in the footer and names the image after it, as Java names it after its file", () => {
|
||||
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' });
|
||||
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' });
|
||||
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' });
|
||||
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' });
|
||||
});
|
||||
|
||||
it('writes „Filius“ without a name, as Java does without a file', () => {
|
||||
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' });
|
||||
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' });
|
||||
});
|
||||
});
|
||||
|
||||
/** A 2D context that writes down what is painted, its text 7 wide a character, a font's ascent 0.9 of its size, its descent 0.25. */
|
||||
function recorder() {
|
||||
const calls: unknown[][] = [];
|
||||
const state = { font: '10px sans-serif', fillStyle: '' as unknown, strokeStyle: '' as unknown, lineWidth: 1, textAlign: 'start', textBaseline: 'alphabetic' };
|
||||
const size = () => Number(/(\d+)px/.exec(state.font)![1]);
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) => {
|
||||
calls.push([name, ...args]);
|
||||
};
|
||||
const context = {
|
||||
save: record('save'),
|
||||
restore: record('restore'),
|
||||
translate: record('translate'),
|
||||
beginPath: record('beginPath'),
|
||||
moveTo: record('moveTo'),
|
||||
quadraticCurveTo: record('quadraticCurveTo'),
|
||||
roundRect: record('roundRect'),
|
||||
rect: record('rect'),
|
||||
clip: record('clip'),
|
||||
fill: () => calls.push(['fill', state.fillStyle]),
|
||||
stroke: () => calls.push(['stroke', state.strokeStyle, state.lineWidth]),
|
||||
fillText: (text: string, x: number, y: number) => calls.push(['fillText', text, x, y, state.font, state.fillStyle, state.textAlign]),
|
||||
measureText: (text: string) => ({ width: text.length * 7, fontBoundingBoxAscent: size() * 0.9, fontBoundingBoxDescent: size() * 0.25 }),
|
||||
drawImage: (image: unknown, x: number, y: number, width: number, height: number) => calls.push(['drawImage', image, x, y, width, height]),
|
||||
setLineDash: record('setLineDash'),
|
||||
};
|
||||
for (const key of Object.keys(state) as (keyof typeof state)[]) {
|
||||
Object.defineProperty(context, key, {
|
||||
get: () => state[key],
|
||||
set: (value: never) => {
|
||||
state[key] = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
return { context: context as unknown as Painter, calls };
|
||||
}
|
||||
|
||||
const grey = { red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1 };
|
||||
const doc = (fields: Partial<DocumentationView> & Pick<DocumentationView, 'id' | 'kind'>): DocumentationView => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
text: '',
|
||||
color: grey,
|
||||
font: 'Dialog',
|
||||
size: 12,
|
||||
bold: false,
|
||||
italic: false,
|
||||
order: 0,
|
||||
...fields,
|
||||
});
|
||||
const VIEW: TopologyView = {
|
||||
nodes: [
|
||||
{ id: 'a', kind: 'pc', name: 'A', label: 'Rechner A', x: 100, y: 80, cloud: false, ports: [] },
|
||||
{ id: 'b', kind: 'networkSwitch', name: 'S', label: 'Switch', x: 300, y: 200, cloud: false, ports: [] },
|
||||
{ id: 'c', kind: 'notebook', name: 'N', label: 'N', x: 500, y: 60, cloud: false, ports: [] },
|
||||
],
|
||||
links: [
|
||||
{ id: 'l1', sourceNode: 'a', sourcePort: 'p', targetNode: 'b', targetPort: 'q', wireless: false },
|
||||
{ id: 'l2', sourceNode: 'c', sourcePort: 'p', targetNode: 'b', targetPort: 'r', wireless: true },
|
||||
// Its device gone (before the janitor): left out, as the surface leaves it.
|
||||
{ id: 'l3', sourceNode: 'a', sourcePort: 'p', targetNode: 'gone', targetPort: 'r', wireless: false },
|
||||
],
|
||||
docs: [
|
||||
doc({ id: 'r', kind: 'rectangle', x: 60, y: 40, width: 200, height: 120, color: { red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 } }),
|
||||
doc({ id: 't', kind: 'text', x: 70, y: 300, width: 64, height: 40, text: 'Netz A123 B', size: 18, italic: true, color: { red: 0, green: 0, blue: 0, alpha: 1 } }),
|
||||
],
|
||||
};
|
||||
const ICONS = Object.fromEntries(['server', 'laptop', 'switch', 'cloud', 'router', 'gateway', 'modem'].map((name) => [name, `icon:${name}`])) as unknown as Record<IconName, CanvasImageSource>;
|
||||
|
||||
describe('painting the image', () => {
|
||||
const labelWidth = (text: string) => text.length * 7;
|
||||
|
||||
it("counts the devices, the cables between them and the documentation, as Java's panels do", () => {
|
||||
const { boxes, parts } = networkParts(VIEW, labelWidth);
|
||||
const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!);
|
||||
expect(a).toEqual(deviceBox(VIEW.nodes[0]!, labelWidth('Rechner A')));
|
||||
expect(parts).toEqual([a, b, c, cableFrame(a!.center, b!.center), cableFrame(c!.center, b!.center), { x: 60, y: 40, width: 200, height: 120 }, { x: 70, y: 300, width: 64, height: 40 }]);
|
||||
});
|
||||
|
||||
it('measures the labels and the footer in Java’s font', () => {
|
||||
const { context } = recorder();
|
||||
const { layout } = measureNetworkImage(context, VIEW, 'netz.fls');
|
||||
expect(context.font).toBe(JAVA_LABEL_FONT);
|
||||
const { parts } = networkParts(VIEW, labelWidth);
|
||||
expect(layout).toEqual(exportLayout(parts, { width: 8 * 7 + 20, height: JAVA_LABEL_FONT_HEIGHT }));
|
||||
});
|
||||
|
||||
it('paints the documentation, then the cables, then the devices, then the footer, on the cut-out', () => {
|
||||
const { context, calls } = recorder();
|
||||
const layout = paintNetworkImage(context, VIEW, ICONS, 'netz.fls');
|
||||
const { boxes } = networkParts(VIEW, labelWidth);
|
||||
const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!);
|
||||
const named = (name: string) => calls.filter((call) => call[0] === name);
|
||||
expect(calls[1]).toEqual(['translate', -layout.image.x, -layout.image.y]);
|
||||
|
||||
// The rectangle as the design shows it: inset by 1, corners of 5, opaque and lighter, its border darker.
|
||||
const pink = VIEW.docs[0]!.color;
|
||||
expect(named('roundRect')).toEqual([['roundRect', 61, 41, 198, 118, 5]]);
|
||||
expect(named('fill')).toEqual([['fill', rectangleFill(pink, false)]]);
|
||||
expect(named('stroke')[0]).toEqual(['stroke', rectangleBorder(pink), 1]);
|
||||
|
||||
// The text inside its frame, in its font, broken to its width less the padding (62: "Netz A123" is 63 wide).
|
||||
expect(named('rect')).toEqual([['rect', 70, 300, 64, 40]]);
|
||||
const words = named('fillText').filter((call) => String(call[4]).startsWith('italic'));
|
||||
expect(words).toEqual([
|
||||
// A line the font's ascent and descent high (16.2 + 4.5).
|
||||
['fillText', 'Netz ', 71, 301 + 16.2, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'],
|
||||
['fillText', 'A123 B', 71, 301 + 16.2 + 20.7, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'],
|
||||
]);
|
||||
|
||||
// The cables: Java's curve, a wireless one grey and dashed; the one without its device left out.
|
||||
expect(named('moveTo')).toEqual([
|
||||
['moveTo', a!.center.x, a!.center.y],
|
||||
['moveTo', c!.center.x, c!.center.y],
|
||||
]);
|
||||
const [first, second] = [cableControl(a!.center, b!.center), cableControl(c!.center, b!.center)];
|
||||
expect(named('quadraticCurveTo')).toEqual([
|
||||
['quadraticCurveTo', first.x, first.y, b!.center.x, b!.center.y],
|
||||
['quadraticCurveTo', second.x, second.y, b!.center.x, b!.center.y],
|
||||
]);
|
||||
expect(named('stroke').slice(1)).toEqual([
|
||||
['stroke', CABLE_COLOR, 2],
|
||||
['stroke', WIRELESS_COLOR, 2],
|
||||
]);
|
||||
expect(named('setLineDash')).toEqual([
|
||||
['setLineDash', []],
|
||||
['setLineDash', [10]],
|
||||
['setLineDash', []],
|
||||
]);
|
||||
|
||||
// The devices: Java's icon, the label in the middle below it, black.
|
||||
expect(named('drawImage')).toEqual([
|
||||
['drawImage', 'icon:server', a!.iconRect.x, a!.iconRect.y, 64, 94],
|
||||
['drawImage', 'icon:switch', b!.iconRect.x, b!.iconRect.y, 70, 52],
|
||||
['drawImage', 'icon:laptop', c!.iconRect.x, c!.iconRect.y, 84, 80],
|
||||
]);
|
||||
const labels = named('fillText').filter((call) => call[4] === JAVA_LABEL_FONT);
|
||||
expect(labels).toEqual([
|
||||
['fillText', 'Rechner A', a!.label.x, a!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
|
||||
['fillText', 'Switch', b!.label.x, b!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
|
||||
['fillText', 'N', c!.label.x, c!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
|
||||
// The footer: at its left, its baseline the font's ascent down, light grey.
|
||||
['fillText', 'netz.fls', layout.footer.x, layout.footer.y + 14, JAVA_LABEL_FONT, FOOTER_COLOR, 'left'],
|
||||
]);
|
||||
|
||||
// In that order, and the context left as it was found.
|
||||
const order = calls.map((call) => call[0]);
|
||||
expect(order.indexOf('roundRect')).toBeLessThan(order.indexOf('rect'));
|
||||
expect(order.lastIndexOf('fillText', order.indexOf('moveTo'))).toBeGreaterThan(order.indexOf('rect'));
|
||||
expect(order.lastIndexOf('quadraticCurveTo')).toBeLessThan(order.indexOf('drawImage'));
|
||||
expect(order.filter((name) => name === 'save').length).toBe(order.filter((name) => name === 'restore').length);
|
||||
expect(order.at(-1)).toBe('restore');
|
||||
expect(layout.footer.y).toBe(340 + 20);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user