reladraw 0.17.0 → 0.18.0

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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The geometry of every outline `shape:` names: where its edge runs, where its
3
+ * middle is, and how big it grows to hold its text.
4
+ *
5
+ * One place knows each shape, and the resolver, the line planner, the checks
6
+ * and the renderer all ask it, so they cannot disagree about where a diamond's
7
+ * edge is.
8
+ *
9
+ * A shape stretches to fill its box. What the box doesn't decide is at most one
10
+ * amount and one direction (a parallelogram's slant and which way it leans),
11
+ * and those are the settings in the shape's bracket. Amounts are fixed
12
+ * distances, not fractions of the box, as the document's fold always was.
13
+ *
14
+ * The rectangle-like outlines (`rectangle`, `document`, `flow-subprocess`) and
15
+ * a round `circle` keep the exact geometry they always had; every other
16
+ * outline is worked with as a polygon, its curves sampled finely enough that a
17
+ * line meeting one lands on the drawn curve to within a fraction of a pixel.
18
+ */
19
+ /** A point, relative to whatever the caller says. */
20
+ export interface Point {
21
+ x: number;
22
+ y: number;
23
+ }
24
+ /** How each setting in a shape's bracket is read. */
25
+ type SettingKind = 'distance' | 'flag' | readonly string[];
26
+ /**
27
+ * The settings each shape takes in its bracket, by name. A shape not listed
28
+ * takes none. The first word of a direction is the default.
29
+ */
30
+ export declare const SHAPE_SETTINGS: Readonly<Record<string, Readonly<Record<string, SettingKind>>>>;
31
+ /** The settings a shape was given, read: distances in pixels, directions as words, flags as true. */
32
+ export interface ShapeLook {
33
+ fold?: number;
34
+ slant?: number;
35
+ lean?: string;
36
+ point?: number;
37
+ taper?: number;
38
+ narrow?: string;
39
+ aim?: string;
40
+ cap?: number;
41
+ lying?: boolean;
42
+ }
43
+ /**
44
+ * Read a shape's bracket, refusing a setting the shape doesn't take and a
45
+ * value that isn't one. `written` holds the dotted keys as stored, `shape.slant`.
46
+ */
47
+ export declare function readShapeLook(outline: string, written: Record<string, string>, subject: string, line: number): ShapeLook;
48
+ /** How far inside each side a subprocess's bars stand. */
49
+ export declare const SUBPROCESS_INSET: number;
50
+ /** Whether an outline is a rectangle as far as placement and lines go. */
51
+ export declare function isBoxy(outline: string): boolean;
52
+ /** Whether an outline is drawn round in a square box, so the exact circle code applies. */
53
+ export declare function isRound(outline: string, width: number, height: number): boolean;
54
+ /** One outline at one size: the polygon it is worked with, and the SVG it is drawn as. Points are relative to the box's top-left. */
55
+ export interface Shape {
56
+ /** The outline as a closed polygon, for meeting lines and testing what is inside. */
57
+ polygon: Point[];
58
+ /** The path data, relative, drawn translated to the box. */
59
+ path: string;
60
+ /** Lines drawn inside the outline: a cylinder's near rim, a subprocess's bars. */
61
+ detail: string[];
62
+ }
63
+ /**
64
+ * An outline at a size. Rectangle-like outlines and the round circle are
65
+ * included so a caller can always ask, but the renderer draws those itself.
66
+ */
67
+ export declare function shapeOf(outline: string, look: ShapeLook, w: number, h: number): Shape;
68
+ /**
69
+ * A path written relative to a box's top-left, moved to (x, y). Only the
70
+ * absolute commands `shapeOf` writes are handled: M, L, H, V, Q, T, A and Z.
71
+ */
72
+ export declare function placePath(d: string, x: number, y: number): string;
73
+ /**
74
+ * The shape's own middle, where its text sits and its lines aim: the center of
75
+ * its area. On a symmetric shape that is the middle of its box; a triangle's is
76
+ * a third of the way from its base.
77
+ */
78
+ export declare function middleOf(outline: string, look: ShapeLook, w: number, h: number): Point;
79
+ /** Whether a point is inside a polygon, by crossings. */
80
+ export declare function insideOutline(points: Point[], p: Point): boolean;
81
+ /** Whether a point is more than `distance` from every edge of a polygon. */
82
+ export declare function clearOf(points: Point[], p: Point, distance: number): boolean;
83
+ /**
84
+ * The size an outline takes to hold a block `tw` by `th` (the text with its
85
+ * padding) at its middle: a first guess that is exact for most shapes, then
86
+ * grown a step at a time until the block fits. A rectangle-like outline and
87
+ * the round circle are sized by the resolver, as they always were.
88
+ */
89
+ export declare function sizeFor(outline: string, look: ShapeLook, tw: number, th: number): {
90
+ width: number;
91
+ height: number;
92
+ };
93
+ /**
94
+ * Where the ray from `from` heading `way` first leaves a polygon, measured as
95
+ * the distance along the ray, or undefined if it never crosses it.
96
+ */
97
+ export declare function rayExit(points: Point[], from: Point, way: Point): number | undefined;
98
+ /** Every distance along the line through `from` heading `way`, either direction, at which it crosses a polygon. */
99
+ export declare function lineCrossings(points: Point[], from: Point, way: Point): number[];
100
+ /** Where the line from a polygon's middle toward `target` crosses its outline: the farthest crossing, so a concave outline is met at its rim. */
101
+ export declare function towardOutline(points: Point[], middle: Point, target: Point): Point;
102
+ /**
103
+ * The stretch of the outline that is one side: from where the line from the
104
+ * middle to one corner of the box meets it, round to where the line to the
105
+ * next corner does. Returned in reading order, left to right along a top or
106
+ * bottom side and top to bottom along a left or right one.
107
+ */
108
+ export declare function sideStretch(points: Point[], middle: Point, box: {
109
+ x: number;
110
+ y: number;
111
+ width: number;
112
+ height: number;
113
+ }, side: 'top' | 'bottom' | 'left' | 'right'): Point[];
114
+ /** The point a fraction of the way along a polyline, by length. */
115
+ export declare function alongPolyline(points: Point[], fraction: number): Point;
116
+ /** Which side of the box a point on the outline is on, by the corner rays from the middle. */
117
+ export declare function sideOfPoint(middle: Point, box: {
118
+ x: number;
119
+ y: number;
120
+ width: number;
121
+ height: number;
122
+ }, at: Point): 'top' | 'bottom' | 'left' | 'right';
123
+ export {};
@@ -0,0 +1,564 @@
1
+ /**
2
+ * The geometry of every outline `shape:` names: where its edge runs, where its
3
+ * middle is, and how big it grows to hold its text.
4
+ *
5
+ * One place knows each shape, and the resolver, the line planner, the checks
6
+ * and the renderer all ask it, so they cannot disagree about where a diamond's
7
+ * edge is.
8
+ *
9
+ * A shape stretches to fill its box. What the box doesn't decide is at most one
10
+ * amount and one direction (a parallelogram's slant and which way it leans),
11
+ * and those are the settings in the shape's bracket. Amounts are fixed
12
+ * distances, not fractions of the box, as the document's fold always was.
13
+ *
14
+ * The rectangle-like outlines (`rectangle`, `document`, `flow-subprocess`) and
15
+ * a round `circle` keep the exact geometry they always had; every other
16
+ * outline is worked with as a polygon, its curves sampled finely enough that a
17
+ * line meeting one lands on the drawn curve to within a fraction of a pixel.
18
+ */
19
+ import { UNIT } from './constants.js';
20
+ import { SourceError } from './errors.js';
21
+ /**
22
+ * The settings each shape takes in its bracket, by name. A shape not listed
23
+ * takes none. The first word of a direction is the default.
24
+ */
25
+ export const SHAPE_SETTINGS = {
26
+ document: { fold: 'distance' },
27
+ parallelogram: { slant: 'distance', lean: ['right', 'left'] },
28
+ hexagon: { point: 'distance' },
29
+ trapezoid: { taper: 'distance', narrow: ['bottom', 'top'] },
30
+ triangle: { aim: ['down', 'up', 'left', 'right'] },
31
+ cylinder: { cap: 'distance', lying: 'flag' },
32
+ };
33
+ /** Each amount's default in pixels, the settings' and those the flow- shapes fix. A document's fold is the renderer's `FOLD`. */
34
+ const SHAPE_DEFAULTS = {
35
+ slant: 18,
36
+ point: 18,
37
+ taper: 18,
38
+ cap: 9,
39
+ inset: 10,
40
+ wave: 7,
41
+ cut: 14,
42
+ slope: 16,
43
+ curve: 12,
44
+ offPage: 18,
45
+ tail: 16,
46
+ };
47
+ /**
48
+ * Read a shape's bracket, refusing a setting the shape doesn't take and a
49
+ * value that isn't one. `written` holds the dotted keys as stored, `shape.slant`.
50
+ */
51
+ export function readShapeLook(outline, written, subject, line) {
52
+ const takes = SHAPE_SETTINGS[outline] ?? {};
53
+ const look = {};
54
+ for (const [key, value] of Object.entries(written)) {
55
+ const kind = takes[key];
56
+ if (kind === undefined) {
57
+ const names = Object.keys(takes);
58
+ throw new SourceError(`${subject}: a ${outline} has no "${key}" setting — ` +
59
+ (names.length === 0 ? `\`shape: ${outline}\` takes no settings` : `it takes ${names.join(', ')}`), line);
60
+ }
61
+ if (kind === 'flag') {
62
+ if (value !== 'true')
63
+ throw new SourceError(`${subject}: "${key}" is a word on its own, written without a value: \`shape: ${outline} (${key})\``, line);
64
+ look[key] = true;
65
+ continue;
66
+ }
67
+ if (kind === 'distance') {
68
+ look[key] = readDistance(value, `${subject} ${key}:`, line);
69
+ continue;
70
+ }
71
+ if (!kind.includes(value)) {
72
+ throw new SourceError(`${subject}: "${key}: ${value}" is not a way a ${outline} can go — use ${kind.join(', ')}`, line);
73
+ }
74
+ look[key] = value;
75
+ }
76
+ return look;
77
+ }
78
+ function readDistance(written, subject, line) {
79
+ if (/^\d*\.?\d+$/.test(written))
80
+ return Number(written) * UNIT;
81
+ const pixels = /^(\d*\.?\d+)px$/.exec(written);
82
+ if (pixels)
83
+ return Number(pixels[1]);
84
+ throw new SourceError(`${subject} "${written}" is not a distance — write a number in the file's unit, or pixels such as 12px`, line);
85
+ }
86
+ /** How far inside each side a subprocess's bars stand. */
87
+ export const SUBPROCESS_INSET = SHAPE_DEFAULTS.inset;
88
+ /** The outlines handled as a plain rectangle by everything but the renderer. */
89
+ const BOXY = new Set(['rectangle', 'document', 'flow-subprocess']);
90
+ /** Whether an outline is a rectangle as far as placement and lines go. */
91
+ export function isBoxy(outline) {
92
+ return BOXY.has(outline);
93
+ }
94
+ /** Whether an outline is drawn round in a square box, so the exact circle code applies. */
95
+ export function isRound(outline, width, height) {
96
+ return outline === 'circle' && Math.abs(width - height) < 0.5;
97
+ }
98
+ // --- curves, sampled ------------------------------------------------------------
99
+ const CURVE_STEPS = 24;
100
+ /** Points along an elliptical arc about (cx, cy), from angle a0 to a1, both ends included. */
101
+ function arcPoints(cx, cy, rx, ry, a0, a1) {
102
+ const out = [];
103
+ for (let i = 0; i <= CURVE_STEPS; i++) {
104
+ const a = a0 + ((a1 - a0) * i) / CURVE_STEPS;
105
+ out.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) });
106
+ }
107
+ return out;
108
+ }
109
+ /** Points along a quadratic curve, the start left out. */
110
+ function quadPoints(p0, c, p1) {
111
+ const out = [];
112
+ for (let i = 1; i <= CURVE_STEPS; i++) {
113
+ const t = i / CURVE_STEPS;
114
+ const u = 1 - t;
115
+ out.push({ x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x, y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y });
116
+ }
117
+ return out;
118
+ }
119
+ /** A wave across a box from (x0, y) to (x1, y), up then down, as the standard's document draws its bottom. */
120
+ function wavePoints(x0, x1, y, depth) {
121
+ const mid = (x0 + x1) / 2;
122
+ const q = (x1 - x0) / 4;
123
+ return [...quadPoints({ x: x0, y }, { x: x0 + q, y: y - 2 * depth }, { x: mid, y }), ...quadPoints({ x: mid, y }, { x: mid + q, y: y + 2 * depth }, { x: x1, y })];
124
+ }
125
+ function polyShape(points) {
126
+ return { polygon: points, path: polygonPath(points), detail: [] };
127
+ }
128
+ function polygonPath(points) {
129
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${r2(p.x)} ${r2(p.y)}`).join(' ') + ' Z';
130
+ }
131
+ function r2(value) {
132
+ return Math.round(value * 100) / 100;
133
+ }
134
+ /**
135
+ * An outline at a size. Rectangle-like outlines and the round circle are
136
+ * included so a caller can always ask, but the renderer draws those itself.
137
+ */
138
+ export function shapeOf(outline, look, w, h) {
139
+ switch (outline) {
140
+ case 'circle': {
141
+ const points = arcPoints(w / 2, h / 2, w / 2, h / 2, Math.PI, 3 * Math.PI).slice(0, -1);
142
+ return { polygon: points, path: `M0 ${r2(h / 2)} A${r2(w / 2)} ${r2(h / 2)} 0 1 0 ${r2(w)} ${r2(h / 2)} A${r2(w / 2)} ${r2(h / 2)} 0 1 0 0 ${r2(h / 2)} Z`, detail: [] };
143
+ }
144
+ case 'diamond':
145
+ return polyShape([{ x: w / 2, y: 0 }, { x: w, y: h / 2 }, { x: w / 2, y: h }, { x: 0, y: h / 2 }]);
146
+ case 'pill': {
147
+ const rad = Math.min(w, h) / 2;
148
+ if (w >= h) {
149
+ const points = [...arcPoints(w - rad, rad, rad, rad, -Math.PI / 2, Math.PI / 2), ...arcPoints(rad, rad, rad, rad, Math.PI / 2, (3 * Math.PI) / 2)];
150
+ return { polygon: points, path: `M${r2(rad)} 0 H${r2(w - rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(w - rad)} ${r2(h)} H${r2(rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(rad)} 0 Z`, detail: [] };
151
+ }
152
+ const points = [...arcPoints(rad, h - rad, rad, rad, 0, Math.PI), ...arcPoints(rad, rad, rad, rad, Math.PI, 2 * Math.PI)];
153
+ return { polygon: points, path: `M0 ${r2(rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(w)} ${r2(rad)} V${r2(h - rad)} A${r2(rad)} ${r2(rad)} 0 0 1 0 ${r2(h - rad)} Z`, detail: [] };
154
+ }
155
+ case 'parallelogram': {
156
+ const s = Math.min(look.slant ?? SHAPE_DEFAULTS.slant, w / 2);
157
+ return look.lean === 'left'
158
+ ? polyShape([{ x: 0, y: 0 }, { x: w - s, y: 0 }, { x: w, y: h }, { x: s, y: h }])
159
+ : polyShape([{ x: s, y: 0 }, { x: w, y: 0 }, { x: w - s, y: h }, { x: 0, y: h }]);
160
+ }
161
+ case 'hexagon': {
162
+ const p = Math.min(look.point ?? SHAPE_DEFAULTS.point, w / 2);
163
+ return polyShape([{ x: p, y: 0 }, { x: w - p, y: 0 }, { x: w, y: h / 2 }, { x: w - p, y: h }, { x: p, y: h }, { x: 0, y: h / 2 }]);
164
+ }
165
+ case 'trapezoid': {
166
+ const t = Math.min(look.taper ?? SHAPE_DEFAULTS.taper, w / 2);
167
+ return look.narrow === 'top'
168
+ ? polyShape([{ x: t, y: 0 }, { x: w - t, y: 0 }, { x: w, y: h }, { x: 0, y: h }])
169
+ : polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w - t, y: h }, { x: t, y: h }]);
170
+ }
171
+ case 'triangle':
172
+ switch (look.aim ?? 'down') {
173
+ case 'up':
174
+ return polyShape([{ x: w / 2, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
175
+ case 'left':
176
+ return polyShape([{ x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h / 2 }]);
177
+ case 'right':
178
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: h / 2 }, { x: 0, y: h }]);
179
+ default:
180
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w / 2, y: h }]);
181
+ }
182
+ case 'cylinder': {
183
+ if (look.lying) {
184
+ const c = Math.min(look.cap ?? SHAPE_DEFAULTS.cap, w / 4);
185
+ const points = [...arcPoints(w - c, h / 2, c, h / 2, -Math.PI / 2, Math.PI / 2), ...arcPoints(c, h / 2, c, h / 2, Math.PI / 2, (3 * Math.PI) / 2)];
186
+ return {
187
+ polygon: points,
188
+ path: `M${r2(c)} 0 H${r2(w - c)} A${r2(c)} ${r2(h / 2)} 0 0 1 ${r2(w - c)} ${r2(h)} H${r2(c)} A${r2(c)} ${r2(h / 2)} 0 0 1 ${r2(c)} 0 Z`,
189
+ detail: [`M${r2(w - c)} 0 A${r2(c)} ${r2(h / 2)} 0 0 0 ${r2(w - c)} ${r2(h)}`],
190
+ };
191
+ }
192
+ const c = Math.min(look.cap ?? SHAPE_DEFAULTS.cap, h / 4);
193
+ const points = [...arcPoints(w / 2, c, w / 2, c, Math.PI, 2 * Math.PI), ...arcPoints(w / 2, h - c, w / 2, c, 0, Math.PI)];
194
+ return {
195
+ polygon: points,
196
+ path: `M0 ${r2(c)} A${r2(w / 2)} ${r2(c)} 0 0 1 ${r2(w)} ${r2(c)} V${r2(h - c)} A${r2(w / 2)} ${r2(c)} 0 0 1 0 ${r2(h - c)} Z`,
197
+ detail: [`M0 ${r2(c)} A${r2(w / 2)} ${r2(c)} 0 0 0 ${r2(w)} ${r2(c)}`],
198
+ };
199
+ }
200
+ case 'flow-document': {
201
+ const a = SHAPE_DEFAULTS.wave;
202
+ const points = [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - a }, ...wavePoints(w, 0, h - a, -a)];
203
+ return polyShape(points);
204
+ }
205
+ case 'flow-off-page': {
206
+ const p = Math.min(SHAPE_DEFAULTS.offPage, h / 2);
207
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - p }, { x: w / 2, y: h }, { x: 0, y: h - p }]);
208
+ }
209
+ case 'flow-delay': {
210
+ const rad = Math.min(h / 2, w);
211
+ const points = [{ x: 0, y: 0 }, ...arcPoints(w - rad, h / 2, rad, h / 2, -Math.PI / 2, Math.PI / 2), { x: 0, y: h }];
212
+ return { polygon: points, path: `M0 0 H${r2(w - rad)} A${r2(rad)} ${r2(h / 2)} 0 0 1 ${r2(w - rad)} ${r2(h)} H0 Z`, detail: [] };
213
+ }
214
+ case 'flow-manual-input': {
215
+ const s = Math.min(SHAPE_DEFAULTS.slope, h / 2);
216
+ return polyShape([{ x: 0, y: s }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
217
+ }
218
+ case 'flow-stored-data': {
219
+ const k = Math.min(SHAPE_DEFAULTS.curve, w / 4);
220
+ const points = [{ x: k, y: 0 }, { x: w, y: 0 }, ...quadPoints({ x: w, y: 0 }, { x: w - 2 * k, y: h / 2 }, { x: w, y: h }), { x: k, y: h }, ...quadPoints({ x: k, y: h }, { x: -k, y: h / 2 }, { x: k, y: 0 })];
221
+ return {
222
+ polygon: points.slice(0, -1),
223
+ path: `M${r2(k)} 0 H${r2(w)} Q${r2(w - 2 * k)} ${r2(h / 2)} ${r2(w)} ${r2(h)} H${r2(k)} Q${r2(-k)} ${r2(h / 2)} ${r2(k)} 0 Z`,
224
+ detail: [],
225
+ };
226
+ }
227
+ case 'flow-loop-start': {
228
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
229
+ return polyShape([{ x: c, y: 0 }, { x: w - c, y: 0 }, { x: w, y: c }, { x: w, y: h }, { x: 0, y: h }, { x: 0, y: c }]);
230
+ }
231
+ case 'flow-loop-end': {
232
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
233
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - c }, { x: w - c, y: h }, { x: c, y: h }, { x: 0, y: h - c }]);
234
+ }
235
+ case 'flow-card': {
236
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
237
+ return polyShape([{ x: c, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }, { x: 0, y: c }]);
238
+ }
239
+ case 'flow-display': {
240
+ const p = Math.min(h * 0.35, w / 4);
241
+ const k = Math.min(h * 0.45, w / 4);
242
+ const points = [{ x: p, y: 0 }, { x: w - k, y: 0 }, ...quadPoints({ x: w - k, y: 0 }, { x: w, y: 0 }, { x: w, y: h / 2 }), ...quadPoints({ x: w, y: h / 2 }, { x: w, y: h }, { x: w - k, y: h }), { x: p, y: h }, { x: 0, y: h / 2 }];
243
+ return {
244
+ polygon: points,
245
+ path: `M${r2(p)} 0 H${r2(w - k)} Q${r2(w)} 0 ${r2(w)} ${r2(h / 2)} Q${r2(w)} ${r2(h)} ${r2(w - k)} ${r2(h)} H${r2(p)} L0 ${r2(h / 2)} Z`,
246
+ detail: [],
247
+ };
248
+ }
249
+ case 'flow-tape': {
250
+ const d = Math.min(h, w - SHAPE_DEFAULTS.tail);
251
+ const rad = d / 2;
252
+ const points = arcPoints(rad, h / 2, rad, rad, Math.PI, 3 * Math.PI).slice(0, -1);
253
+ return {
254
+ polygon: points,
255
+ path: `M0 ${r2(h / 2)} A${r2(rad)} ${r2(rad)} 0 1 0 ${r2(d)} ${r2(h / 2)} A${r2(rad)} ${r2(rad)} 0 1 0 0 ${r2(h / 2)} Z`,
256
+ detail: [`M${r2(rad)} ${r2(h / 2 + rad)} H${r2(w)}`],
257
+ };
258
+ }
259
+ case 'flow-punched-tape': {
260
+ const a = SHAPE_DEFAULTS.wave;
261
+ const points = [...[{ x: 0, y: a }, ...wavePoints(0, w, a, a)], ...[{ x: w, y: h - a }, ...wavePoints(w, 0, h - a, -a)]];
262
+ return polyShape(points);
263
+ }
264
+ case 'flow-collate': {
265
+ // Two triangles meeting at a point. Worked with as the bowtie's outline,
266
+ // which passes through its own middle.
267
+ return {
268
+ polygon: [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w / 2, y: h / 2 }, { x: w, y: h }, { x: 0, y: h }, { x: w / 2, y: h / 2 }],
269
+ path: `M0 0 H${r2(w)} L0 ${r2(h)} H${r2(w)} Z`,
270
+ detail: [],
271
+ };
272
+ }
273
+ default:
274
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
275
+ }
276
+ }
277
+ /**
278
+ * A path written relative to a box's top-left, moved to (x, y). Only the
279
+ * absolute commands `shapeOf` writes are handled: M, L, H, V, Q, T, A and Z.
280
+ */
281
+ export function placePath(d, x, y) {
282
+ return d.replace(/([MLHVQTAZ])([^MLHVQTAZ]*)/g, (_all, command, rest) => {
283
+ const n = rest.trim().length === 0 ? [] : rest.trim().split(/[\s,]+/).map(Number);
284
+ const moved = n.map((value, i) => {
285
+ switch (command) {
286
+ case 'H':
287
+ return value + x;
288
+ case 'V':
289
+ return value + y;
290
+ case 'A':
291
+ // rx ry rotation large-arc sweep x y: only the end point moves.
292
+ return i % 7 === 5 ? value + x : i % 7 === 6 ? value + y : value;
293
+ default:
294
+ return i % 2 === 0 ? value + x : value + y;
295
+ }
296
+ });
297
+ return `${command}${moved.map(r2).join(' ')}${command === 'Z' ? '' : ' '}`;
298
+ }).trim();
299
+ }
300
+ // --- the middle, and size for text ------------------------------------------------
301
+ /**
302
+ * The shape's own middle, where its text sits and its lines aim: the center of
303
+ * its area. On a symmetric shape that is the middle of its box; a triangle's is
304
+ * a third of the way from its base.
305
+ */
306
+ export function middleOf(outline, look, w, h) {
307
+ if (isBoxy(outline) || outline === 'circle' || outline === 'flow-collate')
308
+ return { x: w / 2, y: h / 2 };
309
+ if (outline === 'flow-tape')
310
+ return { x: Math.min(h, w - SHAPE_DEFAULTS.tail) / 2, y: h / 2 };
311
+ return areaCentroid(shapeOf(outline, look, w, h).polygon);
312
+ }
313
+ function areaCentroid(points) {
314
+ let area = 0;
315
+ let cx = 0;
316
+ let cy = 0;
317
+ for (let i = 0; i < points.length; i++) {
318
+ const a = points[i];
319
+ const b = points[(i + 1) % points.length];
320
+ const cross = a.x * b.y - b.x * a.y;
321
+ area += cross;
322
+ cx += (a.x + b.x) * cross;
323
+ cy += (a.y + b.y) * cross;
324
+ }
325
+ if (Math.abs(area) < 1e-9)
326
+ return points[0] ?? { x: 0, y: 0 };
327
+ return { x: cx / (3 * area), y: cy / (3 * area) };
328
+ }
329
+ /** Whether a point is inside a polygon, by crossings. */
330
+ export function insideOutline(points, p) {
331
+ let within = false;
332
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
333
+ const a = points[i];
334
+ const b = points[j];
335
+ if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x)
336
+ within = !within;
337
+ }
338
+ return within;
339
+ }
340
+ /** Whether a point is more than `distance` from every edge of a polygon. */
341
+ export function clearOf(points, p, distance) {
342
+ for (let i = 0; i < points.length; i++) {
343
+ const a = points[i];
344
+ const b = points[(i + 1) % points.length];
345
+ const ex = b.x - a.x;
346
+ const ey = b.y - a.y;
347
+ const length = ex * ex + ey * ey;
348
+ const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * ex + (p.y - a.y) * ey) / length));
349
+ if (Math.hypot(p.x - (a.x + ex * t), p.y - (a.y + ey * t)) <= distance)
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+ /** Whether a rectangle centered on the shape's middle fits inside its outline, checked round its edge. */
355
+ function holdsBlock(outline, look, w, h, tw, th) {
356
+ const polygon = shapeOf(outline, look, w, h).polygon;
357
+ const m = middleOf(outline, look, w, h);
358
+ const x0 = m.x - tw / 2;
359
+ const y0 = m.y - th / 2;
360
+ const n = 8;
361
+ for (let i = 0; i <= n; i++) {
362
+ const f = i / n;
363
+ for (const p of [{ x: x0 + tw * f, y: y0 }, { x: x0 + tw * f, y: y0 + th }, { x: x0, y: y0 + th * f }, { x: x0 + tw, y: y0 + th * f }]) {
364
+ // A hair in, so a corner the outline passes exactly through counts as inside.
365
+ const toward = { x: p.x + (m.x - p.x) * 1e-6, y: p.y + (m.y - p.y) * 1e-6 };
366
+ if (!insideOutline(polygon, toward))
367
+ return false;
368
+ }
369
+ }
370
+ return true;
371
+ }
372
+ /**
373
+ * The size an outline takes to hold a block `tw` by `th` (the text with its
374
+ * padding) at its middle: a first guess that is exact for most shapes, then
375
+ * grown a step at a time until the block fits. A rectangle-like outline and
376
+ * the round circle are sized by the resolver, as they always were.
377
+ */
378
+ export function sizeFor(outline, look, tw, th) {
379
+ const guess = guessSize(outline, look, tw, th);
380
+ if (outline === 'flow-collate')
381
+ return guess;
382
+ let { width, height } = guess;
383
+ for (let step = 0; step < 200 && !holdsBlock(outline, look, width, height, tw, th); step++) {
384
+ width *= 1.02;
385
+ height *= 1.02;
386
+ }
387
+ return { width, height };
388
+ }
389
+ function guessSize(outline, look, tw, th) {
390
+ switch (outline) {
391
+ case 'circle':
392
+ // A circle given two sizes is an ellipse; one sized for its text is round.
393
+ return { width: Math.hypot(tw, th), height: Math.hypot(tw, th) };
394
+ case 'diamond':
395
+ return { width: tw * 2, height: th * 2 };
396
+ case 'pill':
397
+ return { width: tw + th, height: th };
398
+ case 'parallelogram':
399
+ return { width: tw + (look.slant ?? SHAPE_DEFAULTS.slant), height: th };
400
+ case 'hexagon':
401
+ return { width: tw + 2 * (look.point ?? SHAPE_DEFAULTS.point), height: th };
402
+ case 'trapezoid':
403
+ return { width: tw + 2 * (look.taper ?? SHAPE_DEFAULTS.taper), height: th };
404
+ case 'triangle':
405
+ return look.aim === 'left' || look.aim === 'right' ? { width: tw * 2, height: th * 2.4 } : { width: tw * 2.4, height: th * 2 };
406
+ case 'cylinder': {
407
+ const c = look.cap ?? SHAPE_DEFAULTS.cap;
408
+ return look.lying ? { width: tw + 4 * c, height: th } : { width: tw, height: th + 4 * c };
409
+ }
410
+ case 'flow-document':
411
+ return { width: tw, height: th + 3 * SHAPE_DEFAULTS.wave };
412
+ case 'flow-off-page':
413
+ return { width: tw, height: th + SHAPE_DEFAULTS.offPage };
414
+ case 'flow-delay':
415
+ return { width: tw + th / 2, height: th };
416
+ case 'flow-manual-input':
417
+ return { width: tw, height: th + SHAPE_DEFAULTS.slope };
418
+ case 'flow-stored-data':
419
+ return { width: tw + 2 * SHAPE_DEFAULTS.curve, height: th };
420
+ case 'flow-loop-start':
421
+ case 'flow-loop-end':
422
+ return { width: tw + SHAPE_DEFAULTS.cut, height: th + SHAPE_DEFAULTS.cut / 2 };
423
+ case 'flow-card':
424
+ return { width: tw + SHAPE_DEFAULTS.cut / 2, height: th + SHAPE_DEFAULTS.cut / 2 };
425
+ case 'flow-display':
426
+ return { width: tw + th * 0.8, height: th };
427
+ case 'flow-tape': {
428
+ const d = Math.hypot(tw, th);
429
+ return { width: d + SHAPE_DEFAULTS.tail, height: d };
430
+ }
431
+ case 'flow-punched-tape':
432
+ return { width: tw, height: th + 4 * SHAPE_DEFAULTS.wave };
433
+ case 'flow-collate':
434
+ return { width: tw, height: Math.max(th * 1.6, th + 20) };
435
+ default:
436
+ return { width: tw, height: th };
437
+ }
438
+ }
439
+ // --- sides and corners ---------------------------------------------------------------
440
+ /**
441
+ * Where the ray from `from` heading `way` first leaves a polygon, measured as
442
+ * the distance along the ray, or undefined if it never crosses it.
443
+ */
444
+ export function rayExit(points, from, way) {
445
+ let best;
446
+ for (let i = 0; i < points.length; i++) {
447
+ const a = points[i];
448
+ const b = points[(i + 1) % points.length];
449
+ const t = rayHitsSegment(from, way, a, b);
450
+ if (t !== undefined && t > 1e-9 && (best === undefined || t > best))
451
+ best = t;
452
+ }
453
+ return best;
454
+ }
455
+ /** Every distance along the line through `from` heading `way`, either direction, at which it crosses a polygon. */
456
+ export function lineCrossings(points, from, way) {
457
+ const out = [];
458
+ for (let i = 0; i < points.length; i++) {
459
+ const t = rayHitsSegment(from, way, points[i], points[(i + 1) % points.length], true);
460
+ if (t !== undefined)
461
+ out.push(t);
462
+ }
463
+ return out;
464
+ }
465
+ function rayHitsSegment(from, way, a, b, both = false) {
466
+ const ex = b.x - a.x;
467
+ const ey = b.y - a.y;
468
+ const denom = way.x * ey - way.y * ex;
469
+ if (Math.abs(denom) < 1e-12)
470
+ return undefined;
471
+ const t = ((a.x - from.x) * ey - (a.y - from.y) * ex) / denom;
472
+ const s = ((a.x - from.x) * way.y - (a.y - from.y) * way.x) / denom;
473
+ if (s < -1e-9 || s > 1 + 1e-9)
474
+ return undefined;
475
+ if (!both && t < 0)
476
+ return undefined;
477
+ return t;
478
+ }
479
+ /** Where the line from a polygon's middle toward `target` crosses its outline: the farthest crossing, so a concave outline is met at its rim. */
480
+ export function towardOutline(points, middle, target) {
481
+ const dx = target.x - middle.x;
482
+ const dy = target.y - middle.y;
483
+ const length = Math.hypot(dx, dy);
484
+ if (length < 1e-9)
485
+ return middle;
486
+ const way = { x: dx / length, y: dy / length };
487
+ const t = rayExit(points, middle, way) ?? 0;
488
+ return { x: middle.x + way.x * t, y: middle.y + way.y * t };
489
+ }
490
+ /**
491
+ * The stretch of the outline that is one side: from where the line from the
492
+ * middle to one corner of the box meets it, round to where the line to the
493
+ * next corner does. Returned in reading order, left to right along a top or
494
+ * bottom side and top to bottom along a left or right one.
495
+ */
496
+ export function sideStretch(points, middle, box, side) {
497
+ const tl = { x: box.x, y: box.y };
498
+ const tr = { x: box.x + box.width, y: box.y };
499
+ const bl = { x: box.x, y: box.y + box.height };
500
+ const br = { x: box.x + box.width, y: box.y + box.height };
501
+ const [c0, c1] = { top: [tl, tr], right: [tr, br], bottom: [bl, br], left: [tl, bl] }[side];
502
+ const a0 = Math.atan2(c0.y - middle.y, c0.x - middle.x);
503
+ const a1 = Math.atan2(c1.y - middle.y, c1.x - middle.x);
504
+ // The angular wedge between the two corner rays that holds the side.
505
+ const span = (to) => {
506
+ let d = to - a0;
507
+ while (d < 0)
508
+ d += 2 * Math.PI;
509
+ while (d >= 2 * Math.PI)
510
+ d -= 2 * Math.PI;
511
+ return d;
512
+ };
513
+ let width = span(a1);
514
+ let clockwise = true;
515
+ const facing = { top: -Math.PI / 2, right: 0, bottom: Math.PI / 2, left: Math.PI }[side];
516
+ if (span(facing) > width) {
517
+ clockwise = false;
518
+ width = 2 * Math.PI - width;
519
+ }
520
+ const out = [];
521
+ const steps = 96;
522
+ for (let i = 0; i <= steps; i++) {
523
+ const angle = a0 + (clockwise ? 1 : -1) * (width * i) / steps;
524
+ const p = towardOutline(points, middle, { x: middle.x + Math.cos(angle), y: middle.y + Math.sin(angle) });
525
+ const last = out[out.length - 1];
526
+ if (!last || Math.hypot(p.x - last.x, p.y - last.y) > 1e-6)
527
+ out.push(p);
528
+ }
529
+ return out;
530
+ }
531
+ /** The point a fraction of the way along a polyline, by length. */
532
+ export function alongPolyline(points, fraction) {
533
+ let total = 0;
534
+ for (let i = 1; i < points.length; i++)
535
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
536
+ let left = Math.max(0, Math.min(1, fraction)) * total;
537
+ for (let i = 1; i < points.length; i++) {
538
+ const a = points[i - 1];
539
+ const b = points[i];
540
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
541
+ if (left <= length || i === points.length - 1) {
542
+ const f = length < 1e-9 ? 0 : Math.min(1, left / length);
543
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
544
+ }
545
+ left -= length;
546
+ }
547
+ return points[0] ?? { x: 0, y: 0 };
548
+ }
549
+ /** Which side of the box a point on the outline is on, by the corner rays from the middle. */
550
+ export function sideOfPoint(middle, box, at) {
551
+ const angle = Math.atan2(at.y - middle.y, at.x - middle.x);
552
+ const corner = (x, y) => Math.atan2(y - middle.y, x - middle.x);
553
+ const tr = corner(box.x + box.width, box.y);
554
+ const br = corner(box.x + box.width, box.y + box.height);
555
+ const bl = corner(box.x, box.y + box.height);
556
+ const tl = corner(box.x, box.y);
557
+ if (angle >= tr && angle <= br)
558
+ return 'right';
559
+ if (angle >= br && angle <= bl)
560
+ return 'bottom';
561
+ if (angle <= tr && angle >= tl)
562
+ return 'top';
563
+ return 'left';
564
+ }