@dxos/diagram 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +10 -0
  3. package/dist/lib/index.mjs +3975 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +68 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/content.d.ts +60 -0
  8. package/dist/types/src/content.d.ts.map +1 -0
  9. package/dist/types/src/diagnostics.d.ts +56 -0
  10. package/dist/types/src/diagnostics.d.ts.map +1 -0
  11. package/dist/types/src/diagnostics.test.d.ts +2 -0
  12. package/dist/types/src/diagnostics.test.d.ts.map +1 -0
  13. package/dist/types/src/dialect.d.ts +19 -0
  14. package/dist/types/src/dialect.d.ts.map +1 -0
  15. package/dist/types/src/index.d.ts +16 -0
  16. package/dist/types/src/index.d.ts.map +1 -0
  17. package/dist/types/src/layout.d.ts +26 -0
  18. package/dist/types/src/layout.d.ts.map +1 -0
  19. package/dist/types/src/mermaid-engine.d.ts +83 -0
  20. package/dist/types/src/mermaid-engine.d.ts.map +1 -0
  21. package/dist/types/src/mermaid-engine.test.d.ts +2 -0
  22. package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
  23. package/dist/types/src/mermaid.d.ts +56 -0
  24. package/dist/types/src/mermaid.d.ts.map +1 -0
  25. package/dist/types/src/mermaid.test.d.ts +3 -0
  26. package/dist/types/src/mermaid.test.d.ts.map +1 -0
  27. package/dist/types/src/objective.d.ts +75 -0
  28. package/dist/types/src/objective.d.ts.map +1 -0
  29. package/dist/types/src/objective.test.d.ts +2 -0
  30. package/dist/types/src/objective.test.d.ts.map +1 -0
  31. package/dist/types/src/ortho-router.d.ts +13 -0
  32. package/dist/types/src/ortho-router.d.ts.map +1 -0
  33. package/dist/types/src/scene.d.ts +1006 -0
  34. package/dist/types/src/scene.d.ts.map +1 -0
  35. package/dist/types/src/scene.test.d.ts +2 -0
  36. package/dist/types/src/scene.test.d.ts.map +1 -0
  37. package/dist/types/src/svg-handler.d.ts +5 -0
  38. package/dist/types/src/svg-handler.d.ts.map +1 -0
  39. package/dist/types/src/svg-handler.test.d.ts +2 -0
  40. package/dist/types/src/svg-handler.test.d.ts.map +1 -0
  41. package/dist/types/src/testing.d.ts +11 -0
  42. package/dist/types/src/testing.d.ts.map +1 -0
  43. package/dist/types/src/ui.d.ts +67 -0
  44. package/dist/types/src/ui.d.ts.map +1 -0
  45. package/dist/types/src/ui.test.d.ts +2 -0
  46. package/dist/types/src/ui.test.d.ts.map +1 -0
  47. package/dist/types/src/uml-engine.d.ts +14 -0
  48. package/dist/types/src/uml-engine.d.ts.map +1 -0
  49. package/dist/types/src/uml-engine.test.d.ts +2 -0
  50. package/dist/types/src/uml-engine.test.d.ts.map +1 -0
  51. package/dist/types/src/uml-grid.d.ts +99 -0
  52. package/dist/types/src/uml-grid.d.ts.map +1 -0
  53. package/dist/types/src/uml-grid.test.d.ts +2 -0
  54. package/dist/types/src/uml-grid.test.d.ts.map +1 -0
  55. package/dist/types/src/uml-rules.d.ts +56 -0
  56. package/dist/types/src/uml-rules.d.ts.map +1 -0
  57. package/dist/types/src/uml-rules.test.d.ts +2 -0
  58. package/dist/types/src/uml-rules.test.d.ts.map +1 -0
  59. package/dist/types/src/uml-search.d.ts +26 -0
  60. package/dist/types/src/uml-search.d.ts.map +1 -0
  61. package/dist/types/src/uml-search.test.d.ts +2 -0
  62. package/dist/types/src/uml-search.test.d.ts.map +1 -0
  63. package/dist/types/src/uml.d.ts +60 -0
  64. package/dist/types/src/uml.d.ts.map +1 -0
  65. package/dist/types/src/uml.test.d.ts +2 -0
  66. package/dist/types/src/uml.test.d.ts.map +1 -0
  67. package/dist/types/tsconfig.tsbuildinfo +1 -0
  68. package/package.json +48 -0
  69. package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
  70. package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
  71. package/src/content.ts +180 -0
  72. package/src/diagnostics.test.ts +173 -0
  73. package/src/diagnostics.ts +393 -0
  74. package/src/dialect.ts +53 -0
  75. package/src/index.ts +19 -0
  76. package/src/layout.ts +99 -0
  77. package/src/mermaid-engine.test.ts +244 -0
  78. package/src/mermaid-engine.ts +863 -0
  79. package/src/mermaid.test.ts +138 -0
  80. package/src/mermaid.ts +326 -0
  81. package/src/objective.test.ts +82 -0
  82. package/src/objective.ts +157 -0
  83. package/src/ortho-router.ts +372 -0
  84. package/src/scene.test.ts +36 -0
  85. package/src/scene.ts +281 -0
  86. package/src/svg-handler.test.ts +75 -0
  87. package/src/svg-handler.ts +119 -0
  88. package/src/testing.ts +68 -0
  89. package/src/ui.test.ts +183 -0
  90. package/src/ui.ts +398 -0
  91. package/src/uml-engine.test.ts +63 -0
  92. package/src/uml-engine.ts +146 -0
  93. package/src/uml-grid.test.ts +175 -0
  94. package/src/uml-grid.ts +559 -0
  95. package/src/uml-rules.test.ts +109 -0
  96. package/src/uml-rules.ts +319 -0
  97. package/src/uml-search.test.ts +108 -0
  98. package/src/uml-search.ts +266 -0
  99. package/src/uml.test.ts +172 -0
  100. package/src/uml.ts +390 -0
