@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/dist/route.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export const TRACK_GAP = 12;
|
|
2
|
+
/* Space between the lane of labels and the first track. */
|
|
3
|
+
const LANE_GAP = 12;
|
|
4
|
+
/* Space between two labels side by side in a lane. */
|
|
5
|
+
const LABEL_GAP = 8;
|
|
6
|
+
/* How far a label keeps from the line it belongs to. */
|
|
7
|
+
const LABEL_OFFSET = 4;
|
|
8
|
+
export const MIN_CHANNEL = 64;
|
|
9
|
+
/* Two horizontal lines closer than this read as one. */
|
|
10
|
+
const COLLINEAR = 4;
|
|
11
|
+
/* Two vertical ranges closer than this need tracks of their own. */
|
|
12
|
+
const RANGE_CLEARANCE = 8;
|
|
13
|
+
function overlaps(left, right) {
|
|
14
|
+
return left.x < right.x + right.w && right.x < left.x + left.w && left.y < right.y + right.h && right.y < left.y + left.h;
|
|
15
|
+
}
|
|
16
|
+
function inflate(rect, by) {
|
|
17
|
+
return { x: rect.x - by, y: rect.y - by, w: rect.w + by * 2, h: rect.h + by * 2 };
|
|
18
|
+
}
|
|
19
|
+
/*
|
|
20
|
+
* How badly two orthogonal polylines get in each other's way: a crossing counts once, a stretch where
|
|
21
|
+
* they run on top of each other counts four times, since that is the one a reader cannot follow.
|
|
22
|
+
*/
|
|
23
|
+
export function conflictsOf(left, right) {
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (let i = 1; i < left.length; i++) {
|
|
26
|
+
const a0 = left[i - 1];
|
|
27
|
+
const a1 = left[i];
|
|
28
|
+
for (let j = 1; j < right.length; j++) {
|
|
29
|
+
const b0 = right[j - 1];
|
|
30
|
+
const b1 = right[j];
|
|
31
|
+
const aFlat = a0.y === a1.y;
|
|
32
|
+
const bFlat = b0.y === b1.y;
|
|
33
|
+
if (aFlat !== bFlat) {
|
|
34
|
+
const [h0, h1, v0, v1] = aFlat ? [a0, a1, b0, b1] : [b0, b1, a0, a1];
|
|
35
|
+
if (v0.x > Math.min(h0.x, h1.x) && v0.x < Math.max(h0.x, h1.x) && h0.y > Math.min(v0.y, v1.y) && h0.y < Math.max(v0.y, v1.y)) {
|
|
36
|
+
total += 1;
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const [along0, along1, other0, other1, gap] = aFlat
|
|
41
|
+
? [Math.min(a0.x, a1.x), Math.max(a0.x, a1.x), Math.min(b0.x, b1.x), Math.max(b0.x, b1.x), Math.abs(a0.y - b0.y)]
|
|
42
|
+
: [Math.min(a0.y, a1.y), Math.max(a0.y, a1.y), Math.min(b0.y, b1.y), Math.max(b0.y, b1.y), Math.abs(a0.x - b0.x)];
|
|
43
|
+
if (gap < COLLINEAR && Math.min(along1, other1) > Math.max(along0, other0)) {
|
|
44
|
+
total += 4;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return total;
|
|
49
|
+
}
|
|
50
|
+
/* A hop drawn on a track in a channel of unit tracks, only to compare two orders of the tracks. */
|
|
51
|
+
function sketchOf(hop, track) {
|
|
52
|
+
return [
|
|
53
|
+
{ x: 0, y: hop.ya },
|
|
54
|
+
{ x: track * TRACK_GAP, y: hop.ya },
|
|
55
|
+
{ x: track * TRACK_GAP, y: hop.yb },
|
|
56
|
+
{ x: TRACK_GAP * 3, y: hop.yb }
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
/*
|
|
60
|
+
* The inside of one channel between two layers. Every hop that changes place across the flow gets a
|
|
61
|
+
* vertical track of its own where its range meets another's, in the order that crosses the fewest
|
|
62
|
+
* lines; labels go in a lane before the tracks, beside the line their hop leaves on, and the lane
|
|
63
|
+
* grows a column where two labels would otherwise cover each other.
|
|
64
|
+
*/
|
|
65
|
+
export function routeChannel(input) {
|
|
66
|
+
const vertical = input.hops.filter((hop) => hop.ya !== hop.yb);
|
|
67
|
+
const count = vertical.length;
|
|
68
|
+
const near = (left, right) => Math.min(Math.max(left.ya, left.yb), Math.max(right.ya, right.yb)) + RANGE_CLEARANCE >
|
|
69
|
+
Math.max(Math.min(left.ya, left.yb), Math.min(right.ya, right.yb));
|
|
70
|
+
// cost[i * count + j]: how much i and j conflict when i's track is left of j's.
|
|
71
|
+
const cost = new Array(count * count).fill(0);
|
|
72
|
+
for (let i = 0; i < count; i++) {
|
|
73
|
+
for (let j = 0; j < count; j++) {
|
|
74
|
+
if (i !== j && near(vertical[i], vertical[j])) {
|
|
75
|
+
cost[i * count + j] = conflictsOf(sketchOf(vertical[i], 1), sketchOf(vertical[j], 2));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const score = vertical.map((_, i) => {
|
|
80
|
+
let sum = 0;
|
|
81
|
+
for (let j = 0; j < count; j++) {
|
|
82
|
+
sum += cost[i * count + j] - cost[j * count + i];
|
|
83
|
+
}
|
|
84
|
+
return sum;
|
|
85
|
+
});
|
|
86
|
+
const order = vertical.map((_, i) => i).sort((left, right) => score[left] - score[right] || left - right);
|
|
87
|
+
for (let pass = 0; pass < count; pass++) {
|
|
88
|
+
let swapped = false;
|
|
89
|
+
for (let p = 0; p + 1 < count; p++) {
|
|
90
|
+
const left = order[p];
|
|
91
|
+
const right = order[p + 1];
|
|
92
|
+
if (cost[right * count + left] < cost[left * count + right]) {
|
|
93
|
+
order[p] = right;
|
|
94
|
+
order[p + 1] = left;
|
|
95
|
+
swapped = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!swapped) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const track = new Array(count).fill(0);
|
|
103
|
+
let tracks = 0;
|
|
104
|
+
order.forEach((index, p) => {
|
|
105
|
+
let at = 0;
|
|
106
|
+
for (let q = 0; q < p; q++) {
|
|
107
|
+
const other = order[q];
|
|
108
|
+
if (near(vertical[other], vertical[index])) {
|
|
109
|
+
at = Math.max(at, track[other] + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
track[index] = at;
|
|
113
|
+
tracks = Math.max(tracks, at + 1);
|
|
114
|
+
});
|
|
115
|
+
// Labels are placed in two frames before the width is known: the lane before the tracks, and the tracks with the lane after them.
|
|
116
|
+
const slotAlong = input.labels.reduce((widest, label) => Math.max(widest, label.along), 0);
|
|
117
|
+
const slotStep = slotAlong + LABEL_GAP;
|
|
118
|
+
const tracksSpan = Math.max(0, tracks - 1) * TRACK_GAP;
|
|
119
|
+
const afterStart = tracks > 0 ? tracksSpan + LANE_GAP : 0;
|
|
120
|
+
const trackOf = new Map();
|
|
121
|
+
vertical.forEach((hop, index) => trackOf.set(hop.hop, track[index] * TRACK_GAP));
|
|
122
|
+
const placed = [];
|
|
123
|
+
const chosen = new Map();
|
|
124
|
+
let slotsBefore = input.labels.length > 0 ? 1 : 0;
|
|
125
|
+
let slotsAfter = 0;
|
|
126
|
+
const touches = (x0, x1, y0, y1, rect) => x1 >= rect.x - 3 && x0 <= rect.x + rect.w + 3 && y1 >= rect.y - 3 && y0 <= rect.y + rect.h + 3;
|
|
127
|
+
const blocked = (zone, rect) => placed.some((other) => other.zone === zone && overlaps(inflate(other.rect, 4), rect)) ||
|
|
128
|
+
input.obstacles.some((obstacle) => obstacle.y < rect.y + rect.h && rect.y < obstacle.y + obstacle.h);
|
|
129
|
+
const onLine = (zone, rect, label) => input.hops.some((hop) => {
|
|
130
|
+
if (zone === 'before') {
|
|
131
|
+
return hop.hop !== label.hop && hop.ya > rect.y - 3 && hop.ya < rect.y + rect.h + 3;
|
|
132
|
+
}
|
|
133
|
+
const own = hop.hop === label.hop;
|
|
134
|
+
const at = trackOf.get(hop.hop);
|
|
135
|
+
if (at === undefined) {
|
|
136
|
+
return !own && touches(-Infinity, Infinity, hop.ya, hop.ya, rect);
|
|
137
|
+
}
|
|
138
|
+
return ((!own && touches(-Infinity, at, hop.ya, hop.ya, rect)) ||
|
|
139
|
+
(!own && touches(at, at, Math.min(hop.ya, hop.yb), Math.max(hop.ya, hop.yb), rect)) ||
|
|
140
|
+
touches(at, Infinity, hop.yb, hop.yb, rect));
|
|
141
|
+
});
|
|
142
|
+
const inLane = (label, x, y, above) => ({
|
|
143
|
+
x: x + Math.floor((slotAlong - label.along) / 2),
|
|
144
|
+
y: above ? y - LABEL_OFFSET - label.across : y + LABEL_OFFSET,
|
|
145
|
+
w: label.along,
|
|
146
|
+
h: label.across
|
|
147
|
+
});
|
|
148
|
+
for (const label of input.labels) {
|
|
149
|
+
const hop = input.hops.find((candidate) => candidate.hop === label.hop);
|
|
150
|
+
const before = (slot) => [true, false].map((above) => ({ zone: 'before', rect: inLane(label, slot * slotStep, label.y, above) }));
|
|
151
|
+
const after = (slot) => hop ? [true, false].map((above) => ({ zone: 'middle', rect: inLane(label, afterStart + slot * slotStep, hop.yb, above) })) : [];
|
|
152
|
+
const at = trackOf.get(label.hop);
|
|
153
|
+
const beside = hop && at !== undefined
|
|
154
|
+
? [
|
|
155
|
+
{
|
|
156
|
+
zone: 'middle',
|
|
157
|
+
rect: { x: at + LABEL_OFFSET, y: Math.round((hop.ya + hop.yb) / 2 - label.across / 2), w: label.along, h: label.across }
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
: [];
|
|
161
|
+
const existing = [
|
|
162
|
+
...Array.from({ length: slotsBefore }, (_, slot) => before(slot)).flat(),
|
|
163
|
+
...beside,
|
|
164
|
+
...Array.from({ length: slotsAfter }, (_, slot) => after(slot)).flat()
|
|
165
|
+
];
|
|
166
|
+
const clear = (candidate) => !blocked(candidate.zone, candidate.rect) && !onLine(candidate.zone, candidate.rect, label);
|
|
167
|
+
let choice = existing.find(clear);
|
|
168
|
+
if (!choice) {
|
|
169
|
+
choice = before(slotsBefore).find(clear);
|
|
170
|
+
if (choice) {
|
|
171
|
+
slotsBefore += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!choice) {
|
|
175
|
+
choice = after(slotsAfter).find(clear);
|
|
176
|
+
if (choice) {
|
|
177
|
+
slotsAfter += 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// A place beside no line may not exist, since every line in a lane runs its whole width; a free place is enough.
|
|
181
|
+
choice ??= existing.find((candidate) => !blocked(candidate.zone, candidate.rect));
|
|
182
|
+
if (!choice) {
|
|
183
|
+
choice = before(slotsBefore)[0];
|
|
184
|
+
slotsBefore += 1;
|
|
185
|
+
}
|
|
186
|
+
placed.push(choice);
|
|
187
|
+
chosen.set(label.edge, choice);
|
|
188
|
+
}
|
|
189
|
+
// Only what was put in it: a channel whose labels all found room beside their tracks keeps no lane.
|
|
190
|
+
const laneWidth = Math.max(0, ...placed.filter((entry) => entry.zone === 'before').map((entry) => entry.rect.x + entry.rect.w));
|
|
191
|
+
const middleLabels = placed.filter((entry) => entry.zone === 'middle');
|
|
192
|
+
const hasMiddle = tracks > 0 || middleLabels.length > 0;
|
|
193
|
+
const middleWidth = Math.max(tracksSpan, slotsAfter > 0 ? afterStart + slotsAfter * slotStep - LABEL_GAP : 0, ...middleLabels.map((entry) => entry.rect.x + entry.rect.w));
|
|
194
|
+
const laneGap = laneWidth > 0 && hasMiddle ? LANE_GAP : 0;
|
|
195
|
+
const needed = input.clearBefore + laneWidth + laneGap + (hasMiddle ? middleWidth : 0) + input.clearAfter;
|
|
196
|
+
const width = Math.max(MIN_CHANNEL, needed);
|
|
197
|
+
const laneStart = input.start + input.clearBefore;
|
|
198
|
+
const middleStart = laneStart + laneWidth + laneGap + Math.floor((width - needed) / 2);
|
|
199
|
+
const trackX = new Map();
|
|
200
|
+
trackOf.forEach((x, hop) => trackX.set(hop, middleStart + x));
|
|
201
|
+
const labels = new Map();
|
|
202
|
+
chosen.forEach((entry, edge) => {
|
|
203
|
+
labels.set(edge, { ...entry.rect, x: entry.rect.x + (entry.zone === 'before' ? laneStart : middleStart) });
|
|
204
|
+
});
|
|
205
|
+
return { width, trackX, labels };
|
|
206
|
+
}
|
package/dist/shapes.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DiagramEdgeStyle, DiagramShape } from './protocol.ts';
|
|
2
|
+
import { type EdgeLabelBox, type NodeBox, type Point, type Rect } from './layout.ts';
|
|
3
|
+
export interface ShapePaths {
|
|
4
|
+
body: string;
|
|
5
|
+
detail: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePaths;
|
|
8
|
+
export interface TextLine {
|
|
9
|
+
text: string;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
size: number;
|
|
13
|
+
bold: boolean;
|
|
14
|
+
muted: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function textLinesOf(box: Pick<NodeBox, 'x' | 'y' | 'w' | 'h' | 'label' | 'sub'>, shape?: DiagramShape): TextLine[];
|
|
17
|
+
export declare function edgeLabelLinesOf(label: EdgeLabelBox): TextLine[];
|
|
18
|
+
export declare function edgePath(points: readonly Point[]): string;
|
|
19
|
+
export declare function arrowHeadPath(points: readonly Point[]): string;
|
|
20
|
+
export declare function dashOf(style: DiagramEdgeStyle | undefined): string | null;
|
package/dist/shapes.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CYLINDER_LID, EDGE_LABEL_PADDING } from './layout.js';
|
|
2
|
+
import { LABEL_LINE, LABEL_SIZE, SUB_LINE, SUB_SIZE } from './text.js';
|
|
3
|
+
const ROUND_RADIUS = 10;
|
|
4
|
+
/* How deep the lid of a cylinder is, as a share of its height. */
|
|
5
|
+
const LID_RATIO = 0.18;
|
|
6
|
+
const HEAD_LENGTH = 10;
|
|
7
|
+
const HEAD_WIDTH = 6;
|
|
8
|
+
function roundedRect(box, radius) {
|
|
9
|
+
const r = Math.round(Math.min(radius, box.w / 2, box.h / 2));
|
|
10
|
+
const right = box.x + box.w;
|
|
11
|
+
const bottom = box.y + box.h;
|
|
12
|
+
return [
|
|
13
|
+
`M${box.x + r} ${box.y}`,
|
|
14
|
+
`H${right - r}`,
|
|
15
|
+
`A${r} ${r} 0 0 1 ${right} ${box.y + r}`,
|
|
16
|
+
`V${bottom - r}`,
|
|
17
|
+
`A${r} ${r} 0 0 1 ${right - r} ${bottom}`,
|
|
18
|
+
`H${box.x + r}`,
|
|
19
|
+
`A${r} ${r} 0 0 1 ${box.x} ${bottom - r}`,
|
|
20
|
+
`V${box.y + r}`,
|
|
21
|
+
`A${r} ${r} 0 0 1 ${box.x + r} ${box.y}`,
|
|
22
|
+
'Z'
|
|
23
|
+
].join(' ');
|
|
24
|
+
}
|
|
25
|
+
export function shapePaths(shape, box) {
|
|
26
|
+
const right = box.x + box.w;
|
|
27
|
+
const bottom = box.y + box.h;
|
|
28
|
+
const middleX = Math.round(box.x + box.w / 2);
|
|
29
|
+
const middleY = Math.round(box.y + box.h / 2);
|
|
30
|
+
switch (shape ?? 'rect') {
|
|
31
|
+
case 'round':
|
|
32
|
+
return { body: roundedRect(box, ROUND_RADIUS), detail: null };
|
|
33
|
+
case 'pill':
|
|
34
|
+
return { body: roundedRect(box, box.h / 2), detail: null };
|
|
35
|
+
case 'diamond':
|
|
36
|
+
return { body: `M${middleX} ${box.y} L${right} ${middleY} L${middleX} ${bottom} L${box.x} ${middleY} Z`, detail: null };
|
|
37
|
+
case 'cylinder': {
|
|
38
|
+
const lid = Math.round(box.h * LID_RATIO);
|
|
39
|
+
const rx = Math.round(box.w / 2);
|
|
40
|
+
const ry = Math.round(lid / 2);
|
|
41
|
+
const top = box.y + ry;
|
|
42
|
+
const base = bottom - ry;
|
|
43
|
+
return {
|
|
44
|
+
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`,
|
|
45
|
+
detail: `M${box.x} ${top} A${rx} ${ry} 0 0 0 ${right} ${top}`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
return { body: `M${box.x} ${box.y} H${right} V${bottom} H${box.x} Z`, detail: null };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function textLinesOf(box, shape) {
|
|
53
|
+
const x = Math.round(box.x + box.w / 2);
|
|
54
|
+
const height = box.label.length * LABEL_LINE + box.sub.length * SUB_LINE;
|
|
55
|
+
// The lid of a cylinder takes the top of the box, so its text sits below it.
|
|
56
|
+
const top = Math.round(box.y + (box.h - height) / 2) + (shape === 'cylinder' ? CYLINDER_LID / 2 : 0);
|
|
57
|
+
const label = box.label.map((text, index) => ({ text, x, y: top + index * LABEL_LINE + 14, size: LABEL_SIZE, bold: true, muted: false }));
|
|
58
|
+
const subTop = top + box.label.length * LABEL_LINE;
|
|
59
|
+
const sub = box.sub.map((text, index) => ({ text, x, y: subTop + index * SUB_LINE + 12, size: SUB_SIZE, bold: false, muted: true }));
|
|
60
|
+
return [...label, ...sub];
|
|
61
|
+
}
|
|
62
|
+
export function edgeLabelLinesOf(label) {
|
|
63
|
+
const x = Math.round(label.x + label.w / 2);
|
|
64
|
+
return label.lines.map((text, index) => ({
|
|
65
|
+
text,
|
|
66
|
+
x,
|
|
67
|
+
y: label.y + EDGE_LABEL_PADDING.y + index * SUB_LINE + 12,
|
|
68
|
+
size: SUB_SIZE,
|
|
69
|
+
bold: false,
|
|
70
|
+
muted: false
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
export function edgePath(points) {
|
|
74
|
+
return points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x} ${point.y}`).join(' ');
|
|
75
|
+
}
|
|
76
|
+
export function arrowHeadPath(points) {
|
|
77
|
+
const tip = points.at(-1);
|
|
78
|
+
const before = points.at(-2) ?? tip;
|
|
79
|
+
const length = Math.hypot(tip.x - before.x, tip.y - before.y) || 1;
|
|
80
|
+
const ux = (tip.x - before.x) / length;
|
|
81
|
+
const uy = (tip.y - before.y) / length;
|
|
82
|
+
const baseX = tip.x - ux * HEAD_LENGTH;
|
|
83
|
+
const baseY = tip.y - uy * HEAD_LENGTH;
|
|
84
|
+
const round = Math.round;
|
|
85
|
+
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`;
|
|
86
|
+
}
|
|
87
|
+
export function dashOf(style) {
|
|
88
|
+
if (style === 'dashed') {
|
|
89
|
+
return '8 6';
|
|
90
|
+
}
|
|
91
|
+
if (style === 'dotted') {
|
|
92
|
+
return '2 5';
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
package/dist/svg.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DrawingColor } from '@adecore/drawing/protocol';
|
|
2
|
+
import type { DiagramDocument } from './protocol.ts';
|
|
3
|
+
import { type DiagramLayout } from './layout.ts';
|
|
4
|
+
export interface DiagramSvgOptions {
|
|
5
|
+
palette?: Record<DrawingColor, string>;
|
|
6
|
+
paper?: Record<DrawingColor, string>;
|
|
7
|
+
background?: string | null;
|
|
8
|
+
margin?: number;
|
|
9
|
+
font?: string;
|
|
10
|
+
layout?: DiagramLayout;
|
|
11
|
+
}
|
|
12
|
+
export declare const DEFAULT_DIAGRAM_MARGIN = 32;
|
|
13
|
+
export declare const DEFAULT_NODE_TONE: DrawingColor;
|
|
14
|
+
export declare const DEFAULT_GROUP_TONE: DrawingColor;
|
|
15
|
+
export declare const DEFAULT_EDGE_TONE: DrawingColor;
|
|
16
|
+
export declare function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups' | 'edges'>, options?: DiagramSvgOptions): string;
|
package/dist/svg.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DEFAULT_FONT_STACKS, DEFAULT_PALETTE, DEFAULT_PAPER } from '@adecore/drawing';
|
|
2
|
+
import { GROUP_LABEL_BAND, layoutOf } from './layout.js';
|
|
3
|
+
import { arrowHeadPath, dashOf, edgeLabelLinesOf, edgePath, shapePaths, textLinesOf } from './shapes.js';
|
|
4
|
+
import { SUB_SIZE } from './text.js';
|
|
5
|
+
export const DEFAULT_DIAGRAM_MARGIN = 32;
|
|
6
|
+
/* What a node, a group and an edge wear when the file names no tone. */
|
|
7
|
+
export const DEFAULT_NODE_TONE = 'ink';
|
|
8
|
+
export const DEFAULT_GROUP_TONE = 'muted';
|
|
9
|
+
export const DEFAULT_EDGE_TONE = 'muted';
|
|
10
|
+
// XML has no way to write a control character other than tab and the line breaks, not even escaped.
|
|
11
|
+
// oxlint-disable-next-line no-control-regex
|
|
12
|
+
const NOT_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
|
|
13
|
+
function escapeXml(value) {
|
|
14
|
+
return value.replace(NOT_XML, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
15
|
+
}
|
|
16
|
+
/*
|
|
17
|
+
* The whole diagram as one SVG string, for an export and for what the daemon hands an agent. The
|
|
18
|
+
* view in the app draws the same layout and the same paths as elements of its own, so the two only
|
|
19
|
+
* differ in where the colors come from.
|
|
20
|
+
*/
|
|
21
|
+
export function toSvg(document, options = {}) {
|
|
22
|
+
const palette = options.palette ?? DEFAULT_PALETTE;
|
|
23
|
+
const paper = options.paper ?? DEFAULT_PAPER;
|
|
24
|
+
const margin = options.margin ?? DEFAULT_DIAGRAM_MARGIN;
|
|
25
|
+
const font = escapeXml(options.font ?? DEFAULT_FONT_STACKS.sans);
|
|
26
|
+
const layout = options.layout ?? layoutOf(document);
|
|
27
|
+
const nodes = new Map(document.nodes.map((node) => [node.id, node]));
|
|
28
|
+
const groups = new Map(document.groups.map((group) => [group.id, group]));
|
|
29
|
+
const x = layout.bounds.x - margin;
|
|
30
|
+
const y = layout.bounds.y - margin;
|
|
31
|
+
const w = layout.bounds.w + margin * 2;
|
|
32
|
+
const h = layout.bounds.h + margin * 2;
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (options.background) {
|
|
35
|
+
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>`);
|
|
36
|
+
}
|
|
37
|
+
for (const box of layout.groups) {
|
|
38
|
+
const group = groups.get(box.id);
|
|
39
|
+
const tone = palette[group.tone ?? DEFAULT_GROUP_TONE];
|
|
40
|
+
parts.push(`<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"/>`, `<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>`);
|
|
41
|
+
}
|
|
42
|
+
for (const route of layout.edges) {
|
|
43
|
+
const edge = document.edges[route.index];
|
|
44
|
+
const tone = palette[edge.tone ?? DEFAULT_EDGE_TONE];
|
|
45
|
+
const dash = dashOf(edge.style);
|
|
46
|
+
parts.push(`<path d="${edgePath(route.points)}" fill="none" stroke="${tone}" stroke-width="2" stroke-linejoin="round"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`, `<path d="${arrowHeadPath(route.points)}" fill="${tone}"/>`);
|
|
47
|
+
if (route.label) {
|
|
48
|
+
const halo = options.background ? ` stroke="${options.background}" stroke-width="4" paint-order="stroke"` : '';
|
|
49
|
+
for (const line of edgeLabelLinesOf(route.label)) {
|
|
50
|
+
parts.push(`<text x="${line.x}" y="${line.y}" font-size="${line.size}" text-anchor="middle" fill="${tone}"${halo}>${escapeXml(line.text)}</text>`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const box of layout.nodes) {
|
|
55
|
+
const node = nodes.get(box.id);
|
|
56
|
+
const tone = node.tone ?? DEFAULT_NODE_TONE;
|
|
57
|
+
const paths = shapePaths(node.shape, box);
|
|
58
|
+
parts.push(`<path d="${paths.body}" fill="${paper[tone]}" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
59
|
+
if (paths.detail) {
|
|
60
|
+
parts.push(`<path d="${paths.detail}" fill="none" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
61
|
+
}
|
|
62
|
+
for (const line of textLinesOf(box, node.shape)) {
|
|
63
|
+
parts.push(`<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>`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}" font-family="${font}">${parts.join('')}</svg>`;
|
|
67
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const LABEL_SIZE = 14;
|
|
2
|
+
export declare const SUB_SIZE = 12;
|
|
3
|
+
export declare const LABEL_LINE = 18;
|
|
4
|
+
export declare const SUB_LINE = 16;
|
|
5
|
+
export declare function estimateTextWidth(text: string, size: number, bold?: boolean): number;
|
|
6
|
+
export declare function wrapText(text: string, size: number, bold: boolean, maxWidth: number): string[];
|
|
7
|
+
export declare function widestLine(lines: readonly string[], size: number, bold: boolean): number;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
const NARROW = new Set([..."ijl.,:;!|'"]);
|
|
6
|
+
const SEMI = new Set([...' ftrI-()[]{}/\\`"*']);
|
|
7
|
+
const WIDE_LOWER = new Set([...'mw']);
|
|
8
|
+
const WIDE = new Set([...'MW@%&']);
|
|
9
|
+
/*
|
|
10
|
+
* The width of a glyph as a share of the font size, per class of character. Measured in Chromium
|
|
11
|
+
* for the system face (SF on macOS) and Helvetica as a stand-in for Inter and Segoe UI, taking the
|
|
12
|
+
* wider of the two at weight 600, so an estimate errs towards a box that is a little too wide.
|
|
13
|
+
*/
|
|
14
|
+
function ratioOf(char) {
|
|
15
|
+
if (NARROW.has(char)) {
|
|
16
|
+
return 0.3;
|
|
17
|
+
}
|
|
18
|
+
if (SEMI.has(char)) {
|
|
19
|
+
return 0.4;
|
|
20
|
+
}
|
|
21
|
+
if (WIDE_LOWER.has(char)) {
|
|
22
|
+
return 0.86;
|
|
23
|
+
}
|
|
24
|
+
if (WIDE.has(char)) {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
if (char >= 'a' && char <= 'z') {
|
|
28
|
+
return 0.6;
|
|
29
|
+
}
|
|
30
|
+
if (char >= 'A' && char <= 'Z') {
|
|
31
|
+
return 0.74;
|
|
32
|
+
}
|
|
33
|
+
if (char >= '0' && char <= '9') {
|
|
34
|
+
return 0.64;
|
|
35
|
+
}
|
|
36
|
+
// CJK and everything after it is set on a full em; any other letter is taken as a wide Latin one.
|
|
37
|
+
return char.codePointAt(0) >= 0x2e80 ? 1 : 0.64;
|
|
38
|
+
}
|
|
39
|
+
const REGULAR = 0.94;
|
|
40
|
+
/* No DOM in this package, so text is estimated rather than measured, the same way on every machine. */
|
|
41
|
+
export function estimateTextWidth(text, size, bold = false) {
|
|
42
|
+
let units = 0;
|
|
43
|
+
for (const char of text) {
|
|
44
|
+
units += ratioOf(char);
|
|
45
|
+
}
|
|
46
|
+
return Math.ceil(units * size * (bold ? 1 : REGULAR));
|
|
47
|
+
}
|
|
48
|
+
function breakWord(word, size, bold, maxWidth) {
|
|
49
|
+
const pieces = [];
|
|
50
|
+
let piece = '';
|
|
51
|
+
for (const char of word) {
|
|
52
|
+
if (piece !== '' && estimateTextWidth(piece + char, size, bold) > maxWidth) {
|
|
53
|
+
pieces.push(piece);
|
|
54
|
+
piece = '';
|
|
55
|
+
}
|
|
56
|
+
piece += char;
|
|
57
|
+
}
|
|
58
|
+
pieces.push(piece);
|
|
59
|
+
return pieces;
|
|
60
|
+
}
|
|
61
|
+
export function wrapText(text, size, bold, maxWidth) {
|
|
62
|
+
const words = text
|
|
63
|
+
.trim()
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.filter((word) => word !== '');
|
|
66
|
+
const lines = [];
|
|
67
|
+
let line = '';
|
|
68
|
+
for (const word of words) {
|
|
69
|
+
const pieces = estimateTextWidth(word, size, bold) > maxWidth ? breakWord(word, size, bold, maxWidth) : [word];
|
|
70
|
+
for (const piece of pieces) {
|
|
71
|
+
const joined = line === '' ? piece : `${line} ${piece}`;
|
|
72
|
+
if (line !== '' && estimateTextWidth(joined, size, bold) > maxWidth) {
|
|
73
|
+
lines.push(line);
|
|
74
|
+
line = piece;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
line = joined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
lines.push(line);
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
export function widestLine(lines, size, bold) {
|
|
85
|
+
return lines.reduce((widest, line) => Math.max(widest, estimateTextWidth(line, size, bold)), 0);
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/diagram",
|
|
3
|
-
"version": "0.0.1",
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.17.0-beta.1",
|
|
4
|
+
"description": "Directed graph schemas, layered layout, SVG export and reading order.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
|
+
"author": "Bas Milius",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/basmilius/adecore.git",
|
|
9
10
|
"directory": "packages/diagram"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/basmilius/adecore/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://adecore.dev/diagram/",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/fixtures"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"source": "./src/index.ts",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./protocol": {
|
|
31
|
+
"source": "./src/protocol.ts",
|
|
32
|
+
"types": "./dist/protocol.d.ts",
|
|
33
|
+
"default": "./dist/protocol.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "bun scripts/build.ts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "bun test --conditions=source"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "^1.4.2",
|
|
43
|
+
"@types/node": "^24.13.6",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"zod": "^4.6.5",
|
|
48
|
+
"@adecore/drawing": "0.17.0-beta.1"
|
|
10
49
|
}
|
|
11
50
|
}
|