forma-diagrams 0.5.2
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/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/bin/forma +3 -0
- package/deploy/.env.example +11 -0
- package/deploy/Caddyfile +4 -0
- package/deploy/compose.yml +38 -0
- package/dist/assets/IBMPlexSans-Regular-Bl2SjS7V.ttf +0 -0
- package/dist/assets/IBMPlexSans-SemiBold-B9auKknr.ttf +0 -0
- package/dist/assets/elk.bundled-Y7ymokgr.js +24 -0
- package/dist/assets/index-_kg9cdik.css +1 -0
- package/dist/assets/index-al0xDdTG.js +452 -0
- package/dist/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/dist/fonts/IBMPlexSans-SemiBold.ttf +0 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/index.html +14 -0
- package/dist/licenses/FORMA_LICENSE.txt +21 -0
- package/dist/licenses/THIRD_PARTY_LICENSES.txt +11601 -0
- package/docs/api.md +41 -0
- package/docs/decisions/001-foundation.md +57 -0
- package/docs/decisions/002-composition-and-human-edits.md +47 -0
- package/docs/decisions/003-general-composition.md +30 -0
- package/docs/decisions/004-library-and-distribution.md +33 -0
- package/docs/decisions/005-organizational-hosting.md +33 -0
- package/docs/decisions/006-hosted-agent-access.md +25 -0
- package/docs/format.md +132 -0
- package/docs/gallery.md +73 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/editor.png +0 -0
- package/docs/images/gallery/architecture.png +0 -0
- package/docs/images/gallery/decision.png +0 -0
- package/docs/images/gallery/development-signal.png +0 -0
- package/docs/images/gallery/development.png +0 -0
- package/docs/images/gallery/editor-roundtrip.png +0 -0
- package/docs/images/gallery/entities.png +0 -0
- package/docs/images/gallery/mindmap.png +0 -0
- package/docs/images/gallery/organization.png +0 -0
- package/docs/images/gallery/timeline.png +0 -0
- package/docs/images/release.png +0 -0
- package/docs/install.md +50 -0
- package/docs/plans/mvp.md +24 -0
- package/docs/self-hosting.md +167 -0
- package/docs/verification-v2.md +41 -0
- package/docs/verification-v3.md +16 -0
- package/docs/verification-v4.1.md +35 -0
- package/docs/verification-v4.2.md +20 -0
- package/docs/verification-v4.3.md +5 -0
- package/docs/verification-v4.md +31 -0
- package/docs/verification-v5.1.md +7 -0
- package/docs/verification-v5.2.md +7 -0
- package/docs/verification-v5.md +7 -0
- package/docs/verification.md +91 -0
- package/examples/design-systems/atelier.json +53 -0
- package/examples/design-systems/signal.json +53 -0
- package/examples/gallery/architecture.forma.json +178 -0
- package/examples/gallery/decision.forma.json +182 -0
- package/examples/gallery/development-signal.forma.json +299 -0
- package/examples/gallery/development.forma.json +299 -0
- package/examples/gallery/entities.forma.json +123 -0
- package/examples/gallery/mindmap.forma.json +158 -0
- package/examples/gallery/organization.forma.json +151 -0
- package/examples/gallery/timeline.forma.json +138 -0
- package/examples/platform.forma.json +116 -0
- package/examples/release.forma.json +58 -0
- package/examples/roundtrip/agent-continued.forma.json +310 -0
- package/examples/roundtrip/agent-patch.json +7 -0
- package/examples/roundtrip/human-edited.forma.json +309 -0
- package/package.json +81 -0
- package/packages/cli/bin.mjs +3 -0
- package/packages/cli/src/agent-access.ts +232 -0
- package/packages/cli/src/blob-store.ts +241 -0
- package/packages/cli/src/file-library.ts +179 -0
- package/packages/cli/src/google.ts +51 -0
- package/packages/cli/src/hosted.ts +498 -0
- package/packages/cli/src/http.ts +67 -0
- package/packages/cli/src/index.ts +397 -0
- package/packages/cli/src/mcp.ts +114 -0
- package/packages/cli/src/remote.ts +228 -0
- package/packages/cli/src/server.ts +69 -0
- package/packages/cli/src/signed-cookie.ts +42 -0
- package/packages/core/src/align.ts +237 -0
- package/packages/core/src/document.ts +408 -0
- package/packages/core/src/font-metrics.json +1 -0
- package/packages/core/src/geometry.ts +463 -0
- package/packages/core/src/index.ts +12 -0
- package/packages/core/src/inspect.ts +230 -0
- package/packages/core/src/layout.ts +450 -0
- package/packages/core/src/render.ts +182 -0
- package/packages/core/src/resolve-style.ts +29 -0
- package/packages/core/src/scene.ts +40 -0
- package/packages/core/src/styles.ts +122 -0
- package/packages/core/src/text.ts +81 -0
- package/packages/core/src/theme.ts +33 -0
- package/public/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/public/fonts/IBMPlexSans-SemiBold.ttf +0 -0
- package/public/fonts/OFL.txt +93 -0
- package/schema/design-system.v1.schema.json +734 -0
- package/schema/forma.v1.schema.json +273 -0
- package/schema/forma.v2.schema.json +1756 -0
- package/skills/forma/SKILL.md +20 -0
- package/skills/forma/references/composition.md +11 -0
- package/skills/forma/references/hosted.md +28 -0
- package/skills/forma-architecture/SKILL.md +14 -0
- package/skills/forma-entities/SKILL.md +14 -0
- package/skills/forma-flow/SKILL.md +14 -0
- package/skills/forma-mindmap/SKILL.md +14 -0
- package/skills/forma-organization/SKILL.md +14 -0
- package/skills/forma-timeline/SKILL.md +14 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import type { Point, PortCounts } from './document';
|
|
2
|
+
import type { Box } from './scene';
|
|
3
|
+
import type { PortSide } from './styles';
|
|
4
|
+
const round = (n: number) => Math.round(n * 100) / 100;
|
|
5
|
+
export const quantize = (p: Point): Point => ({ x: round(p.x), y: round(p.y) });
|
|
6
|
+
export function overlaps(a: Box, b: Box, gap = 0) {
|
|
7
|
+
return (
|
|
8
|
+
a.x < b.x + b.width + gap &&
|
|
9
|
+
a.x + a.width + gap > b.x &&
|
|
10
|
+
a.y < b.y + b.height + gap &&
|
|
11
|
+
a.y + a.height + gap > b.y
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
export function inside(p: Point, b: Box) {
|
|
15
|
+
return p.x > b.x && p.x < b.x + b.width && p.y > b.y && p.y < b.y + b.height;
|
|
16
|
+
}
|
|
17
|
+
export function segmentHitsBox(a: Point, b: Point, box: Box) {
|
|
18
|
+
if (a.x === b.x)
|
|
19
|
+
return (
|
|
20
|
+
a.x > box.x &&
|
|
21
|
+
a.x < box.x + box.width &&
|
|
22
|
+
Math.max(a.y, b.y) > box.y &&
|
|
23
|
+
Math.min(a.y, b.y) < box.y + box.height
|
|
24
|
+
);
|
|
25
|
+
if (a.y === b.y)
|
|
26
|
+
return (
|
|
27
|
+
a.y > box.y &&
|
|
28
|
+
a.y < box.y + box.height &&
|
|
29
|
+
Math.max(a.x, b.x) > box.x &&
|
|
30
|
+
Math.min(a.x, b.x) < box.x + box.width
|
|
31
|
+
);
|
|
32
|
+
// Slab intersection, excluding touches along the box border.
|
|
33
|
+
let low = 0,
|
|
34
|
+
high = 1;
|
|
35
|
+
for (const [origin, delta, min, max] of [
|
|
36
|
+
[a.x, b.x - a.x, box.x + 0.01, box.x + box.width - 0.01],
|
|
37
|
+
[a.y, b.y - a.y, box.y + 0.01, box.y + box.height - 0.01],
|
|
38
|
+
]) {
|
|
39
|
+
if (!delta) {
|
|
40
|
+
if (origin < min || origin > max) return false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const one = (min - origin) / delta,
|
|
44
|
+
two = (max - origin) / delta;
|
|
45
|
+
low = Math.max(low, Math.min(one, two));
|
|
46
|
+
high = Math.min(high, Math.max(one, two));
|
|
47
|
+
if (low > high) return false;
|
|
48
|
+
}
|
|
49
|
+
return low < high;
|
|
50
|
+
}
|
|
51
|
+
export function segmentCross(a: Point, b: Point, c: Point, d: Point): Point | null {
|
|
52
|
+
const cross = (u: Point, v: Point) => u.x * v.y - u.y * v.x;
|
|
53
|
+
const r = { x: b.x - a.x, y: b.y - a.y },
|
|
54
|
+
s = { x: d.x - c.x, y: d.y - c.y };
|
|
55
|
+
const det = cross(r, s);
|
|
56
|
+
if (!det) return null;
|
|
57
|
+
const q = { x: c.x - a.x, y: c.y - a.y };
|
|
58
|
+
const t = cross(q, s) / det,
|
|
59
|
+
u = cross(q, r) / det;
|
|
60
|
+
return t > 0.001 && t < 0.999 && u > 0.001 && u < 0.999
|
|
61
|
+
? { x: a.x + t * r.x, y: a.y + t * r.y }
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
export function simplify(points: Point[]): Point[] {
|
|
65
|
+
return points.filter((p, i) => {
|
|
66
|
+
const a = points[i - 1],
|
|
67
|
+
b = points[i + 1];
|
|
68
|
+
if (a && a.x === p.x && a.y === p.y) return false;
|
|
69
|
+
return !a || !b || !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function polylinePath(points: Point[], radius = 8): string {
|
|
73
|
+
if (points.length < 2) return '';
|
|
74
|
+
let path = `M ${points[0].x} ${points[0].y}`;
|
|
75
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
76
|
+
const a = points[i - 1],
|
|
77
|
+
p = points[i],
|
|
78
|
+
b = points[i + 1];
|
|
79
|
+
const d1 = Math.hypot(p.x - a.x, p.y - a.y),
|
|
80
|
+
d2 = Math.hypot(b.x - p.x, b.y - p.y);
|
|
81
|
+
const r = Math.min(radius, d1 / 2, d2 / 2);
|
|
82
|
+
if (!d1 || !d2) continue;
|
|
83
|
+
path += ` L ${p.x + ((a.x - p.x) * r) / d1} ${p.y + ((a.y - p.y) * r) / d1} Q ${p.x} ${p.y} ${p.x + ((b.x - p.x) * r) / d2} ${p.y + ((b.y - p.y) * r) / d2}`;
|
|
84
|
+
}
|
|
85
|
+
return path + ` L ${points.at(-1)!.x} ${points.at(-1)!.y}`;
|
|
86
|
+
}
|
|
87
|
+
/** Slide one orthogonal segment, keeping both ends attached to their ports. */
|
|
88
|
+
export function slideOrthogonalSegment(points: Point[], segment: number, value: number): Point[] {
|
|
89
|
+
if (points.length < 2 || segment < 0 || segment >= points.length - 1) return points;
|
|
90
|
+
const start = points[segment],
|
|
91
|
+
end = points[segment + 1];
|
|
92
|
+
const horizontal = Math.abs(start.y - end.y) <= Math.abs(start.x - end.x);
|
|
93
|
+
const next = points.map((p) => ({ ...p }));
|
|
94
|
+
const shift = (index: number) => {
|
|
95
|
+
next[index] = horizontal ? { ...next[index], y: value } : { ...next[index], x: value };
|
|
96
|
+
};
|
|
97
|
+
shift(segment);
|
|
98
|
+
shift(segment + 1);
|
|
99
|
+
if (segment === 0) {
|
|
100
|
+
const port = points[0],
|
|
101
|
+
moved = next[1];
|
|
102
|
+
next[0] = { ...port };
|
|
103
|
+
next.splice(1, 0, horizontal ? { x: port.x, y: moved.y } : { x: moved.x, y: port.y });
|
|
104
|
+
}
|
|
105
|
+
if (segment + 1 === points.length - 1) {
|
|
106
|
+
const port = points[points.length - 1],
|
|
107
|
+
at = next.length - 1,
|
|
108
|
+
moved = next[at - 1];
|
|
109
|
+
next[at] = { ...port };
|
|
110
|
+
next.splice(at, 0, horizontal ? { x: port.x, y: moved.y } : { x: moved.x, y: port.y });
|
|
111
|
+
}
|
|
112
|
+
return simplify(next);
|
|
113
|
+
}
|
|
114
|
+
/** Closest connection point on a shape to an existing line end. */
|
|
115
|
+
export function nearestPort(box: Box, ports: PortCounts | undefined, point: Point) {
|
|
116
|
+
let best: { side: PortSide; index: number; distance: number } | undefined;
|
|
117
|
+
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
|
|
118
|
+
const count = ports?.[side] ?? 1;
|
|
119
|
+
for (let index = 0; index < count; index++) {
|
|
120
|
+
const candidate = portPoint(box, side, index, count);
|
|
121
|
+
const distance = Math.hypot(candidate.x - point.x, candidate.y - point.y);
|
|
122
|
+
if (!best || distance < best.distance) best = { side, index, distance };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return best ?? { side: 'right' as PortSide, index: 0, distance: 0 };
|
|
126
|
+
}
|
|
127
|
+
export function centerPortIndex(count: number) {
|
|
128
|
+
return Math.floor((Math.max(1, count) - 1) / 2);
|
|
129
|
+
}
|
|
130
|
+
/** Evenly spaced. One point sits on the middle of the side; extra points spread toward the corners. */
|
|
131
|
+
export function portPoint(box: Box, side: PortSide, index: number, count: number): Point {
|
|
132
|
+
const n = Math.max(1, count);
|
|
133
|
+
const i = Math.min(Math.max(0, index), n - 1);
|
|
134
|
+
const t = (i + 1) / (n + 1);
|
|
135
|
+
if (side === 'top') return quantize({ x: box.x + box.width * t, y: box.y });
|
|
136
|
+
if (side === 'bottom') return quantize({ x: box.x + box.width * t, y: box.y + box.height });
|
|
137
|
+
if (side === 'left') return quantize({ x: box.x, y: box.y + box.height * t });
|
|
138
|
+
return quantize({ x: box.x + box.width, y: box.y + box.height * t });
|
|
139
|
+
}
|
|
140
|
+
export function attachmentPoint(
|
|
141
|
+
box: Box,
|
|
142
|
+
ports: PortCounts | undefined,
|
|
143
|
+
side: PortSide | undefined,
|
|
144
|
+
index: number | undefined,
|
|
145
|
+
fallback: PortSide,
|
|
146
|
+
) {
|
|
147
|
+
const chosen = side ?? fallback;
|
|
148
|
+
const count = ports?.[chosen] ?? 1;
|
|
149
|
+
const resolved = Math.min(index ?? centerPortIndex(count), count - 1);
|
|
150
|
+
return { side: chosen, index: resolved, count, point: portPoint(box, chosen, resolved, count) };
|
|
151
|
+
}
|
|
152
|
+
export function exitVector(side: PortSide, distance = 20): Point {
|
|
153
|
+
if (side === 'left') return { x: -distance, y: 0 };
|
|
154
|
+
if (side === 'right') return { x: distance, y: 0 };
|
|
155
|
+
if (side === 'top') return { x: 0, y: -distance };
|
|
156
|
+
return { x: 0, y: distance };
|
|
157
|
+
}
|
|
158
|
+
const aligned = (a: number, b: number) => Math.abs(a - b) < 0.51;
|
|
159
|
+
export function isOrthogonal(points: Point[]) {
|
|
160
|
+
return points.every(
|
|
161
|
+
(p, i) => !i || aligned(p.x, points[i - 1].x) || aligned(p.y, points[i - 1].y),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
/** One elbow. An exit side keeps the first leg on that axis. */
|
|
165
|
+
export function elbow(a: Point, b: Point, fromSide?: PortSide): Point {
|
|
166
|
+
const horizontalFirst = { x: b.x, y: a.y };
|
|
167
|
+
if (fromSide === 'left' || fromSide === 'right') return horizontalFirst;
|
|
168
|
+
if (fromSide === 'top' || fromSide === 'bottom') return { x: a.x, y: b.y };
|
|
169
|
+
return Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) ? horizontalFirst : { x: a.x, y: b.y };
|
|
170
|
+
}
|
|
171
|
+
/** Orthogonal polyline through absolute waypoints. Endpoints stay on the chosen ports. */
|
|
172
|
+
export function routeThrough(
|
|
173
|
+
source: Point,
|
|
174
|
+
waypoints: Point[],
|
|
175
|
+
target: Point,
|
|
176
|
+
sourceSide: PortSide,
|
|
177
|
+
targetSide: PortSide,
|
|
178
|
+
): Point[] {
|
|
179
|
+
const points: Point[] = [quantize(source)];
|
|
180
|
+
const pushOrtho = (stop: Point, fromSide?: PortSide) => {
|
|
181
|
+
const prev = points[points.length - 1],
|
|
182
|
+
next = quantize(stop);
|
|
183
|
+
if (aligned(prev.x, next.x) || aligned(prev.y, next.y)) {
|
|
184
|
+
points.push(next);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
points.push(quantize(elbow(prev, next, fromSide)), next);
|
|
188
|
+
};
|
|
189
|
+
const out = exitVector(sourceSide, 20);
|
|
190
|
+
const first = waypoints[0];
|
|
191
|
+
const leaves =
|
|
192
|
+
!!first &&
|
|
193
|
+
((sourceSide === 'right' && first.x > source.x + 1 && aligned(first.y, source.y)) ||
|
|
194
|
+
(sourceSide === 'left' && first.x < source.x - 1 && aligned(first.y, source.y)) ||
|
|
195
|
+
(sourceSide === 'bottom' && first.y > source.y + 1 && aligned(first.x, source.x)) ||
|
|
196
|
+
(sourceSide === 'top' && first.y < source.y - 1 && aligned(first.x, source.x)));
|
|
197
|
+
if (!leaves) pushOrtho({ x: source.x + out.x, y: source.y + out.y }, sourceSide);
|
|
198
|
+
for (const stop of waypoints) pushOrtho(stop);
|
|
199
|
+
const into = exitVector(targetSide, 20);
|
|
200
|
+
pushOrtho({ x: target.x + into.x, y: target.y + into.y });
|
|
201
|
+
pushOrtho(target);
|
|
202
|
+
return simplify(points);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Slide only the segments that already leave and enter on the requested sides.
|
|
206
|
+
* Returns null when keeping those bends would reverse a port or break orthogonality.
|
|
207
|
+
*/
|
|
208
|
+
export function retargetRoute(
|
|
209
|
+
points: Point[],
|
|
210
|
+
source: Point,
|
|
211
|
+
target: Point,
|
|
212
|
+
sourceSide: PortSide,
|
|
213
|
+
targetSide: PortSide,
|
|
214
|
+
): Point[] | null {
|
|
215
|
+
const base = simplify(points.map((p) => ({ ...p })));
|
|
216
|
+
if (base.length < 4) return null;
|
|
217
|
+
const horizontalExit = sourceSide === 'left' || sourceSide === 'right';
|
|
218
|
+
const firstHorizontal = aligned(base[0].y, base[1].y);
|
|
219
|
+
if (horizontalExit !== firstHorizontal) return null;
|
|
220
|
+
const last = base.length - 1;
|
|
221
|
+
const horizontalEntry = targetSide === 'left' || targetSide === 'right';
|
|
222
|
+
const lastHorizontal = aligned(base[last].y, base[last - 1].y);
|
|
223
|
+
if (horizontalEntry !== lastHorizontal) return null;
|
|
224
|
+
const next = base.map((p) => ({ ...p }));
|
|
225
|
+
next[0] = quantize(source);
|
|
226
|
+
next[1] = firstHorizontal ? { x: next[1].x, y: next[0].y } : { x: next[0].x, y: next[1].y };
|
|
227
|
+
next[last] = quantize(target);
|
|
228
|
+
next[last - 1] = lastHorizontal
|
|
229
|
+
? { x: next[last - 1].x, y: next[last].y }
|
|
230
|
+
: { x: next[last].x, y: next[last - 1].y };
|
|
231
|
+
const dx = next[1].x - next[0].x,
|
|
232
|
+
dy = next[1].y - next[0].y;
|
|
233
|
+
const backward =
|
|
234
|
+
(sourceSide === 'right' && dx < -0.5) ||
|
|
235
|
+
(sourceSide === 'left' && dx > 0.5) ||
|
|
236
|
+
(sourceSide === 'bottom' && dy < -0.5) ||
|
|
237
|
+
(sourceSide === 'top' && dy > 0.5);
|
|
238
|
+
const tdx = next[last].x - next[last - 1].x,
|
|
239
|
+
tdy = next[last].y - next[last - 1].y;
|
|
240
|
+
const arriveBad =
|
|
241
|
+
(targetSide === 'left' && tdx < -0.5) ||
|
|
242
|
+
(targetSide === 'right' && tdx > 0.5) ||
|
|
243
|
+
(targetSide === 'top' && tdy < -0.5) ||
|
|
244
|
+
(targetSide === 'bottom' && tdy > 0.5);
|
|
245
|
+
const simplified = simplify(next);
|
|
246
|
+
if (backward || arriveBad || !isOrthogonal(simplified)) return null;
|
|
247
|
+
return simplified;
|
|
248
|
+
}
|
|
249
|
+
export function routesCross(a: Point[], b: Point[]) {
|
|
250
|
+
for (let s = 1; s < a.length; s++)
|
|
251
|
+
for (let t = 1; t < b.length; t++)
|
|
252
|
+
if (segmentCross(a[s - 1], a[s], b[t - 1], b[t])) return true;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
export function pointSegmentDistance(p: Point, a: Point, b: Point) {
|
|
256
|
+
const dx = b.x - a.x,
|
|
257
|
+
dy = b.y - a.y,
|
|
258
|
+
len = dx * dx + dy * dy;
|
|
259
|
+
const t = len ? Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len)) : 0;
|
|
260
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
261
|
+
}
|
|
262
|
+
/** Insert a corner on the closest segment. Anchors include the port endpoints; the result does not. */
|
|
263
|
+
export function insertWaypoint(anchors: Point[], point: Point): Point[] {
|
|
264
|
+
let best = 1,
|
|
265
|
+
bestDist = Infinity;
|
|
266
|
+
for (let i = 1; i < anchors.length; i++) {
|
|
267
|
+
const dist = pointSegmentDistance(point, anchors[i - 1], anchors[i]);
|
|
268
|
+
if (dist < bestDist) {
|
|
269
|
+
bestDist = dist;
|
|
270
|
+
best = i;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const next = anchors.map((p) => ({ ...p }));
|
|
274
|
+
next.splice(best, 0, quantize(point));
|
|
275
|
+
return next.slice(1, -1);
|
|
276
|
+
}
|
|
277
|
+
export function departSide(from: Point, toward: Point): PortSide {
|
|
278
|
+
const dx = toward.x - from.x,
|
|
279
|
+
dy = toward.y - from.y;
|
|
280
|
+
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
|
|
281
|
+
return dy >= 0 ? 'bottom' : 'top';
|
|
282
|
+
}
|
|
283
|
+
export function arriveSide(from: Point, at: Point): PortSide {
|
|
284
|
+
const dx = at.x - from.x,
|
|
285
|
+
dy = at.y - from.y;
|
|
286
|
+
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'left' : 'right';
|
|
287
|
+
return dy >= 0 ? 'top' : 'bottom';
|
|
288
|
+
}
|
|
289
|
+
/** Which side an endpoint sits on. A hint point is the next bend, used to break corner ties. */
|
|
290
|
+
/** Orthogonal visibility-grid Dijkstra. Crossing an occupied route is expensive; sharing a line is free. */
|
|
291
|
+
export function routeBetween(
|
|
292
|
+
source: Box,
|
|
293
|
+
target: Box,
|
|
294
|
+
obstacles: Box[],
|
|
295
|
+
down: boolean,
|
|
296
|
+
self = false,
|
|
297
|
+
sourcePort?: PortSide,
|
|
298
|
+
targetPort?: PortSide,
|
|
299
|
+
sourceIndex?: number,
|
|
300
|
+
targetIndex?: number,
|
|
301
|
+
sourceCount = 1,
|
|
302
|
+
targetCount = 1,
|
|
303
|
+
peers: Point[][] = [],
|
|
304
|
+
): Point[] {
|
|
305
|
+
if (self)
|
|
306
|
+
return [
|
|
307
|
+
{ x: source.x + source.width, y: source.y + source.height * 0.65 },
|
|
308
|
+
{ x: source.x + source.width + 40, y: source.y + source.height * 0.65 },
|
|
309
|
+
{ x: source.x + source.width + 40, y: source.y - 28 },
|
|
310
|
+
{ x: source.x + source.width * 0.6, y: source.y - 28 },
|
|
311
|
+
{ x: source.x + source.width * 0.6, y: source.y },
|
|
312
|
+
];
|
|
313
|
+
const startSide = sourcePort ?? (down ? 'bottom' : 'right'),
|
|
314
|
+
endSide = targetPort ?? (down ? 'top' : 'left');
|
|
315
|
+
const a = portPoint(source, startSide, sourceIndex ?? centerPortIndex(sourceCount), sourceCount),
|
|
316
|
+
b = portPoint(target, endSide, targetIndex ?? centerPortIndex(targetCount), targetCount);
|
|
317
|
+
const stub = (point: Point, side: string) => ({
|
|
318
|
+
x: point.x + (side === 'left' ? -20 : side === 'right' ? 20 : 0),
|
|
319
|
+
y: point.y + (side === 'top' ? -20 : side === 'bottom' ? 20 : 0),
|
|
320
|
+
});
|
|
321
|
+
const start = stub(a, startSide),
|
|
322
|
+
end = stub(b, endSide);
|
|
323
|
+
const boxes = obstacles.map((o) => ({
|
|
324
|
+
x: o.x - 12,
|
|
325
|
+
y: o.y - 12,
|
|
326
|
+
width: o.width + 24,
|
|
327
|
+
height: o.height + 24,
|
|
328
|
+
}));
|
|
329
|
+
const peerPoints = peers.flat();
|
|
330
|
+
const xs = [
|
|
331
|
+
...new Set(
|
|
332
|
+
[
|
|
333
|
+
start.x,
|
|
334
|
+
end.x,
|
|
335
|
+
...boxes.flatMap((o) => [o.x, o.x + o.width]),
|
|
336
|
+
...peerPoints.map((p) => p.x),
|
|
337
|
+
].map(round),
|
|
338
|
+
),
|
|
339
|
+
].sort((a, b) => a - b);
|
|
340
|
+
const ys = [
|
|
341
|
+
...new Set(
|
|
342
|
+
[
|
|
343
|
+
start.y,
|
|
344
|
+
end.y,
|
|
345
|
+
...boxes.flatMap((o) => [o.y, o.y + o.height]),
|
|
346
|
+
...peerPoints.map((p) => p.y),
|
|
347
|
+
].map(round),
|
|
348
|
+
),
|
|
349
|
+
].sort((a, b) => a - b);
|
|
350
|
+
const width = xs.length,
|
|
351
|
+
count = width * ys.length;
|
|
352
|
+
const startIndex = ys.indexOf(round(start.y)) * width + xs.indexOf(round(start.x)),
|
|
353
|
+
endIndex = ys.indexOf(round(end.y)) * width + xs.indexOf(round(end.x));
|
|
354
|
+
const distances = new Float64Array(count * 2).fill(Infinity),
|
|
355
|
+
previous = new Int32Array(count * 2).fill(-1);
|
|
356
|
+
const visited = new Uint8Array(count * 2);
|
|
357
|
+
// Small binary heap keeps routing practical on medium diagrams.
|
|
358
|
+
const heap: [number, number][] = [];
|
|
359
|
+
const push = (item: [number, number]) => {
|
|
360
|
+
heap.push(item);
|
|
361
|
+
let i = heap.length - 1;
|
|
362
|
+
while (i) {
|
|
363
|
+
const p = (i - 1) >> 1;
|
|
364
|
+
if (heap[p][0] <= item[0]) break;
|
|
365
|
+
heap[i] = heap[p];
|
|
366
|
+
i = p;
|
|
367
|
+
}
|
|
368
|
+
heap[i] = item;
|
|
369
|
+
};
|
|
370
|
+
const pop = () => {
|
|
371
|
+
const root = heap[0],
|
|
372
|
+
tail = heap.pop()!;
|
|
373
|
+
if (heap.length) {
|
|
374
|
+
let i = 0;
|
|
375
|
+
while (i * 2 + 1 < heap.length) {
|
|
376
|
+
let c = i * 2 + 1;
|
|
377
|
+
if (c + 1 < heap.length && heap[c + 1][0] < heap[c][0]) c++;
|
|
378
|
+
if (heap[c][0] >= tail[0]) break;
|
|
379
|
+
heap[i] = heap[c];
|
|
380
|
+
i = c;
|
|
381
|
+
}
|
|
382
|
+
heap[i] = tail;
|
|
383
|
+
}
|
|
384
|
+
return root;
|
|
385
|
+
};
|
|
386
|
+
distances[startIndex * 2] = 0;
|
|
387
|
+
distances[startIndex * 2 + 1] = 0;
|
|
388
|
+
push([0, startIndex * 2]);
|
|
389
|
+
push([0, startIndex * 2 + 1]);
|
|
390
|
+
let finish = -1;
|
|
391
|
+
while (heap.length) {
|
|
392
|
+
const [distance, state] = pop();
|
|
393
|
+
if (visited[state]) continue;
|
|
394
|
+
visited[state] = 1;
|
|
395
|
+
const index = state >> 1,
|
|
396
|
+
dir = state % 2,
|
|
397
|
+
x = index % width,
|
|
398
|
+
y = Math.floor(index / width);
|
|
399
|
+
if (index === endIndex) {
|
|
400
|
+
finish = state;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
for (const [dx, dy, nextDir] of [
|
|
404
|
+
[1, 0, 0],
|
|
405
|
+
[-1, 0, 0],
|
|
406
|
+
[0, 1, 1],
|
|
407
|
+
[0, -1, 1],
|
|
408
|
+
]) {
|
|
409
|
+
const nx = x + dx,
|
|
410
|
+
ny = y + dy;
|
|
411
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= ys.length) continue;
|
|
412
|
+
const p = { x: xs[x], y: ys[y] },
|
|
413
|
+
q = { x: xs[nx], y: ys[ny] };
|
|
414
|
+
if (boxes.some((o) => inside(q, o) || segmentHitsBox(p, q, o))) continue;
|
|
415
|
+
let penalty = 0;
|
|
416
|
+
for (const route of peers) {
|
|
417
|
+
for (let i = 1; i < route.length; i++)
|
|
418
|
+
if (segmentCross(p, q, route[i - 1], route[i])) {
|
|
419
|
+
penalty += 900;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
if (penalty >= 900) break;
|
|
423
|
+
}
|
|
424
|
+
const next = (ny * width + nx) * 2 + nextDir,
|
|
425
|
+
cost =
|
|
426
|
+
distance +
|
|
427
|
+
Math.abs(p.x - q.x) +
|
|
428
|
+
Math.abs(p.y - q.y) +
|
|
429
|
+
(dir === nextDir ? 0 : 24) +
|
|
430
|
+
penalty;
|
|
431
|
+
if (cost < distances[next]) {
|
|
432
|
+
distances[next] = cost;
|
|
433
|
+
previous[next] = state;
|
|
434
|
+
push([cost, next]);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (finish < 0) return simplify([a, start, { x: start.x, y: end.y }, end, b]); // Inspector reports impossible pinned routes.
|
|
439
|
+
const path: Point[] = [];
|
|
440
|
+
for (let s = finish; s !== -1; s = previous[s]) {
|
|
441
|
+
const i = s >> 1;
|
|
442
|
+
path.push({ x: xs[i % width], y: ys[Math.floor(i / width)] });
|
|
443
|
+
}
|
|
444
|
+
return simplify([a, ...path.reverse(), b]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Ray from shape center to an exterior point, used for direct connectors. */
|
|
448
|
+
export function shapeBoundary(box: Box, toward: Point, shape = 'rect'): Point {
|
|
449
|
+
const x = box.x + box.width / 2,
|
|
450
|
+
y = box.y + box.height / 2,
|
|
451
|
+
dx = toward.x - x,
|
|
452
|
+
dy = toward.y - y;
|
|
453
|
+
if (!dx && !dy) return { x: box.x + box.width, y };
|
|
454
|
+
const nx = Math.abs(dx) / (box.width / 2),
|
|
455
|
+
ny = Math.abs(dy) / (box.height / 2);
|
|
456
|
+
const scale =
|
|
457
|
+
shape === 'ellipse'
|
|
458
|
+
? 1 / Math.hypot(nx, ny)
|
|
459
|
+
: shape === 'diamond'
|
|
460
|
+
? 1 / (nx + ny)
|
|
461
|
+
: 1 / Math.max(nx, ny);
|
|
462
|
+
return { x: x + dx * scale, y: y + dy * scale };
|
|
463
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './document';
|
|
2
|
+
export * from './scene';
|
|
3
|
+
export * from './layout';
|
|
4
|
+
export * from './inspect';
|
|
5
|
+
export * from './align';
|
|
6
|
+
export * from './render';
|
|
7
|
+
export * from './theme';
|
|
8
|
+
export * from './geometry';
|
|
9
|
+
|
|
10
|
+
export * from './styles';
|
|
11
|
+
|
|
12
|
+
export * from './resolve-style';
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { Point } from './document';
|
|
2
|
+
import type { Scene, Box, SceneNode } from './scene';
|
|
3
|
+
import { overlaps, segmentCross, segmentHitsBox, shapeBoundary } from './geometry';
|
|
4
|
+
import { textWidth } from './text';
|
|
5
|
+
import { selectedAlignmentClusters, type AlignEdge } from './align';
|
|
6
|
+
export interface VisualIssue {
|
|
7
|
+
code: string;
|
|
8
|
+
severity: 'error' | 'warning';
|
|
9
|
+
message: string;
|
|
10
|
+
ids: string[];
|
|
11
|
+
location?: { x: number; y: number };
|
|
12
|
+
fix?: { edge: AlignEdge; target: number };
|
|
13
|
+
}
|
|
14
|
+
export function inspectScene(scene: Scene) {
|
|
15
|
+
const issues: VisualIssue[] = [];
|
|
16
|
+
const add = (code: string, severity: VisualIssue['severity'], message: string, ids: string[]) =>
|
|
17
|
+
issues.push({ code, severity, message, ids });
|
|
18
|
+
const { nodes, groups, edges, bounds } = scene;
|
|
19
|
+
for (let i = 0; i < nodes.length; i++)
|
|
20
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
21
|
+
const a = nodes[i],
|
|
22
|
+
b = nodes[j];
|
|
23
|
+
if (overlaps(a, b))
|
|
24
|
+
add(
|
|
25
|
+
'node-overlap',
|
|
26
|
+
'error',
|
|
27
|
+
`${a.semantic.label} overlaps ${b.semantic.label}. Move a pinned node or reset its position.`,
|
|
28
|
+
[a.id, b.id],
|
|
29
|
+
);
|
|
30
|
+
else if (overlaps(a, b, 20))
|
|
31
|
+
add('tight-spacing', 'warning', 'Nodes have less than 20px of breathing room.', [
|
|
32
|
+
a.id,
|
|
33
|
+
b.id,
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
for (const cluster of selectedAlignmentClusters(nodes)) {
|
|
37
|
+
const labels = cluster.ids
|
|
38
|
+
.map((id) => nodes.find((n) => n.id === id)?.semantic.label ?? id)
|
|
39
|
+
.join(', ');
|
|
40
|
+
issues.push({
|
|
41
|
+
code: 'near-alignment',
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
message: `${cluster.edge} edges of ${labels} are ${cluster.delta}px apart. Snap them to a shared ${cluster.edge} edge.`,
|
|
44
|
+
ids: cluster.ids,
|
|
45
|
+
fix: { edge: cluster.edge, target: cluster.target },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
for (const n of nodes) {
|
|
49
|
+
if (n.style?.fontFamily && n.style.fontFamily !== 'IBM Plex Sans')
|
|
50
|
+
add(
|
|
51
|
+
'font-fallback',
|
|
52
|
+
'warning',
|
|
53
|
+
'Only IBM Plex Sans is bundled and measured. Other font families may use platform fallback; inspect the export.',
|
|
54
|
+
[n.id],
|
|
55
|
+
);
|
|
56
|
+
if (
|
|
57
|
+
n.titleLines.some(
|
|
58
|
+
(l) =>
|
|
59
|
+
textWidth(l, n.style?.fontSize ?? 16, n.style?.fontWeight ?? 600) >
|
|
60
|
+
n.width - 2 * (n.style?.padding ?? 19.5),
|
|
61
|
+
) ||
|
|
62
|
+
n.descriptionLines.some((l) => textWidth(l, 12) > n.width - 39)
|
|
63
|
+
)
|
|
64
|
+
add('text-clipping', 'error', 'Text exceeds the measured card width.', [n.id]);
|
|
65
|
+
if (
|
|
66
|
+
n.x < bounds.x ||
|
|
67
|
+
n.y < bounds.y ||
|
|
68
|
+
n.x + n.width > bounds.x + bounds.width ||
|
|
69
|
+
n.y + n.height > bounds.y + bounds.height
|
|
70
|
+
)
|
|
71
|
+
add('canvas-clipping', 'error', 'Node extends beyond the export bounds.', [n.id]);
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < groups.length; i++)
|
|
74
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
75
|
+
const a = groups[i],
|
|
76
|
+
b = groups[j];
|
|
77
|
+
if (a.semantic.parent === b.semantic.parent && overlaps(a, b))
|
|
78
|
+
add('group-overlap', 'warning', 'Sibling groups overlap after a manual edit.', [
|
|
79
|
+
a.id,
|
|
80
|
+
b.id,
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
for (const g of groups)
|
|
84
|
+
for (const n of nodes)
|
|
85
|
+
if (n.semantic.group !== g.id && !isAncestor(g.id, n.semantic.group, scene) && overlaps(g, n))
|
|
86
|
+
add('group-intrusion', 'warning', 'A node sits inside a group it does not belong to.', [
|
|
87
|
+
n.id,
|
|
88
|
+
g.id,
|
|
89
|
+
]);
|
|
90
|
+
for (const g of groups)
|
|
91
|
+
if (
|
|
92
|
+
textWidth(g.semantic.label.toUpperCase(), 11, 600) + g.semantic.label.length * 1.2 + 63 >
|
|
93
|
+
g.width
|
|
94
|
+
)
|
|
95
|
+
add('group-heading-clipping', 'error', 'The group heading exceeds its container.', [g.id]);
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const onBoundary = (p: Point | undefined, b: SceneNode | undefined) =>
|
|
98
|
+
p &&
|
|
99
|
+
b &&
|
|
100
|
+
(edge.style?.routing === 'straight'
|
|
101
|
+
? Math.hypot(
|
|
102
|
+
p.x - shapeBoundary(b, p, b.style?.shape).x,
|
|
103
|
+
p.y - shapeBoundary(b, p, b.style?.shape).y,
|
|
104
|
+
) < 1.5
|
|
105
|
+
: p &&
|
|
106
|
+
b &&
|
|
107
|
+
p.x >= b.x - 1 &&
|
|
108
|
+
p.x <= b.x + b.width + 1 &&
|
|
109
|
+
p.y >= b.y - 1 &&
|
|
110
|
+
p.y <= b.y + b.height + 1 &&
|
|
111
|
+
Math.min(
|
|
112
|
+
Math.abs(p.x - b.x),
|
|
113
|
+
Math.abs(p.x - b.x - b.width),
|
|
114
|
+
Math.abs(p.y - b.y),
|
|
115
|
+
Math.abs(p.y - b.y - b.height),
|
|
116
|
+
) <= 1);
|
|
117
|
+
if (
|
|
118
|
+
!onBoundary(
|
|
119
|
+
edge.points[0],
|
|
120
|
+
nodes.find((n) => n.id === edge.semantic.source),
|
|
121
|
+
) ||
|
|
122
|
+
!onBoundary(
|
|
123
|
+
edge.points.at(-1),
|
|
124
|
+
nodes.find((n) => n.id === edge.semantic.target),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
add(
|
|
128
|
+
'detached-connector',
|
|
129
|
+
'error',
|
|
130
|
+
'A connector endpoint does not meet its component boundary.',
|
|
131
|
+
[edge.id],
|
|
132
|
+
);
|
|
133
|
+
if (
|
|
134
|
+
edge.style?.routing !== 'straight' &&
|
|
135
|
+
edge.points.some(
|
|
136
|
+
(p, i) =>
|
|
137
|
+
i > 0 &&
|
|
138
|
+
Math.abs(p.x - edge.points[i - 1].x) > 0.01 &&
|
|
139
|
+
Math.abs(p.y - edge.points[i - 1].y) > 0.01,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
add('nonorthogonal-connector', 'warning', 'A connector contains a diagonal segment.', [
|
|
143
|
+
edge.id,
|
|
144
|
+
]);
|
|
145
|
+
for (const n of nodes) {
|
|
146
|
+
if (n.id === edge.semantic.source || n.id === edge.semantic.target) continue;
|
|
147
|
+
if (edge.points.some((p, i) => i > 0 && segmentHitsBox(edge.points[i - 1], p, n)))
|
|
148
|
+
add(
|
|
149
|
+
'edge-node-collision',
|
|
150
|
+
'error',
|
|
151
|
+
'A connector passes through a node. Release a conflicting pin or increase spacing.',
|
|
152
|
+
[edge.id, n.id],
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (edge.label) {
|
|
156
|
+
for (const n of nodes)
|
|
157
|
+
if (overlaps(edge.label, n))
|
|
158
|
+
add('label-overlap', 'error', 'A relationship label overlaps a node.', [edge.id, n.id]);
|
|
159
|
+
for (const g of groups)
|
|
160
|
+
if (overlaps(edge.label, { x: g.x, y: g.y, width: g.width, height: 46 }))
|
|
161
|
+
add('group-label-overlap', 'warning', 'Relationship label overlaps a group heading.', [
|
|
162
|
+
edge.id,
|
|
163
|
+
g.id,
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let crossings = 0;
|
|
168
|
+
for (let i = 0; i < edges.length; i++)
|
|
169
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
170
|
+
const a = edges[i],
|
|
171
|
+
b = edges[j];
|
|
172
|
+
if (a.label && b.label && overlaps(a.label, b.label))
|
|
173
|
+
add('label-overlap', 'warning', 'Relationship labels overlap.', [a.id, b.id]);
|
|
174
|
+
let cross = false;
|
|
175
|
+
for (let s = 1; s < a.points.length; s++)
|
|
176
|
+
for (let t = 1; t < b.points.length; t++)
|
|
177
|
+
if (segmentCross(a.points[s - 1], a.points[s], b.points[t - 1], b.points[t]))
|
|
178
|
+
cross = true;
|
|
179
|
+
if (cross) {
|
|
180
|
+
crossings++;
|
|
181
|
+
add(
|
|
182
|
+
'edge-crossing',
|
|
183
|
+
'warning',
|
|
184
|
+
'Connectors cross. Consider changing direction or separating branches.',
|
|
185
|
+
[a.id, b.id],
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const nodeArea = nodes.reduce((area, n) => area + n.width * n.height, 0);
|
|
190
|
+
const contentBounds: Box = nodes.length
|
|
191
|
+
? {
|
|
192
|
+
x: Math.min(...nodes.map((n) => n.x)),
|
|
193
|
+
y: Math.min(...nodes.map((n) => n.y)),
|
|
194
|
+
width: 0,
|
|
195
|
+
height: 0,
|
|
196
|
+
}
|
|
197
|
+
: { x: 0, y: 0, width: 1, height: 1 };
|
|
198
|
+
if (nodes.length) {
|
|
199
|
+
contentBounds.width = Math.max(...nodes.map((n) => n.x + n.width)) - contentBounds.x;
|
|
200
|
+
contentBounds.height = Math.max(...nodes.map((n) => n.y + n.height)) - contentBounds.y;
|
|
201
|
+
}
|
|
202
|
+
const density = nodeArea / (contentBounds.width * contentBounds.height || 1);
|
|
203
|
+
if (nodes.length > 3 && density > 0.78)
|
|
204
|
+
add(
|
|
205
|
+
'high-density',
|
|
206
|
+
'warning',
|
|
207
|
+
'Nodes occupy more than 78% of the composition. Use comfortable spacing.',
|
|
208
|
+
nodes.map((n) => n.id),
|
|
209
|
+
);
|
|
210
|
+
return {
|
|
211
|
+
fingerprint: scene.fingerprint,
|
|
212
|
+
issues,
|
|
213
|
+
summary: {
|
|
214
|
+
errors: issues.filter((i) => i.severity === 'error').length,
|
|
215
|
+
warnings: issues.filter((i) => i.severity === 'warning').length,
|
|
216
|
+
nodes: nodes.length,
|
|
217
|
+
edges: edges.length,
|
|
218
|
+
crossings,
|
|
219
|
+
pinned: nodes.filter((n) => n.pinned).length,
|
|
220
|
+
density: Math.round(density * 100) / 100,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function isAncestor(ancestor: string, child: string | undefined, scene: Scene): boolean {
|
|
225
|
+
while (child) {
|
|
226
|
+
if (child === ancestor) return true;
|
|
227
|
+
child = scene.groups.find((g) => g.id === child)?.semantic.parent;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|