package/src/scene.ts ADDED
@@ -0,0 +1,281 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Backend-neutral scene DSL: a diagram is a set of named world objects ("face", "hat"),
7
+ // each composed of graphical elements (rect, ellipse, line, curve, text, arrow, portal)
8
+ // authored in object-local units. A backend's `ContentHandler` (tldraw, excalidraw, the SVG
9
+ // handler here) maps the scene onto its concrete canvas records; see `content.ts`.
10
+ //
11
+
12
+ import * as Schema from 'effect/Schema';
13
+
14
+ export const Color = Schema.Literals([
15
+ 'black',
16
+ 'grey',
17
+ 'light-violet',
18
+ 'violet',
19
+ 'blue',
20
+ 'light-blue',
21
+ 'yellow',
22
+ 'orange',
23
+ 'green',
24
+ 'light-green',
25
+ 'light-red',
26
+ 'red',
27
+ 'white',
28
+ ]);
29
+ export type Color = Schema.Schema.Type<typeof Color>;
30
+
31
+ export const Fill = Schema.Literals(['none', 'solid', 'pattern']);
32
+ export type Fill = Schema.Schema.Type<typeof Fill>;
33
+
34
+ export const Stroke = Schema.Literals(['sketchy', 'solid', 'dashed', 'dotted']);
35
+ export type Stroke = Schema.Schema.Type<typeof Stroke>;
36
+
37
+ export const Weight = Schema.Literals(['s', 'm', 'l', 'xl']);
38
+ export type Weight = Schema.Schema.Type<typeof Weight>;
39
+
40
+ export const Point = Schema.Struct({
41
+ x: Schema.Number,
42
+ y: Schema.Number,
43
+ });
44
+ export type Point = Schema.Schema.Type<typeof Point>;
45
+
46
+ /** Style props shared by all elements; every field is optional with sensible defaults. */
47
+ const styleFields = {
48
+ color: Schema.optional(Color).annotate({ description: 'Stroke/text color.' }),
49
+ fill: Schema.optional(Fill).annotate({ description: 'Interior fill of closed shapes.' }),
50
+ stroke: Schema.optional(Stroke).annotate({ description: 'Stroke rendering; sketchy is hand-drawn.' }),
51
+ weight: Schema.optional(Weight).annotate({ description: 'Stroke weight / text size.' }),
52
+ };
53
+
54
+ const id = Schema.String.annotate({
55
+ description: 'Stable element id, unique within the object (e.g. "left-eye"). Used to edit or delete the element.',
56
+ });
57
+
58
+ /** Closed shape drawn inside a local bounding box. */
59
+ export const Box = Schema.Struct({
60
+ kind: Schema.Literals(['rect', 'ellipse', 'diamond', 'triangle']),
61
+ id,
62
+ x: Schema.Number.annotate({ description: 'Left edge (object-local units).' }),
63
+ y: Schema.Number.annotate({ description: 'Top edge (object-local units).' }),
64
+ w: Schema.Number,
65
+ h: Schema.Number,
66
+ rotation: Schema.optional(Schema.Number).annotate({ description: 'Clockwise rotation in degrees.' }),
67
+ text: Schema.optional(Schema.String).annotate({ description: 'Centered label.' }),
68
+ corners: Schema.optional(Schema.Literals(['top', 'bottom', 'none'])).annotate({
69
+ description: 'Rects only: round just these corners (default all); backends without corner control ignore it.',
70
+ }),
71
+ ...styleFields,
72
+ });
73
+ export type Box = Schema.Schema.Type<typeof Box>;
74
+
75
+ /** Circle sugar; reads back as an ellipse. */
76
+ export const Circle = Schema.Struct({
77
+ kind: Schema.Literal('circle'),
78
+ id,
79
+ cx: Schema.Number.annotate({ description: 'Center x (object-local units).' }),
80
+ cy: Schema.Number.annotate({ description: 'Center y (object-local units).' }),
81
+ r: Schema.Number,
82
+ text: Schema.optional(Schema.String),
83
+ ...styleFields,
84
+ });
85
+ export type Circle = Schema.Schema.Type<typeof Circle>;
86
+
87
+ /** Straight segment or open polyline through the given points. */
88
+ export const Polyline = Schema.Struct({
89
+ kind: Schema.Literal('line'),
90
+ id,
91
+ points: Schema.Array(Point).annotate({ description: 'Two or more points (object-local units).' }),
92
+ closed: Schema.optional(Schema.Boolean).annotate({ description: 'Close the path back to the first point.' }),
93
+ ...styleFields,
94
+ });
95
+ export type Polyline = Schema.Schema.Type<typeof Polyline>;
96
+
97
+ /** Smooth curve through the given points (spline). */
98
+ export const Curve = Schema.Struct({
99
+ kind: Schema.Literal('curve'),
100
+ id,
101
+ points: Schema.Array(Point).annotate({ description: 'Control points the curve passes through.' }),
102
+ ...styleFields,
103
+ });
104
+ export type Curve = Schema.Schema.Type<typeof Curve>;
105
+
106
+ /**
107
+ * Circular arc sugar; reads back as a curve. Angles are degrees, measured clockwise from
108
+ * the positive x-axis in screen coordinates (y grows downward): 0–180 is the lower half
109
+ * (a smile), 180–360 the upper half (a frown).
110
+ */
111
+ export const Arc = Schema.Struct({
112
+ kind: Schema.Literal('arc'),
113
+ id,
114
+ cx: Schema.Number,
115
+ cy: Schema.Number,
116
+ r: Schema.Number,
117
+ startAngle: Schema.Number,
118
+ endAngle: Schema.Number,
119
+ ...styleFields,
120
+ });
121
+ export type Arc = Schema.Schema.Type<typeof Arc>;
122
+
123
+ /** Free-standing text. */
124
+ export const Text = Schema.Struct({
125
+ kind: Schema.Literal('text'),
126
+ id,
127
+ x: Schema.Number,
128
+ y: Schema.Number,
129
+ w: Schema.optional(Schema.Number).annotate({ description: 'Wrap width; omit for auto-size.' }),
130
+ text: Schema.String,
131
+ ...styleFields,
132
+ });
133
+ export type Text = Schema.Schema.Type<typeof Text>;
134
+
135
+ /** UML-style end markers: the head sits at the target, the tail at the source. */
136
+ export const ArrowHead = Schema.Literals(['arrow', 'triangle', 'crowsfoot', 'none']);
137
+ export type ArrowHead = Schema.Schema.Type<typeof ArrowHead>;
138
+ export const ArrowTail = Schema.Literals(['none', 'circle']);
139
+ export type ArrowTail = Schema.Schema.Type<typeof ArrowTail>;
140
+
141
+ /**
142
+ * Connector. Endpoints are element refs — `"<elementId>"` within the same object or
143
+ * `"<objectId>/<elementId>"` across objects, either with an optional `#<port>` naming an
144
+ * attachment point on the element — or explicit local points. Bound endpoints track their
145
+ * target when it moves; without a port the renderer picks the attachment point.
146
+ */
147
+ export const Arrow = Schema.Struct({
148
+ kind: Schema.Literal('arrow'),
149
+ id,
150
+ from: Schema.optional(Schema.String).annotate({ description: 'Source element ref, optionally `#port`.' }),
151
+ to: Schema.optional(Schema.String).annotate({ description: 'Target element ref, optionally `#port`.' }),
152
+ start: Schema.optional(Point).annotate({ description: 'Explicit start (used when `from` is omitted).' }),
153
+ end: Schema.optional(Point).annotate({ description: 'Explicit end (used when `to` is omitted).' }),
154
+ text: Schema.optional(Schema.String).annotate({ description: 'Label at the arrow midpoint.' }),
155
+ head: Schema.optional(ArrowHead).annotate({
156
+ description: 'Marker at the target end (default arrow): triangle for inheritance, crowsfoot for has-many.',
157
+ }),
158
+ tail: Schema.optional(ArrowTail).annotate({
159
+ description: 'Marker at the source end (default none): circle for containment.',
160
+ }),
161
+ ...styleFields,
162
+ });
163
+ export type Arrow = Schema.Schema.Type<typeof Arrow>;
164
+
165
+ /** A parsed endpoint ref; `object` is absent for a ref within the same object. */
166
+ export type ElementRef = {
167
+ object?: string;
168
+ element: string;
169
+ port?: string;
170
+ };
171
+
172
+ /** Parse `"[object/]element[#port]"`; element ids may not contain `/` or `#`. */
173
+ export const parseRef = (ref: string): ElementRef => {
174
+ const hash = ref.indexOf('#');
175
+ const target = hash === -1 ? ref : ref.slice(0, hash);
176
+ const port = hash === -1 ? undefined : ref.slice(hash + 1);
177
+ const slash = target.indexOf('/');
178
+ return {
179
+ ...(slash === -1 ? {} : { object: target.slice(0, slash) }),
180
+ element: slash === -1 ? target : target.slice(slash + 1),
181
+ ...(port ? { port } : {}),
182
+ };
183
+ };
184
+
185
+ /** Inverse of {@link parseRef}. */
186
+ export const formatRef = ({ object, element, port }: ElementRef): string =>
187
+ `${object === undefined ? '' : `${object}/`}${element}${port === undefined ? '' : `#${port}`}`;
188
+
189
+ /**
190
+ * Canonical `object/element` handle of a ref, resolving a bare element id against the owning
191
+ * object and dropping the port, so renderers without ports bind to the element itself.
192
+ */
193
+ export const resolveRef = (ref: string, objectId: string): string => {
194
+ const { object = objectId, element } = parseRef(ref);
195
+ return `${object}/${element}`;
196
+ };
197
+
198
+ /**
199
+ * Window onto another drawing: the referenced scene is rendered inside the box as a nested level
200
+ * of detail. Renderers without nesting ignore it.
201
+ */
202
+ export const Portal = Schema.Struct({
203
+ kind: Schema.Literal('portal'),
204
+ id,
205
+ x: Schema.Number.annotate({ description: 'Left edge (object-local units).' }),
206
+ y: Schema.Number.annotate({ description: 'Top edge (object-local units).' }),
207
+ w: Schema.Number,
208
+ h: Schema.Number,
209
+ ref: Schema.String.annotate({ description: 'The drawing shown inside the box: an ECHO object reference (DXN).' }),
210
+ text: Schema.optional(Schema.String).annotate({ description: 'Title shown on the frame.' }),
211
+ ...styleFields,
212
+ });
213
+ export type Portal = Schema.Schema.Type<typeof Portal>;
214
+
215
+ export const Element = Schema.Union([Box, Circle, Polyline, Curve, Arc, Text, Arrow, Portal]);
216
+ export type Element = Schema.Schema.Type<typeof Element>;
217
+
218
+ /**
219
+ * A semantic group of elements ("face", "hat") authored in its own local unit space.
220
+ * `origin` (canvas px) and `scale` (px per unit) place it on the canvas:
221
+ * `canvas = origin + local * scale`. On read, `origin` is derived as the top-left of the
222
+ * object's bounding box, so the model survives users dragging shapes around.
223
+ */
224
+ export const WorldObject = Schema.Struct({
225
+ id: Schema.String.annotate({
226
+ description: 'Stable object id naming the thing depicted (e.g. "face", "hat").',
227
+ }),
228
+ origin: Schema.optional(Point).annotate({
229
+ description: 'Canvas position (px). Omit on upsert to keep the current position.',
230
+ }),
231
+ scale: Schema.optional(Schema.Number).annotate({ description: 'Canvas px per local unit (default 1).' }),
232
+ index: Schema.optional(Schema.String).annotate({
233
+ description: 'Fractional z-order index (e.g. "a1"); objects with one paint above those without, in index order.',
234
+ }),
235
+ ref: Schema.optional(Schema.String).annotate({
236
+ description:
237
+ 'What the object depicts: an ECHO object reference (activating the node opens it) or any other URI or path, carried for tooling.',
238
+ }),
239
+ elements: Schema.Array(Element),
240
+ });
241
+ export type WorldObject = Schema.Schema.Type<typeof WorldObject>;
242
+
243
+ export const Scene = Schema.Struct({
244
+ objects: Schema.Array(WorldObject),
245
+ });
246
+ export type Scene = Schema.Schema.Type<typeof Scene>;
247
+
248
+ //
249
+ // Edit commands.
250
+ //
251
+
252
+ export const UpsertObject = Schema.Struct({
253
+ op: Schema.Literal('upsert-object'),
254
+ object: WorldObject,
255
+ }).annotate({ description: 'Create an object, or replace an existing one wholesale (same id).' });
256
+
257
+ export const UpsertElements = Schema.Struct({
258
+ op: Schema.Literal('upsert-elements'),
259
+ objectId: Schema.String,
260
+ elements: Schema.Array(Element),
261
+ }).annotate({ description: 'Add elements to an existing object, replacing any with matching ids.' });
262
+
263
+ export const RemoveElements = Schema.Struct({
264
+ op: Schema.Literal('remove-elements'),
265
+ objectId: Schema.String,
266
+ elementIds: Schema.Array(Schema.String),
267
+ });
268
+
269
+ export const RemoveObject = Schema.Struct({
270
+ op: Schema.Literal('remove-object'),
271
+ objectId: Schema.String,
272
+ });
273
+
274
+ export const MoveObject = Schema.Struct({
275
+ op: Schema.Literal('move-object'),
276
+ objectId: Schema.String,
277
+ origin: Point.annotate({ description: 'New canvas position (px) for the object top-left.' }),
278
+ });
279
+
280
+ export const Command = Schema.Union([UpsertObject, UpsertElements, RemoveElements, RemoveObject, MoveObject]);
281
+ export type Command = Schema.Schema.Type<typeof Command>;
@@ -0,0 +1,75 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { type ContentMap, applyCommands } from './content.ts';
8
+ import { SvgHandler } from './svg-handler.ts';
9
+
10
+ describe('svg-handler', () => {
11
+ test('round-trips the object ref, including through upsert-elements', ({ expect }) => {
12
+ const content: ContentMap = {};
13
+ applyCommands(
14
+ content,
15
+ [
16
+ {
17
+ op: 'upsert-object',
18
+ object: {
19
+ id: 'Echo',
20
+ origin: { x: 0, y: 0 },
21
+ ref: 'packages/core/echo',
22
+ elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: 64, h: 32 }],
23
+ },
24
+ },
25
+ // Elements alone carry no ref; the object's existing records supply it.
26
+ {
27
+ op: 'upsert-elements',
28
+ objectId: 'Echo',
29
+ elements: [{ kind: 'text', id: 'note', x: 0, y: 40, text: 'db' }],
30
+ },
31
+ ],
32
+ SvgHandler,
33
+ );
34
+
35
+ const { scene } = SvgHandler.read(content);
36
+ const echo = scene.objects.find(({ id }) => id === 'Echo');
37
+ expect(echo?.ref).toBe('packages/core/echo');
38
+ expect(echo?.elements.map(({ id }) => id)).toEqual(['box', 'note']);
39
+ });
40
+
41
+ test('round-trips the fractional index and reads objects in index order', ({ expect }) => {
42
+ const content: ContentMap = {};
43
+ const rect = { kind: 'rect', id: 'box', x: 0, y: 0, w: 64, h: 32 } as const;
44
+ applyCommands(
45
+ content,
46
+ [
47
+ { op: 'upsert-object', object: { id: 'top', origin: { x: 0, y: 0 }, index: 'a2', elements: [rect] } },
48
+ { op: 'upsert-object', object: { id: 'plain', origin: { x: 0, y: 0 }, elements: [rect] } },
49
+ { op: 'upsert-object', object: { id: 'bottom', origin: { x: 0, y: 0 }, index: 'a1', elements: [rect] } },
50
+ // Elements alone carry no index; the object's existing records supply it.
51
+ { op: 'upsert-elements', objectId: 'top', elements: [{ kind: 'text', id: 'note', x: 0, y: 40, text: 'z' }] },
52
+ ],
53
+ SvgHandler,
54
+ );
55
+
56
+ const { scene } = SvgHandler.read(content);
57
+ expect(scene.objects.map(({ id, index }) => [id, index])).toEqual([
58
+ ['plain', undefined],
59
+ ['bottom', 'a1'],
60
+ ['top', 'a2'],
61
+ ]);
62
+ });
63
+
64
+ test('stores portals and arrow ports verbatim', ({ expect }) => {
65
+ const content: ContentMap = {};
66
+ const elements = [
67
+ { kind: 'portal', id: 'child', x: 0, y: 0, w: 128, h: 96, ref: 'dxn:echo:@:abc' },
68
+ { kind: 'arrow', id: 'link', from: 'child#east', to: 'other/box#west' },
69
+ ] as const;
70
+ applyCommands(content, [{ op: 'upsert-object', object: { id: 'root', elements } }], SvgHandler);
71
+
72
+ const { scene } = SvgHandler.read(content);
73
+ expect(scene.objects[0]?.elements).toEqual(elements);
74
+ });
75
+ });
@@ -0,0 +1,119 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // SVG variant content handler: the scene DSL is the persistence format. Each element is stored
7
+ // verbatim as one record carrying its DSL identity and its object's placement, so
8
+ // `applyCommands` provides upsert/remove/move and `read` reconstructs the scene losslessly —
9
+ // there is no renderer-native encoding to compile to. The plugin binds it to ECHO as `SvgBuilder`.
10
+ //
11
+
12
+ import { type ContentHandler, type ContentMap, type ReadWorldObject } from './content.ts';
13
+ import type * as Scene from './scene.ts';
14
+
15
+ /** Discriminates the base `Drawing.Canvas` as SVG-rendered scene content. */
16
+ export const SVG_SCHEMA = 'dxos.org/svg/1';
17
+
18
+ type SvgRecord = {
19
+ object: string;
20
+ element: string;
21
+ /** Insertion order, so `read` reproduces element paint order. */
22
+ order: number;
23
+ placement: { origin: Scene.Point; scale: number };
24
+ /** The object's `ref`, repeated on each of its records so `read` restores it. */
25
+ ref?: string;
26
+ /** The object's fractional z-order `index`, repeated like `ref`. */
27
+ index?: string;
28
+ data: Scene.Element;
29
+ };
30
+
31
+ const isSvgRecord = (record: any): record is SvgRecord =>
32
+ typeof record?.object === 'string' && typeof record?.element === 'string' && record?.data !== undefined;
33
+
34
+ /** Objects without an index paint first, in insertion order; indexed objects follow in index order. */
35
+ const byIndex = (left: ReadWorldObject, right: ReadWorldObject): number =>
36
+ left.index === undefined
37
+ ? right.index === undefined
38
+ ? 0
39
+ : -1
40
+ : right.index === undefined
41
+ ? 1
42
+ : left.index < right.index
43
+ ? -1
44
+ : left.index > right.index
45
+ ? 1
46
+ : 0;
47
+
48
+ export const SvgHandler: ContentHandler = {
49
+ identify: (record) => (isSvgRecord(record) ? { object: record.object, element: record.element } : undefined),
50
+
51
+ render: (object, placement, content) => {
52
+ // Past the max, not the count: deletions leave gaps, and a reused order makes paint order
53
+ // ambiguous on read.
54
+ let order =
55
+ Math.max(
56
+ -1,
57
+ ...Object.values(content)
58
+ .filter(isSvgRecord)
59
+ .map((record) => record.order),
60
+ ) + 1;
61
+ // `upsert-elements` supplies only elements; the object's ref and index then come from its
62
+ // existing records.
63
+ const existing = Object.values(content).find((record) => isSvgRecord(record) && record.object === object.id);
64
+ const ref = ('ref' in object && object.ref) || existing?.ref;
65
+ const index = ('index' in object && object.index) || existing?.index;
66
+ const records: ContentMap = {};
67
+ for (const element of object.elements) {
68
+ records[`${object.id}/${element.id}`] = {
69
+ object: object.id,
70
+ element: element.id,
71
+ order: order++,
72
+ placement: { origin: { ...placement.origin }, scale: placement.scale },
73
+ ...(ref ? { ref } : {}),
74
+ ...(index ? { index } : {}),
75
+ // Structured clone: scene elements are plain data, and the record must not alias the input.
76
+ data: JSON.parse(JSON.stringify(element)),
77
+ } satisfies SvgRecord;
78
+ }
79
+ return records;
80
+ },
81
+
82
+ read: (content) => {
83
+ const records = Object.values(content)
84
+ .filter(isSvgRecord)
85
+ .sort((left, right) => left.order - right.order);
86
+ const objects = new Map<string, ReadWorldObject>();
87
+ let unmanaged = 0;
88
+ for (const record of Object.values(content)) {
89
+ if (!isSvgRecord(record)) {
90
+ unmanaged++;
91
+ }
92
+ }
93
+ for (const record of records) {
94
+ let object = objects.get(record.object);
95
+ if (!object) {
96
+ object = {
97
+ id: record.object,
98
+ origin: { ...record.placement.origin },
99
+ scale: record.placement.scale,
100
+ ...(record.ref ? { ref: record.ref } : {}),
101
+ ...(record.index ? { index: record.index } : {}),
102
+ elements: [],
103
+ };
104
+ objects.set(record.object, object);
105
+ }
106
+ (object.elements as Scene.Element[]).push(record.data);
107
+ }
108
+ return { scene: { objects: [...objects.values()].sort(byIndex) }, unmanaged };
109
+ },
110
+
111
+ translate: (record, delta) => {
112
+ if (isSvgRecord(record)) {
113
+ record.placement.origin = {
114
+ x: record.placement.origin.x + delta.x,
115
+ y: record.placement.origin.y + delta.y,
116
+ };
117
+ }
118
+ },
119
+ };
package/src/testing.ts ADDED
@@ -0,0 +1,68 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Minimal layout discussion piece: three packages with three types each — an inheritance fan-in
7
+ * (B, C extend A), a chain with a has-many (X ⇒ Y → Z), and a fan-in (P, R → Q), plus two
8
+ * cross-package references (Y → A, Q → X) so routes must leave and enter frames. Small enough to
9
+ * reason about every placement and route by eye. Relationship kinds are carried by the edge
10
+ * tokens (UML end markers), not labels.
11
+ */
12
+ export const BASIC = `
13
+ flowchart TB
14
+ subgraph pkgA [Package A]
15
+ A[A]
16
+ B[B]
17
+ C[C]
18
+ end
19
+ subgraph pkgX [Package X]
20
+ X[X]
21
+ Y[Y]
22
+ Z[Z]
23
+ end
24
+ subgraph pkgP [Package P]
25
+ P[P]
26
+ Q[Q]
27
+ R[R]
28
+ end
29
+ B --|> A
30
+ C --|> A
31
+ X --{ Y
32
+ Y --> Z
33
+ P --> Q
34
+ R --> Q
35
+ Y --> A
36
+ Q --> X
37
+ `;
38
+
39
+ /** Exercises class blocks, a stereotype, generics, cardinalities, and every relation kind. */
40
+ export const CLASS_DIAGRAM = `
41
+ classDiagram
42
+ direction TB
43
+
44
+ class Animal {
45
+ <<abstract>>
46
+ +name: string
47
+ +move() void
48
+ }
49
+
50
+ class Dog {
51
+ +breed: string
52
+ +bark() void
53
+ }
54
+
55
+ class Serializable {
56
+ <<interface>>
57
+ +serialize() string
58
+ }
59
+
60
+ class Leg
61
+ class Owner
62
+
63
+ Animal <|-- Dog
64
+ Serializable <|.. Dog
65
+ Dog *-- Leg
66
+ Owner "1" o-- "*" Dog : owns
67
+ Dog ..> Bone : chews
68
+ `;