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,450 @@
|
|
|
1
|
+
import { resolveNodeStyle, resolveEdgeStyle, resolveGroupStyle } from './resolve-style';
|
|
2
|
+
import type { ElkNode, ElkExtendedEdge, ELK } from 'elkjs';
|
|
3
|
+
import { fingerprint, parseDocument, type Diagram, type Point } from './document';
|
|
4
|
+
import { nodeText, styledNodeText, textWidth, wrapText } from './text';
|
|
5
|
+
import type { Box, Scene, SceneEdge, SceneGroup, SceneNode } from './scene';
|
|
6
|
+
import {
|
|
7
|
+
arriveSide,
|
|
8
|
+
attachmentPoint,
|
|
9
|
+
departSide,
|
|
10
|
+
isOrthogonal,
|
|
11
|
+
overlaps,
|
|
12
|
+
retargetRoute,
|
|
13
|
+
routeBetween,
|
|
14
|
+
routesCross,
|
|
15
|
+
routeThrough,
|
|
16
|
+
segmentHitsBox,
|
|
17
|
+
shapeBoundary,
|
|
18
|
+
simplify,
|
|
19
|
+
} from './geometry';
|
|
20
|
+
let elkPromise: Promise<ELK> | undefined;
|
|
21
|
+
const groupMinWidth = (label: string) =>
|
|
22
|
+
Math.max(180, Math.ceil(textWidth(label.toUpperCase(), 11, 600) + label.length * 1.2 + 64));
|
|
23
|
+
|
|
24
|
+
export async function layoutDiagram(input: Diagram): Promise<Scene> {
|
|
25
|
+
const elk = await (elkPromise ??= import('elkjs/lib/elk.bundled.js').then(
|
|
26
|
+
(module) => new module.default(),
|
|
27
|
+
));
|
|
28
|
+
const doc = parseDocument(input),
|
|
29
|
+
compact = doc.layout.spacing === 'compact';
|
|
30
|
+
const system = doc.presentation.designSystem;
|
|
31
|
+
const nodeStyles = new Map(doc.nodes.map((n) => [n.id, resolveNodeStyle(doc, n)]));
|
|
32
|
+
const measured = new Map(
|
|
33
|
+
doc.nodes.map((n) => {
|
|
34
|
+
const style = nodeStyles.get(n.id)!;
|
|
35
|
+
return [
|
|
36
|
+
n.id,
|
|
37
|
+
Object.keys(style).length
|
|
38
|
+
? styledNodeText(n.label, n.description, style)
|
|
39
|
+
: nodeText(n.label, n.description),
|
|
40
|
+
];
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
const buildChildren = (parent?: string): ElkNode[] => [
|
|
44
|
+
...doc.groups
|
|
45
|
+
.filter((g) => g.parent === parent)
|
|
46
|
+
.map((g) => ({
|
|
47
|
+
id: g.id,
|
|
48
|
+
children: buildChildren(g.id),
|
|
49
|
+
width: groupMinWidth(g.label),
|
|
50
|
+
height: 100,
|
|
51
|
+
layoutOptions: {
|
|
52
|
+
'elk.padding': '[top=64,left=24,bottom=24,right=24]',
|
|
53
|
+
'elk.nodeSize.constraints': 'MINIMUM_SIZE',
|
|
54
|
+
'elk.nodeSize.minimum': `(${groupMinWidth(g.label)},100)`,
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
...doc.nodes
|
|
58
|
+
.filter((n) => n.group === parent)
|
|
59
|
+
.map((n) => ({
|
|
60
|
+
id: n.id,
|
|
61
|
+
width: measured.get(n.id)!.width,
|
|
62
|
+
height: measured.get(n.id)!.height,
|
|
63
|
+
})),
|
|
64
|
+
];
|
|
65
|
+
const graph = await elk.layout({
|
|
66
|
+
id: '__forma_root__',
|
|
67
|
+
layoutOptions: {
|
|
68
|
+
'elk.algorithm': 'layered',
|
|
69
|
+
'elk.direction': doc.layout.direction,
|
|
70
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
71
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
72
|
+
'elk.spacing.nodeNode': String(system?.spacing?.node ?? (compact ? 32 : 48)),
|
|
73
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(
|
|
74
|
+
system?.spacing?.layer ?? (compact ? 56 : 88),
|
|
75
|
+
),
|
|
76
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': '28',
|
|
77
|
+
'elk.spacing.edgeNode': '24',
|
|
78
|
+
'elk.spacing.edgeEdge': '0',
|
|
79
|
+
'elk.layered.spacing.edgeEdgeBetweenLayers': '0',
|
|
80
|
+
'elk.padding': '[top=0,left=0,bottom=0,right=0]',
|
|
81
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
82
|
+
'elk.randomSeed': '1',
|
|
83
|
+
'elk.separateConnectedComponents': 'true',
|
|
84
|
+
},
|
|
85
|
+
children: buildChildren(),
|
|
86
|
+
edges: doc.edges.map((e) => ({
|
|
87
|
+
id: e.id,
|
|
88
|
+
sources: [e.source],
|
|
89
|
+
targets: [e.target],
|
|
90
|
+
...(e.label
|
|
91
|
+
? { labels: [{ text: e.label, width: textWidth(e.label, 11) + 20, height: 24 }] }
|
|
92
|
+
: {}),
|
|
93
|
+
})),
|
|
94
|
+
});
|
|
95
|
+
const nodes: SceneNode[] = [],
|
|
96
|
+
groups: SceneGroup[] = [],
|
|
97
|
+
edges: SceneEdge[] = [];
|
|
98
|
+
const nodeMap = new Map(doc.nodes.map((n) => [n.id, n])),
|
|
99
|
+
groupMap = new Map(doc.groups.map((g) => [g.id, g]));
|
|
100
|
+
const rawEdges: { edge: ElkExtendedEdge; x: number; y: number }[] = [];
|
|
101
|
+
function walk(container: ElkNode, x: number, y: number, depth: number) {
|
|
102
|
+
for (const e of container.edges ?? []) rawEdges.push({ edge: e, x, y });
|
|
103
|
+
for (const child of container.children ?? []) {
|
|
104
|
+
const box = {
|
|
105
|
+
x: x + (child.x ?? 0),
|
|
106
|
+
y: y + (child.y ?? 0),
|
|
107
|
+
width: child.width ?? 224,
|
|
108
|
+
height: child.height ?? 120,
|
|
109
|
+
};
|
|
110
|
+
const semantic = nodeMap.get(child.id);
|
|
111
|
+
if (semantic) {
|
|
112
|
+
const override = doc.presentation.nodes[child.id];
|
|
113
|
+
nodes.push({
|
|
114
|
+
...box,
|
|
115
|
+
...measured.get(child.id)!,
|
|
116
|
+
id: child.id,
|
|
117
|
+
semantic,
|
|
118
|
+
style: nodeStyles.get(child.id),
|
|
119
|
+
accent:
|
|
120
|
+
override?.color ??
|
|
121
|
+
(semantic.emphasis === 'primary'
|
|
122
|
+
? 'blue'
|
|
123
|
+
: semantic.group
|
|
124
|
+
? groupMap.get(semantic.group)!.color
|
|
125
|
+
: doc.type === 'flow'
|
|
126
|
+
? 'blue'
|
|
127
|
+
: 'slate'),
|
|
128
|
+
pinned: !!override?.position,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
const semantic = groupMap.get(child.id)!;
|
|
132
|
+
groups.push({
|
|
133
|
+
...box,
|
|
134
|
+
id: child.id,
|
|
135
|
+
semantic,
|
|
136
|
+
depth,
|
|
137
|
+
style: resolveGroupStyle(doc, semantic),
|
|
138
|
+
});
|
|
139
|
+
walk(child, box.x, box.y, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
walk(graph, 0, 0, 0);
|
|
144
|
+
const allNodes = new Map(nodes.map((n) => [n.id, n]));
|
|
145
|
+
const rawMap = new Map(
|
|
146
|
+
rawEdges.map((e) => {
|
|
147
|
+
const container = groups.find(
|
|
148
|
+
(g) => g.id === (e.edge as ElkExtendedEdge & { container?: string }).container,
|
|
149
|
+
);
|
|
150
|
+
return [e.edge.id, { ...e, x: container?.x ?? e.x, y: container?.y ?? e.y }];
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
const labelBoxes: Box[] = [];
|
|
154
|
+
// ELK reserves generous compound boundary channels. Compress only wholly empty
|
|
155
|
+
// bands between top-level containers, keeping enough space for labels.
|
|
156
|
+
const axis = doc.layout.direction === 'RIGHT' ? 'x' : 'y';
|
|
157
|
+
const extent = axis === 'x' ? 'width' : 'height';
|
|
158
|
+
const occupied = [
|
|
159
|
+
...groups.filter((g) => !g.semantic.parent),
|
|
160
|
+
...nodes.filter((n) => !n.semantic.group),
|
|
161
|
+
]
|
|
162
|
+
.map((b) => [b[axis], b[axis] + b[extent]])
|
|
163
|
+
.sort((a, b) => a[0] - b[0]);
|
|
164
|
+
const bands: { start: number; end: number; remove: number }[] = [];
|
|
165
|
+
let end = occupied[0]?.[1] ?? 0;
|
|
166
|
+
for (const interval of occupied.slice(1)) {
|
|
167
|
+
if (interval[0] - end > 120)
|
|
168
|
+
bands.push({ start: end, end: interval[0], remove: interval[0] - end - 120 });
|
|
169
|
+
end = Math.max(end, interval[1]);
|
|
170
|
+
}
|
|
171
|
+
const mapAxis = (value: number) =>
|
|
172
|
+
value -
|
|
173
|
+
bands.reduce(
|
|
174
|
+
(sum, b) =>
|
|
175
|
+
sum +
|
|
176
|
+
(value >= b.end
|
|
177
|
+
? b.remove
|
|
178
|
+
: value > b.start
|
|
179
|
+
? ((value - b.start) / (b.end - b.start)) * b.remove
|
|
180
|
+
: 0),
|
|
181
|
+
0,
|
|
182
|
+
);
|
|
183
|
+
const composePoint = (point: Point) => ({
|
|
184
|
+
...point,
|
|
185
|
+
[axis]: Math.round(mapAxis(point[axis]) * 100) / 100,
|
|
186
|
+
});
|
|
187
|
+
for (const box of [...nodes, ...groups]) {
|
|
188
|
+
const max = mapAxis(box[axis] + box[extent]);
|
|
189
|
+
box[axis] = mapAxis(box[axis]);
|
|
190
|
+
box[extent] = max - box[axis];
|
|
191
|
+
}
|
|
192
|
+
if (doc.layout.mode === 'grid') {
|
|
193
|
+
const columns = new Map<number, number>(),
|
|
194
|
+
rows = new Map<number, number>();
|
|
195
|
+
for (const [i, n] of nodes.entries()) {
|
|
196
|
+
const p = n.semantic.placement ?? {
|
|
197
|
+
column: doc.nodes.findIndex((item) => item.id === n.id),
|
|
198
|
+
row: 0,
|
|
199
|
+
};
|
|
200
|
+
columns.set(p.column, Math.max(columns.get(p.column) ?? 0, n.width));
|
|
201
|
+
rows.set(p.row, Math.max(rows.get(p.row) ?? 0, n.height));
|
|
202
|
+
}
|
|
203
|
+
const offset = (map: Map<number, number>, index: number, gap: number) =>
|
|
204
|
+
[...map].filter(([key]) => key < index).reduce((sum, [, size]) => sum + size + gap, 0);
|
|
205
|
+
for (const [i, n] of nodes.entries()) {
|
|
206
|
+
const p = n.semantic.placement ?? {
|
|
207
|
+
column: doc.nodes.findIndex((item) => item.id === n.id),
|
|
208
|
+
row: 0,
|
|
209
|
+
};
|
|
210
|
+
n.x =
|
|
211
|
+
offset(columns, p.column, system?.spacing?.layer ?? 88) +
|
|
212
|
+
((columns.get(p.column) ?? n.width) - n.width) / 2;
|
|
213
|
+
n.y =
|
|
214
|
+
offset(rows, p.row, system?.spacing?.node ?? 64) +
|
|
215
|
+
((rows.get(p.row) ?? n.height) - n.height) / 2;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const n of nodes)
|
|
219
|
+
if (doc.presentation.nodes[n.id]?.position)
|
|
220
|
+
Object.assign(n, doc.presentation.nodes[n.id].position);
|
|
221
|
+
// Bounds follow human-moved descendants, preserving the semantic container.
|
|
222
|
+
if (nodes.some((n) => n.pinned) || doc.layout.mode === 'grid') {
|
|
223
|
+
for (const g of [...groups].sort((a, b) => b.depth - a.depth)) {
|
|
224
|
+
const children: Box[] = [
|
|
225
|
+
...nodes.filter((n) => n.semantic.group === g.id),
|
|
226
|
+
...groups.filter((c) => c.semantic.parent === g.id),
|
|
227
|
+
];
|
|
228
|
+
if (!children.length) continue;
|
|
229
|
+
const x = Math.min(...children.map((c) => c.x)) - 24,
|
|
230
|
+
y = Math.min(...children.map((c) => c.y)) - 64;
|
|
231
|
+
Object.assign(g, {
|
|
232
|
+
x,
|
|
233
|
+
y,
|
|
234
|
+
width: Math.max(
|
|
235
|
+
groupMinWidth(g.semantic.label),
|
|
236
|
+
Math.max(...children.map((c) => c.x + c.width)) + 24 - x,
|
|
237
|
+
),
|
|
238
|
+
height: Math.max(...children.map((c) => c.y + c.height)) + 24 - y,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const down = doc.layout.direction === 'DOWN';
|
|
243
|
+
const routed: { points: Point[]; custom: boolean; straight: boolean }[] = [];
|
|
244
|
+
const commit = (points: Point[], custom: boolean, straight: boolean) => {
|
|
245
|
+
routed.push({ points, custom, straight });
|
|
246
|
+
return points;
|
|
247
|
+
};
|
|
248
|
+
for (const semantic of doc.edges) {
|
|
249
|
+
const edgeStyle = resolveEdgeStyle(doc, semantic);
|
|
250
|
+
const source = allNodes.get(semantic.source)!,
|
|
251
|
+
target = allNodes.get(semantic.target)!;
|
|
252
|
+
const skip = new Set([source.id, target.id]);
|
|
253
|
+
const blocked = (points: Point[]) =>
|
|
254
|
+
nodes.some(
|
|
255
|
+
(n) =>
|
|
256
|
+
!skip.has(n.id) && points.some((p, i) => i > 0 && segmentHitsBox(points[i - 1], p, n)),
|
|
257
|
+
);
|
|
258
|
+
const peers = () => routed.filter((r) => !r.straight).map((r) => r.points);
|
|
259
|
+
const raw = rawMap.get(semantic.id),
|
|
260
|
+
sections = raw?.edge.sections;
|
|
261
|
+
const elkPoints = sections?.length
|
|
262
|
+
? simplify(
|
|
263
|
+
sections
|
|
264
|
+
.flatMap((s) => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint])
|
|
265
|
+
.map((p) => ({
|
|
266
|
+
x: Math.round((p.x + raw!.x) * 100) / 100,
|
|
267
|
+
y: Math.round((p.y + raw!.y) * 100) / 100,
|
|
268
|
+
}))
|
|
269
|
+
.map(composePoint),
|
|
270
|
+
)
|
|
271
|
+
: null;
|
|
272
|
+
const explicitSide = !!(edgeStyle.sourcePort || edgeStyle.targetPort);
|
|
273
|
+
const elkUsable = !!(elkPoints && elkPoints.length > 1 && doc.layout.mode !== 'grid');
|
|
274
|
+
const sourceSide =
|
|
275
|
+
edgeStyle.sourcePort ??
|
|
276
|
+
(elkUsable ? departSide(elkPoints![0], elkPoints![1]) : undefined) ??
|
|
277
|
+
(down ? 'bottom' : 'right');
|
|
278
|
+
const targetSide =
|
|
279
|
+
edgeStyle.targetPort ??
|
|
280
|
+
(elkUsable ? arriveSide(elkPoints!.at(-2)!, elkPoints!.at(-1)!) : undefined) ??
|
|
281
|
+
(down ? 'top' : 'left');
|
|
282
|
+
const from = attachmentPoint(
|
|
283
|
+
source,
|
|
284
|
+
source.semantic.ports,
|
|
285
|
+
sourceSide,
|
|
286
|
+
edgeStyle.sourceIndex,
|
|
287
|
+
sourceSide,
|
|
288
|
+
);
|
|
289
|
+
const to = attachmentPoint(
|
|
290
|
+
target,
|
|
291
|
+
target.semantic.ports,
|
|
292
|
+
targetSide,
|
|
293
|
+
edgeStyle.targetIndex,
|
|
294
|
+
targetSide,
|
|
295
|
+
);
|
|
296
|
+
let points: Point[];
|
|
297
|
+
const straight = edgeStyle.routing === 'straight' && source.id !== target.id;
|
|
298
|
+
if (straight) {
|
|
299
|
+
if (semantic.path?.length)
|
|
300
|
+
points = routeThrough(from.point, semantic.path, to.point, from.side, to.side);
|
|
301
|
+
else {
|
|
302
|
+
const center = (b: Box) => ({ x: b.x + b.width / 2, y: b.y + b.height / 2 });
|
|
303
|
+
points =
|
|
304
|
+
edgeStyle.sourcePort || edgeStyle.targetPort
|
|
305
|
+
? [from.point, to.point]
|
|
306
|
+
: [
|
|
307
|
+
shapeBoundary(source, center(target), source.style?.shape),
|
|
308
|
+
shapeBoundary(target, center(source), target.style?.shape),
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
commit(points, !!semantic.path?.length, true);
|
|
312
|
+
} else if (semantic.path?.length && source.id !== target.id) {
|
|
313
|
+
points = commit(
|
|
314
|
+
routeThrough(from.point, semantic.path, to.point, from.side, to.side),
|
|
315
|
+
true,
|
|
316
|
+
false,
|
|
317
|
+
);
|
|
318
|
+
} else if (source.id === target.id) {
|
|
319
|
+
points = commit(
|
|
320
|
+
routeBetween(
|
|
321
|
+
source,
|
|
322
|
+
target,
|
|
323
|
+
nodes,
|
|
324
|
+
down,
|
|
325
|
+
true,
|
|
326
|
+
from.side,
|
|
327
|
+
to.side,
|
|
328
|
+
from.index,
|
|
329
|
+
to.index,
|
|
330
|
+
from.count,
|
|
331
|
+
to.count,
|
|
332
|
+
),
|
|
333
|
+
false,
|
|
334
|
+
false,
|
|
335
|
+
);
|
|
336
|
+
} else {
|
|
337
|
+
let kept: Point[] | null = null;
|
|
338
|
+
if (elkPoints && doc.layout.mode !== 'grid') {
|
|
339
|
+
const adjusted =
|
|
340
|
+
retargetRoute(elkPoints, from.point, to.point, from.side, to.side) ??
|
|
341
|
+
(explicitSide ? null : elkPoints);
|
|
342
|
+
if (
|
|
343
|
+
adjusted &&
|
|
344
|
+
isOrthogonal(adjusted) &&
|
|
345
|
+
!blocked(adjusted) &&
|
|
346
|
+
!peers().some((route) => routesCross(adjusted, route))
|
|
347
|
+
)
|
|
348
|
+
kept = adjusted;
|
|
349
|
+
}
|
|
350
|
+
points = commit(
|
|
351
|
+
kept ??
|
|
352
|
+
routeBetween(
|
|
353
|
+
source,
|
|
354
|
+
target,
|
|
355
|
+
nodes,
|
|
356
|
+
down,
|
|
357
|
+
false,
|
|
358
|
+
from.side,
|
|
359
|
+
to.side,
|
|
360
|
+
from.index,
|
|
361
|
+
to.index,
|
|
362
|
+
from.count,
|
|
363
|
+
to.count,
|
|
364
|
+
peers(),
|
|
365
|
+
),
|
|
366
|
+
false,
|
|
367
|
+
false,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const style = edgeStyle;
|
|
371
|
+
const edge: SceneEdge = { id: semantic.id, semantic, points, style };
|
|
372
|
+
if (semantic.label) {
|
|
373
|
+
const lines = wrapText(semantic.label, 156, 11),
|
|
374
|
+
width = Math.max(...lines.map((l) => textWidth(l, 11))) + 16,
|
|
375
|
+
height = lines.length * 15 + 8;
|
|
376
|
+
const candidates: { box: Box; score: number }[] = [];
|
|
377
|
+
for (let i = 1; i < points.length; i++) {
|
|
378
|
+
const a = points[i - 1],
|
|
379
|
+
b = points[i],
|
|
380
|
+
length = Math.hypot(b.x - a.x, b.y - a.y);
|
|
381
|
+
for (const ratio of [0.5, 0.3, 0.7]) {
|
|
382
|
+
const x = a.x + (b.x - a.x) * ratio,
|
|
383
|
+
y = a.y + (b.y - a.y) * ratio;
|
|
384
|
+
const horizontal = a.y === b.y;
|
|
385
|
+
const box = {
|
|
386
|
+
x: horizontal ? x - width / 2 : x + 9,
|
|
387
|
+
y: horizontal ? y - height - 7 : y - height / 2,
|
|
388
|
+
width,
|
|
389
|
+
height,
|
|
390
|
+
};
|
|
391
|
+
const collisions = [
|
|
392
|
+
...nodes,
|
|
393
|
+
...labelBoxes,
|
|
394
|
+
...groups.map((g) => ({ x: g.x, y: g.y, width: g.width, height: 48 })),
|
|
395
|
+
].filter((n) => overlaps(n, box, 4)).length;
|
|
396
|
+
candidates.push({
|
|
397
|
+
box,
|
|
398
|
+
score:
|
|
399
|
+
collisions * 100000 +
|
|
400
|
+
(length < (horizontal ? width : height) + 12 ? 5000 : 0) -
|
|
401
|
+
length +
|
|
402
|
+
Math.abs(0.5 - ratio),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
407
|
+
const box = candidates[0]?.box ?? {
|
|
408
|
+
x: points[0]?.x ?? 0,
|
|
409
|
+
y: points[0]?.y ?? 0,
|
|
410
|
+
width,
|
|
411
|
+
height,
|
|
412
|
+
};
|
|
413
|
+
edge.label = { ...box, lines };
|
|
414
|
+
labelBoxes.push(box);
|
|
415
|
+
}
|
|
416
|
+
edges.push(edge);
|
|
417
|
+
}
|
|
418
|
+
const content: Box[] = [
|
|
419
|
+
...nodes,
|
|
420
|
+
...groups,
|
|
421
|
+
...labelBoxes,
|
|
422
|
+
...edges.flatMap((e) => e.points.map((p) => ({ ...p, width: 0, height: 0 }))),
|
|
423
|
+
];
|
|
424
|
+
const minX = Math.min(0, ...content.map((c) => c.x)),
|
|
425
|
+
minY = Math.min(0, ...content.map((c) => c.y));
|
|
426
|
+
const maxX = Math.max(620, ...content.map((c) => c.x + c.width)),
|
|
427
|
+
maxY = Math.max(
|
|
428
|
+
doc.version === 1 || !nodes.length ? 240 : 0,
|
|
429
|
+
...content.map((c) => c.y + c.height),
|
|
430
|
+
);
|
|
431
|
+
const width = maxX - minX + 112;
|
|
432
|
+
const titleLines = wrapText(doc.title, width - 112, 30, 600),
|
|
433
|
+
descriptionLines = wrapText(doc.description ?? '', width - 112, 13);
|
|
434
|
+
const header = Math.max(128, 48 + titleLines.length * 38 + descriptionLines.length * 19 + 28);
|
|
435
|
+
return {
|
|
436
|
+
document: doc,
|
|
437
|
+
fingerprint: fingerprint(doc),
|
|
438
|
+
nodes,
|
|
439
|
+
groups,
|
|
440
|
+
edges,
|
|
441
|
+
bounds: {
|
|
442
|
+
x: minX - 56,
|
|
443
|
+
y: minY - header,
|
|
444
|
+
width,
|
|
445
|
+
height: maxY - minY + header + (doc.presentation.footer?.hidden ? 28 : 76),
|
|
446
|
+
},
|
|
447
|
+
titleLines,
|
|
448
|
+
descriptionLines,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { dashArray } from './styles';
|
|
2
|
+
import type { Scene, SceneNode, SceneGroup, SceneEdge } from './scene';
|
|
3
|
+
import { accents, themes } from './theme';
|
|
4
|
+
import { polylinePath } from './geometry';
|
|
5
|
+
export const escapeXml = (v: string | number) =>
|
|
6
|
+
String(v)
|
|
7
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
8
|
+
.replace(
|
|
9
|
+
/[&<>"']/g,
|
|
10
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
|
|
11
|
+
);
|
|
12
|
+
const t = (
|
|
13
|
+
x: number,
|
|
14
|
+
y: number,
|
|
15
|
+
value: string,
|
|
16
|
+
size: number,
|
|
17
|
+
fill: string,
|
|
18
|
+
weight = 400,
|
|
19
|
+
extra = '',
|
|
20
|
+
) =>
|
|
21
|
+
`<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" ${extra}>${escapeXml(value)}</text>`;
|
|
22
|
+
const icons: Record<string, string> = {
|
|
23
|
+
service:
|
|
24
|
+
'<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/>',
|
|
25
|
+
database:
|
|
26
|
+
'<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>',
|
|
27
|
+
queue:
|
|
28
|
+
'<rect x="2" y="6" width="5" height="12" rx="1.5"/><rect x="9.5" y="6" width="5" height="12" rx="1.5"/><rect x="17" y="6" width="5" height="12" rx="1.5"/>',
|
|
29
|
+
client:
|
|
30
|
+
'<rect x="2" y="3" width="20" height="15" rx="2"/><path d="M8 22h8M12 18v4M2 8h20M6 5.5h.01M9 5.5h.01"/>',
|
|
31
|
+
person: '<circle cx="12" cy="7" r="4"/><path d="M4 22v-3a8 8 0 0 1 16 0v3"/>',
|
|
32
|
+
process: '<path d="m13 2-9 12h7l-1 8 10-12h-8z"/>',
|
|
33
|
+
decision: '<path d="m12 2 10 10-10 10L2 12zM12 7v6M12 17h.01"/>',
|
|
34
|
+
};
|
|
35
|
+
export function iconMarkup(kind: string, color: string, x = 0, y = 0, size = 20): string {
|
|
36
|
+
return `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${icons[kind] ?? icons.service}</g>`;
|
|
37
|
+
}
|
|
38
|
+
export function nodeMarkup(n: SceneNode, themeName: 'paper' | 'midnight'): string {
|
|
39
|
+
if (n.style && Object.keys(n.style).length) return styledNodeMarkup(n, themeName);
|
|
40
|
+
const theme = themes[themeName],
|
|
41
|
+
accent = accents[n.accent],
|
|
42
|
+
dark = themeName === 'midnight',
|
|
43
|
+
primary = n.semantic.emphasis === 'primary';
|
|
44
|
+
const ink = dark ? accent.dark : accent.ink,
|
|
45
|
+
fill = dark ? '#2b394e' : accent.fill;
|
|
46
|
+
const body = `<rect x="0.5" y="1.5" width="${n.width - 1}" height="${n.height - 1}" rx="10" fill="${theme.shadow}" opacity=".04"/>
|
|
47
|
+
<rect x=".5" y=".5" width="${n.width - 1}" height="${n.height - 1}" rx="10" fill="${primary ? fill : theme.card}" stroke="${primary ? (dark ? ink : accent.border) : theme.border}"/>
|
|
48
|
+
<rect x="20" y="18" width="32" height="32" rx="8" fill="${fill}"/>
|
|
49
|
+
${iconMarkup(n.semantic.kind, ink, 26, 24, 20)}
|
|
50
|
+
${t(n.width - 20, 38, n.semantic.kind.toUpperCase(), 9, theme.secondary, 500, 'text-anchor="end" letter-spacing="1.1"')}
|
|
51
|
+
${n.titleLines.map((line, i) => t(20, 76 + i * 22, line, 16, theme.text, 600)).join('')}
|
|
52
|
+
${n.descriptionLines.map((line, i) => t(20, 76 + n.titleLines.length * 22 + 4 + i * 17, line, 12, theme.secondary)).join('')}`;
|
|
53
|
+
return `<g opacity="${n.semantic.emphasis === 'muted' ? 0.62 : 1}">${body}</g>`;
|
|
54
|
+
}
|
|
55
|
+
function styledNodeMarkup(n: SceneNode, themeName: 'paper' | 'midnight'): string {
|
|
56
|
+
const s = n.style!,
|
|
57
|
+
theme = themes[themeName],
|
|
58
|
+
w = n.width,
|
|
59
|
+
h = n.height;
|
|
60
|
+
const shape = s.shape ?? 'rect',
|
|
61
|
+
radius = shape === 'pill' ? h / 2 : (s.radius ?? 10);
|
|
62
|
+
const attrs = `fill="${s.fill ?? theme.card}" stroke="${s.stroke ?? theme.border}" stroke-width="${s.strokeWidth ?? 1}" stroke-dasharray="${dashArray(s.dash)}"`;
|
|
63
|
+
let body = `<rect x=".5" y=".5" width="${w - 1}" height="${h - 1}" rx="${radius}" ${attrs}/>`;
|
|
64
|
+
if (shape === 'diamond')
|
|
65
|
+
body = `<path d="M${w / 2} 0 L${w} ${h / 2} L${w / 2} ${h} L0 ${h / 2}Z" ${attrs}/>`;
|
|
66
|
+
if (shape === 'ellipse')
|
|
67
|
+
body = `<ellipse cx="${w / 2}" cy="${h / 2}" rx="${w / 2 - 0.5}" ry="${h / 2 - 0.5}" ${attrs}/>`;
|
|
68
|
+
if (shape === 'cylinder')
|
|
69
|
+
body = `<path d="M0 14 C0 -4 ${w} -4 ${w} 14 V${h - 14} C${w} ${h + 4} 0 ${h + 4} 0 ${h - 14}Z M0 14 C0 32 ${w} 32 ${w} 14" ${attrs}/>`;
|
|
70
|
+
if (shape === 'text') body = '';
|
|
71
|
+
const size = s.fontSize ?? 16,
|
|
72
|
+
weight = s.fontWeight ?? 600;
|
|
73
|
+
const align = s.align ?? (['diamond', 'ellipse', 'pill'].includes(shape) ? 'center' : 'left');
|
|
74
|
+
const inset = shape === 'diamond' ? w * 0.23 : shape === 'ellipse' ? w * 0.16 : (s.padding ?? 20);
|
|
75
|
+
const x = align === 'center' ? w / 2 : align === 'right' ? w - inset : inset;
|
|
76
|
+
const anchor =
|
|
77
|
+
align === 'center' ? 'text-anchor="middle"' : align === 'right' ? 'text-anchor="end"' : '';
|
|
78
|
+
const blockHeight =
|
|
79
|
+
n.titleLines.length * size * 1.4 +
|
|
80
|
+
(n.descriptionLines.length ? 8 + n.descriptionLines.length * 17 : 0);
|
|
81
|
+
const valign = s.verticalAlign ?? 'middle';
|
|
82
|
+
const vInset = shape === 'diamond' ? h * 0.28 : shape === 'ellipse' ? h * 0.2 : (s.padding ?? 20);
|
|
83
|
+
const blockTop =
|
|
84
|
+
valign === 'top'
|
|
85
|
+
? vInset
|
|
86
|
+
: valign === 'bottom'
|
|
87
|
+
? h - vInset - blockHeight
|
|
88
|
+
: (h - blockHeight) / 2;
|
|
89
|
+
const top = blockTop + size;
|
|
90
|
+
body += n.titleLines
|
|
91
|
+
.map((line, i) => t(x, top + i * size * 1.4, line, size, s.text ?? theme.text, weight, anchor))
|
|
92
|
+
.join('');
|
|
93
|
+
body += n.descriptionLines
|
|
94
|
+
.map((line, i) =>
|
|
95
|
+
t(
|
|
96
|
+
x,
|
|
97
|
+
top + n.titleLines.length * size * 1.4 + 8 + i * 17,
|
|
98
|
+
line,
|
|
99
|
+
12,
|
|
100
|
+
s.secondary ?? theme.secondary,
|
|
101
|
+
400,
|
|
102
|
+
anchor,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.join('');
|
|
106
|
+
return `<g font-family="${escapeXml(s.fontFamily ?? 'IBM Plex Sans')}" opacity="${s.opacity ?? (n.semantic.emphasis === 'muted' ? 0.62 : 1)}">${body}</g>`;
|
|
107
|
+
}
|
|
108
|
+
export function groupMarkup(g: SceneGroup, themeName: 'paper' | 'midnight'): string {
|
|
109
|
+
const theme = themes[themeName],
|
|
110
|
+
accent = accents[g.semantic.color],
|
|
111
|
+
s = g.style ?? {};
|
|
112
|
+
const ink = s.text ?? (themeName === 'midnight' ? accent.dark : accent.ink);
|
|
113
|
+
return `<g opacity="${s.opacity ?? 1}" font-family="${escapeXml(s.fontFamily ?? 'IBM Plex Sans')}"><rect x="${g.x}" y="${g.y}" width="${g.width}" height="${g.height}" rx="${s.radius ?? 14}" fill="${s.fill ?? theme.group}" stroke="${s.stroke ?? theme.border}" stroke-width="${s.strokeWidth ?? 1}" stroke-dasharray="${dashArray(s.dash ?? 'dashed')}"/><rect x="${g.x + 24}" y="${g.y + 26}" width="6" height="6" rx="2" fill="${ink}"/>${t(g.x + 39, g.y + 33, g.semantic.label.toUpperCase(), 11, ink, 600, 'letter-spacing="1.2"')}</g>`;
|
|
114
|
+
}
|
|
115
|
+
export function edgeMarkup(e: SceneEdge, themeName: 'paper' | 'midnight'): string {
|
|
116
|
+
const s = e.style ?? {},
|
|
117
|
+
ink = s.stroke ?? themes[themeName].line,
|
|
118
|
+
width = s.strokeWidth ?? 1.5;
|
|
119
|
+
const arrow = (start: boolean) => {
|
|
120
|
+
const kind = start ? (s.arrowStart ?? 'none') : (s.arrowEnd ?? 'open');
|
|
121
|
+
if (kind === 'none' || e.points.length < 2) return '';
|
|
122
|
+
const b = start ? e.points[0] : e.points.at(-1)!,
|
|
123
|
+
a = start ? e.points[1] : e.points.at(-2)!;
|
|
124
|
+
const angle = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
|
125
|
+
const path =
|
|
126
|
+
kind === 'diamond'
|
|
127
|
+
? 'M0 0 L-6 -4 L-12 0 L-6 4Z'
|
|
128
|
+
: kind === 'filled'
|
|
129
|
+
? 'M0 0 L-8 -4 L-8 4Z'
|
|
130
|
+
: 'M-7 -3.5 L0 0 L-7 3.5';
|
|
131
|
+
return `<g transform="translate(${b.x} ${b.y}) rotate(${angle})" stroke="${ink}" stroke-width="${width}" fill="${kind === 'open' ? 'none' : ink}">${kind === 'circle' ? '<circle cx="-4" cy="0" r="4"/>' : `<path d="${path}"/>`}</g>`;
|
|
132
|
+
};
|
|
133
|
+
return `<g opacity="${s.opacity ?? 1}"><path d="${polylinePath(e.points)}" fill="none" stroke="${ink}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="${dashArray(s.dash ?? e.semantic.style)}"/>${arrow(true)}${arrow(false)}</g>`;
|
|
134
|
+
}
|
|
135
|
+
export function edgeLabelMarkup(e: SceneEdge, themeName: 'paper' | 'midnight'): string {
|
|
136
|
+
if (!e.label) return '';
|
|
137
|
+
const theme = themes[themeName],
|
|
138
|
+
b = e.label;
|
|
139
|
+
return `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="4" fill="${e.style?.fill ?? theme.background}" fill-opacity=".96"/>${b.lines.map((l, i) => t(b.x + b.width / 2, b.y + 15 + i * 15, l, 11, e.style?.text ?? theme.secondary, 400, 'text-anchor="middle"')).join('')}`;
|
|
140
|
+
}
|
|
141
|
+
export function frameMarkup(scene: Scene): string {
|
|
142
|
+
const { bounds: b, document: doc } = scene,
|
|
143
|
+
theme = { ...themes[doc.presentation.theme], ...doc.presentation.designSystem?.canvas };
|
|
144
|
+
const x = b.x + 56,
|
|
145
|
+
titleY = b.y + 54;
|
|
146
|
+
const heading = `${scene.titleLines.map((l, i) => t(x, titleY + i * 38, l, 30, theme.text, 600, 'letter-spacing="-.7"')).join('')}
|
|
147
|
+
${scene.descriptionLines.map((l, i) => t(x, titleY + scene.titleLines.length * 38 - 7 + i * 19, l, 13, theme.secondary)).join('')}`;
|
|
148
|
+
const footer = doc.presentation.footer;
|
|
149
|
+
if (footer?.hidden) return heading;
|
|
150
|
+
const label = footer?.label ?? `FORMA / ${doc.type.toUpperCase()}`;
|
|
151
|
+
const detail =
|
|
152
|
+
footer?.detail ?? `${scene.nodes.length} components · ${scene.edges.length} relationships`;
|
|
153
|
+
return `${heading}
|
|
154
|
+
<line x1="${x}" y1="${b.y + b.height - 44}" x2="${b.x + b.width - 56}" y2="${b.y + b.height - 44}" stroke="${theme.border}"/>
|
|
155
|
+
${label ? t(x, b.y + b.height - 22, label, 9, theme.secondary, 500, 'letter-spacing="1.6"') : ''}
|
|
156
|
+
${detail ? t(b.x + b.width - 56, b.y + b.height - 22, detail, 10, theme.secondary, 400, 'text-anchor="end"') : ''}`;
|
|
157
|
+
}
|
|
158
|
+
export interface SvgOptions {
|
|
159
|
+
fontDataUri?: string;
|
|
160
|
+
boldFontDataUri?: string;
|
|
161
|
+
}
|
|
162
|
+
export function renderSvg(scene: Scene, options: SvgOptions = {}): string {
|
|
163
|
+
const b = scene.bounds,
|
|
164
|
+
theme = scene.document.presentation.theme;
|
|
165
|
+
const font = options.fontDataUri
|
|
166
|
+
? `<style>@font-face{font-family:'IBM Plex Sans';src:url('${options.fontDataUri}') format('truetype');font-weight:400;} ${options.boldFontDataUri ? `@font-face{font-family:'IBM Plex Sans';src:url('${options.boldFontDataUri}') format('truetype');font-weight:600;}` : ''}</style>`
|
|
167
|
+
: '';
|
|
168
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(b.width)}" height="${Math.ceil(b.height)}" viewBox="${b.x} ${b.y} ${b.width} ${b.height}" role="img" aria-labelledby="diagram-title diagram-description"><title id="diagram-title">${escapeXml(scene.document.title)}</title><desc id="diagram-description">${escapeXml(scene.document.description ?? '')}</desc>${font}<g font-family="${escapeXml(scene.document.presentation.designSystem?.canvas?.fontFamily ?? 'IBM Plex Sans, Arial, sans-serif')}"><rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="${scene.document.presentation.designSystem?.canvas?.background ?? themes[theme].background}"/>${frameMarkup(scene)}${scene.groups.map((g) => groupMarkup(g, theme)).join('')}${scene.edges.map((e) => edgeMarkup(e, theme)).join('')}${scene.nodes.map((n) => `<g transform="translate(${n.x} ${n.y})">${nodeMarkup(n, theme)}</g>`).join('')}${scene.edges.map((e) => edgeLabelMarkup(e, theme)).join('')}</g></svg>`;
|
|
169
|
+
}
|
|
170
|
+
/** Adapters can emit native editable shapes from Scene, without parsing SVG. */
|
|
171
|
+
export interface Exporter<T> {
|
|
172
|
+
id: string;
|
|
173
|
+
mimeType: string;
|
|
174
|
+
extension: string;
|
|
175
|
+
export(scene: Scene): Promise<T> | T;
|
|
176
|
+
}
|
|
177
|
+
export const svgExporter: Exporter<string> = {
|
|
178
|
+
id: 'svg',
|
|
179
|
+
mimeType: 'image/svg+xml',
|
|
180
|
+
extension: '.svg',
|
|
181
|
+
export: renderSvg,
|
|
182
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Diagram, DiagramNode, DiagramEdge, DiagramGroup } from './document';
|
|
2
|
+
import type { ElementStyle } from './styles';
|
|
3
|
+
|
|
4
|
+
/** Shared by layout and editing controls; renderer consumes the resolved scene style. */
|
|
5
|
+
export function resolveNodeStyle(doc: Diagram, node: DiagramNode): ElementStyle {
|
|
6
|
+
const system = doc.presentation.designSystem;
|
|
7
|
+
const role =
|
|
8
|
+
node.role ??
|
|
9
|
+
(node.emphasis === 'primary' ? 'emphasis' : node.emphasis === 'muted' ? 'quiet' : '');
|
|
10
|
+
return {
|
|
11
|
+
...system?.node,
|
|
12
|
+
...system?.roles?.[role],
|
|
13
|
+
...node.style,
|
|
14
|
+
...doc.presentation.nodes[node.id]?.style,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function resolveEdgeStyle(doc: Diagram, edge: DiagramEdge): ElementStyle {
|
|
18
|
+
const system = doc.presentation.designSystem;
|
|
19
|
+
return {
|
|
20
|
+
...(system?.canvas ? { fill: system.canvas.background, text: system.canvas.secondary } : {}),
|
|
21
|
+
...system?.edge,
|
|
22
|
+
...system?.roles?.[edge.role ?? ''],
|
|
23
|
+
...edge.appearance,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function resolveGroupStyle(doc: Diagram, group: DiagramGroup): ElementStyle {
|
|
27
|
+
const system = doc.presentation.designSystem;
|
|
28
|
+
return { ...system?.group, ...system?.roles?.[group.role ?? ''], ...group.style };
|
|
29
|
+
}
|