Files
mdg-filius/packages/editor/src/canvas/networkImage.ts
T
julian.kiedaischandClaude Opus 5.5 7e68414bb2 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>
2026-10-04 11:54:09 +00:00

211 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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` };
}