@adecore/diagram 0.0.1 → 0.17.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +35 -0
- package/dist/across.d.ts +2 -0
- package/dist/across.js +202 -0
- package/dist/graph.d.ts +40 -0
- package/dist/graph.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/layout.d.ts +55 -0
- package/dist/layout.js +668 -0
- package/dist/order.d.ts +2 -0
- package/dist/order.js +224 -0
- package/dist/protocol.d.ts +254 -0
- package/dist/protocol.js +111 -0
- package/dist/reading-order.d.ts +2 -0
- package/dist/reading-order.js +23 -0
- package/dist/route.d.ts +33 -0
- package/dist/route.js +206 -0
- package/dist/shapes.d.ts +20 -0
- package/dist/shapes.js +95 -0
- package/dist/svg.d.ts +16 -0
- package/dist/svg.js +67 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.js +86 -0
- package/package.json +41 -2
- package/src/across.ts +213 -0
- package/src/graph.ts +70 -0
- package/src/index.ts +30 -0
- package/src/layout.ts +778 -0
- package/src/order.ts +235 -0
- package/src/protocol.ts +135 -0
- package/src/reading-order.ts +26 -0
- package/src/route.ts +266 -0
- package/src/shapes.ts +119 -0
- package/src/svg.ts +103 -0
- package/src/text.ts +92 -0
package/src/shapes.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { DiagramEdgeStyle, DiagramShape } from './protocol.ts';
|
|
2
|
+
import { CYLINDER_LID, EDGE_LABEL_PADDING, type EdgeLabelBox, type NodeBox, type Point, type Rect } from './layout.ts';
|
|
3
|
+
import { LABEL_LINE, LABEL_SIZE, SUB_LINE, SUB_SIZE } from './text.ts';
|
|
4
|
+
|
|
5
|
+
const ROUND_RADIUS = 10;
|
|
6
|
+
/* How deep the lid of a cylinder is, as a share of its height. */
|
|
7
|
+
const LID_RATIO = 0.18;
|
|
8
|
+
const HEAD_LENGTH = 10;
|
|
9
|
+
const HEAD_WIDTH = 6;
|
|
10
|
+
|
|
11
|
+
export interface ShapePaths {
|
|
12
|
+
body: string;
|
|
13
|
+
/* A line drawn on top of the body and never filled: the front edge of a cylinder's lid. */
|
|
14
|
+
detail: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function roundedRect(box: Rect, radius: number): string {
|
|
18
|
+
const r = Math.round(Math.min(radius, box.w / 2, box.h / 2));
|
|
19
|
+
const right = box.x + box.w;
|
|
20
|
+
const bottom = box.y + box.h;
|
|
21
|
+
return [
|
|
22
|
+
`M${box.x + r} ${box.y}`,
|
|
23
|
+
`H${right - r}`,
|
|
24
|
+
`A${r} ${r} 0 0 1 ${right} ${box.y + r}`,
|
|
25
|
+
`V${bottom - r}`,
|
|
26
|
+
`A${r} ${r} 0 0 1 ${right - r} ${bottom}`,
|
|
27
|
+
`H${box.x + r}`,
|
|
28
|
+
`A${r} ${r} 0 0 1 ${box.x} ${bottom - r}`,
|
|
29
|
+
`V${box.y + r}`,
|
|
30
|
+
`A${r} ${r} 0 0 1 ${box.x + r} ${box.y}`,
|
|
31
|
+
'Z'
|
|
32
|
+
].join(' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePaths {
|
|
36
|
+
const right = box.x + box.w;
|
|
37
|
+
const bottom = box.y + box.h;
|
|
38
|
+
const middleX = Math.round(box.x + box.w / 2);
|
|
39
|
+
const middleY = Math.round(box.y + box.h / 2);
|
|
40
|
+
switch (shape ?? 'rect') {
|
|
41
|
+
case 'round':
|
|
42
|
+
return { body: roundedRect(box, ROUND_RADIUS), detail: null };
|
|
43
|
+
case 'pill':
|
|
44
|
+
return { body: roundedRect(box, box.h / 2), detail: null };
|
|
45
|
+
case 'diamond':
|
|
46
|
+
return { body: `M${middleX} ${box.y} L${right} ${middleY} L${middleX} ${bottom} L${box.x} ${middleY} Z`, detail: null };
|
|
47
|
+
case 'cylinder': {
|
|
48
|
+
const lid = Math.round(box.h * LID_RATIO);
|
|
49
|
+
const rx = Math.round(box.w / 2);
|
|
50
|
+
const ry = Math.round(lid / 2);
|
|
51
|
+
const top = box.y + ry;
|
|
52
|
+
const base = bottom - ry;
|
|
53
|
+
return {
|
|
54
|
+
body: `M${box.x} ${top} A${rx} ${ry} 0 0 1 ${right} ${top} V${base} A${rx} ${ry} 0 0 1 ${box.x} ${base} Z`,
|
|
55
|
+
detail: `M${box.x} ${top} A${rx} ${ry} 0 0 0 ${right} ${top}`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
return { body: `M${box.x} ${box.y} H${right} V${bottom} H${box.x} Z`, detail: null };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TextLine {
|
|
64
|
+
text: string;
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
size: number;
|
|
68
|
+
bold: boolean;
|
|
69
|
+
muted: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function textLinesOf(box: Pick<NodeBox, 'x' | 'y' | 'w' | 'h' | 'label' | 'sub'>, shape?: DiagramShape): TextLine[] {
|
|
73
|
+
const x = Math.round(box.x + box.w / 2);
|
|
74
|
+
const height = box.label.length * LABEL_LINE + box.sub.length * SUB_LINE;
|
|
75
|
+
// The lid of a cylinder takes the top of the box, so its text sits below it.
|
|
76
|
+
const top = Math.round(box.y + (box.h - height) / 2) + (shape === 'cylinder' ? CYLINDER_LID / 2 : 0);
|
|
77
|
+
const label = box.label.map((text, index) => ({ text, x, y: top + index * LABEL_LINE + 14, size: LABEL_SIZE, bold: true, muted: false }));
|
|
78
|
+
const subTop = top + box.label.length * LABEL_LINE;
|
|
79
|
+
const sub = box.sub.map((text, index) => ({ text, x, y: subTop + index * SUB_LINE + 12, size: SUB_SIZE, bold: false, muted: true }));
|
|
80
|
+
return [...label, ...sub];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function edgeLabelLinesOf(label: EdgeLabelBox): TextLine[] {
|
|
84
|
+
const x = Math.round(label.x + label.w / 2);
|
|
85
|
+
return label.lines.map((text, index) => ({
|
|
86
|
+
text,
|
|
87
|
+
x,
|
|
88
|
+
y: label.y + EDGE_LABEL_PADDING.y + index * SUB_LINE + 12,
|
|
89
|
+
size: SUB_SIZE,
|
|
90
|
+
bold: false,
|
|
91
|
+
muted: false
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function edgePath(points: readonly Point[]): string {
|
|
96
|
+
return points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x} ${point.y}`).join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function arrowHeadPath(points: readonly Point[]): string {
|
|
100
|
+
const tip = points.at(-1)!;
|
|
101
|
+
const before = points.at(-2) ?? tip;
|
|
102
|
+
const length = Math.hypot(tip.x - before.x, tip.y - before.y) || 1;
|
|
103
|
+
const ux = (tip.x - before.x) / length;
|
|
104
|
+
const uy = (tip.y - before.y) / length;
|
|
105
|
+
const baseX = tip.x - ux * HEAD_LENGTH;
|
|
106
|
+
const baseY = tip.y - uy * HEAD_LENGTH;
|
|
107
|
+
const round = Math.round;
|
|
108
|
+
return `M${tip.x} ${tip.y} L${round(baseX - uy * HEAD_WIDTH)} ${round(baseY + ux * HEAD_WIDTH)} L${round(baseX + uy * HEAD_WIDTH)} ${round(baseY - ux * HEAD_WIDTH)} Z`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function dashOf(style: DiagramEdgeStyle | undefined): string | null {
|
|
112
|
+
if (style === 'dashed') {
|
|
113
|
+
return '8 6';
|
|
114
|
+
}
|
|
115
|
+
if (style === 'dotted') {
|
|
116
|
+
return '2 5';
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
package/src/svg.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { DrawingColor } from '@adecore/drawing/protocol';
|
|
2
|
+
import type { DiagramDocument } from './protocol.ts';
|
|
3
|
+
import { DEFAULT_FONT_STACKS, DEFAULT_PALETTE, DEFAULT_PAPER } from '@adecore/drawing';
|
|
4
|
+
import { GROUP_LABEL_BAND, layoutOf, type DiagramLayout } from './layout.ts';
|
|
5
|
+
import { arrowHeadPath, dashOf, edgeLabelLinesOf, edgePath, shapePaths, textLinesOf } from './shapes.ts';
|
|
6
|
+
import { SUB_SIZE } from './text.ts';
|
|
7
|
+
|
|
8
|
+
export interface DiagramSvgOptions {
|
|
9
|
+
/* What every palette name is in the theme this export is made in. */
|
|
10
|
+
palette?: Record<DrawingColor, string>;
|
|
11
|
+
/* The pale fill behind a node of each tone. */
|
|
12
|
+
paper?: Record<DrawingColor, string>;
|
|
13
|
+
/* The page behind the diagram, or null for a transparent one; edge labels are haloed in it. */
|
|
14
|
+
background?: string | null;
|
|
15
|
+
/* World units around the diagram, so nothing touches the edge. */
|
|
16
|
+
margin?: number;
|
|
17
|
+
font?: string;
|
|
18
|
+
/* A layout already computed for this document, so a caller that drew it does not compute it twice. */
|
|
19
|
+
layout?: DiagramLayout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_DIAGRAM_MARGIN = 32;
|
|
23
|
+
|
|
24
|
+
/* What a node, a group and an edge wear when the file names no tone. */
|
|
25
|
+
export const DEFAULT_NODE_TONE: DrawingColor = 'ink';
|
|
26
|
+
export const DEFAULT_GROUP_TONE: DrawingColor = 'muted';
|
|
27
|
+
export const DEFAULT_EDGE_TONE: DrawingColor = 'muted';
|
|
28
|
+
|
|
29
|
+
// XML has no way to write a control character other than tab and the line breaks, not even escaped.
|
|
30
|
+
// oxlint-disable-next-line no-control-regex
|
|
31
|
+
const NOT_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
|
|
32
|
+
|
|
33
|
+
function escapeXml(value: string): string {
|
|
34
|
+
return value.replace(NOT_XML, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* The whole diagram as one SVG string, for an export and for what the daemon hands an agent. The
|
|
39
|
+
* view in the app draws the same layout and the same paths as elements of its own, so the two only
|
|
40
|
+
* differ in where the colors come from.
|
|
41
|
+
*/
|
|
42
|
+
export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups' | 'edges'>, options: DiagramSvgOptions = {}): string {
|
|
43
|
+
const palette = options.palette ?? DEFAULT_PALETTE;
|
|
44
|
+
const paper = options.paper ?? DEFAULT_PAPER;
|
|
45
|
+
const margin = options.margin ?? DEFAULT_DIAGRAM_MARGIN;
|
|
46
|
+
const font = escapeXml(options.font ?? DEFAULT_FONT_STACKS.sans);
|
|
47
|
+
const layout = options.layout ?? layoutOf(document);
|
|
48
|
+
const nodes = new Map(document.nodes.map((node) => [node.id, node]));
|
|
49
|
+
const groups = new Map(document.groups.map((group) => [group.id, group]));
|
|
50
|
+
|
|
51
|
+
const x = layout.bounds.x - margin;
|
|
52
|
+
const y = layout.bounds.y - margin;
|
|
53
|
+
const w = layout.bounds.w + margin * 2;
|
|
54
|
+
const h = layout.bounds.h + margin * 2;
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
if (options.background) {
|
|
57
|
+
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const box of layout.groups) {
|
|
61
|
+
const group = groups.get(box.id)!;
|
|
62
|
+
const tone = palette[group.tone ?? DEFAULT_GROUP_TONE];
|
|
63
|
+
parts.push(
|
|
64
|
+
`<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="12" fill="none" stroke="${tone}" stroke-width="1" stroke-dasharray="6 4"/>`,
|
|
65
|
+
`<text x="${box.labelBox.x}" y="${box.y + Math.round(GROUP_LABEL_BAND * 0.7)}" font-size="${SUB_SIZE}" font-weight="600" fill="${tone}">${escapeXml(group.label)}</text>`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const route of layout.edges) {
|
|
70
|
+
const edge = document.edges[route.index]!;
|
|
71
|
+
const tone = palette[edge.tone ?? DEFAULT_EDGE_TONE];
|
|
72
|
+
const dash = dashOf(edge.style);
|
|
73
|
+
parts.push(
|
|
74
|
+
`<path d="${edgePath(route.points)}" fill="none" stroke="${tone}" stroke-width="2" stroke-linejoin="round"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`,
|
|
75
|
+
`<path d="${arrowHeadPath(route.points)}" fill="${tone}"/>`
|
|
76
|
+
);
|
|
77
|
+
if (route.label) {
|
|
78
|
+
const halo = options.background ? ` stroke="${options.background}" stroke-width="4" paint-order="stroke"` : '';
|
|
79
|
+
for (const line of edgeLabelLinesOf(route.label)) {
|
|
80
|
+
parts.push(
|
|
81
|
+
`<text x="${line.x}" y="${line.y}" font-size="${line.size}" text-anchor="middle" fill="${tone}"${halo}>${escapeXml(line.text)}</text>`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const box of layout.nodes) {
|
|
88
|
+
const node = nodes.get(box.id)!;
|
|
89
|
+
const tone = node.tone ?? DEFAULT_NODE_TONE;
|
|
90
|
+
const paths = shapePaths(node.shape, box);
|
|
91
|
+
parts.push(`<path d="${paths.body}" fill="${paper[tone]}" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
92
|
+
if (paths.detail) {
|
|
93
|
+
parts.push(`<path d="${paths.detail}" fill="none" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
94
|
+
}
|
|
95
|
+
for (const line of textLinesOf(box, node.shape)) {
|
|
96
|
+
parts.push(
|
|
97
|
+
`<text x="${line.x}" y="${line.y}" font-size="${line.size}"${line.bold ? ' font-weight="600"' : ''} text-anchor="middle" fill="${line.muted ? palette.muted : palette.ink}">${escapeXml(line.text)}</text>`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}" font-family="${font}">${parts.join('')}</svg>`;
|
|
103
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const LABEL_SIZE = 14;
|
|
2
|
+
export const SUB_SIZE = 12;
|
|
3
|
+
export const LABEL_LINE = 18;
|
|
4
|
+
export const SUB_LINE = 16;
|
|
5
|
+
|
|
6
|
+
const NARROW = new Set([..."ijl.,:;!|'"]);
|
|
7
|
+
const SEMI = new Set([...' ftrI-()[]{}/\\`"*']);
|
|
8
|
+
const WIDE_LOWER = new Set([...'mw']);
|
|
9
|
+
const WIDE = new Set([...'MW@%&']);
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The width of a glyph as a share of the font size, per class of character. Measured in Chromium
|
|
13
|
+
* for the system face (SF on macOS) and Helvetica as a stand-in for Inter and Segoe UI, taking the
|
|
14
|
+
* wider of the two at weight 600, so an estimate errs towards a box that is a little too wide.
|
|
15
|
+
*/
|
|
16
|
+
function ratioOf(char: string): number {
|
|
17
|
+
if (NARROW.has(char)) {
|
|
18
|
+
return 0.3;
|
|
19
|
+
}
|
|
20
|
+
if (SEMI.has(char)) {
|
|
21
|
+
return 0.4;
|
|
22
|
+
}
|
|
23
|
+
if (WIDE_LOWER.has(char)) {
|
|
24
|
+
return 0.86;
|
|
25
|
+
}
|
|
26
|
+
if (WIDE.has(char)) {
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
if (char >= 'a' && char <= 'z') {
|
|
30
|
+
return 0.6;
|
|
31
|
+
}
|
|
32
|
+
if (char >= 'A' && char <= 'Z') {
|
|
33
|
+
return 0.74;
|
|
34
|
+
}
|
|
35
|
+
if (char >= '0' && char <= '9') {
|
|
36
|
+
return 0.64;
|
|
37
|
+
}
|
|
38
|
+
// CJK and everything after it is set on a full em; any other letter is taken as a wide Latin one.
|
|
39
|
+
return char.codePointAt(0)! >= 0x2e80 ? 1 : 0.64;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REGULAR = 0.94;
|
|
43
|
+
|
|
44
|
+
/* No DOM in this package, so text is estimated rather than measured, the same way on every machine. */
|
|
45
|
+
export function estimateTextWidth(text: string, size: number, bold = false): number {
|
|
46
|
+
let units = 0;
|
|
47
|
+
for (const char of text) {
|
|
48
|
+
units += ratioOf(char);
|
|
49
|
+
}
|
|
50
|
+
return Math.ceil(units * size * (bold ? 1 : REGULAR));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function breakWord(word: string, size: number, bold: boolean, maxWidth: number): string[] {
|
|
54
|
+
const pieces: string[] = [];
|
|
55
|
+
let piece = '';
|
|
56
|
+
for (const char of word) {
|
|
57
|
+
if (piece !== '' && estimateTextWidth(piece + char, size, bold) > maxWidth) {
|
|
58
|
+
pieces.push(piece);
|
|
59
|
+
piece = '';
|
|
60
|
+
}
|
|
61
|
+
piece += char;
|
|
62
|
+
}
|
|
63
|
+
pieces.push(piece);
|
|
64
|
+
return pieces;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function wrapText(text: string, size: number, bold: boolean, maxWidth: number): string[] {
|
|
68
|
+
const words = text
|
|
69
|
+
.trim()
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter((word) => word !== '');
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
let line = '';
|
|
74
|
+
for (const word of words) {
|
|
75
|
+
const pieces = estimateTextWidth(word, size, bold) > maxWidth ? breakWord(word, size, bold, maxWidth) : [word];
|
|
76
|
+
for (const piece of pieces) {
|
|
77
|
+
const joined = line === '' ? piece : `${line} ${piece}`;
|
|
78
|
+
if (line !== '' && estimateTextWidth(joined, size, bold) > maxWidth) {
|
|
79
|
+
lines.push(line);
|
|
80
|
+
line = piece;
|
|
81
|
+
} else {
|
|
82
|
+
line = joined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
lines.push(line);
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function widestLine(lines: readonly string[], size: number, bold: boolean): number {
|
|
91
|
+
return lines.reduce((widest, line) => Math.max(widest, estimateTextWidth(line, size, bold)), 0);
|
|
92
|
+
}
|