orcasvn-react-diagrams 0.2.2 → 0.2.4

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 (83) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +15 -3
  3. package/dist/cjs/examples.js +2616 -291
  4. package/dist/cjs/index.js +1186 -153
  5. package/dist/cjs/types/api/createDiagramEditor.d.ts +19 -1
  6. package/dist/cjs/types/api/index.d.ts +1 -1
  7. package/dist/cjs/types/api/types.d.ts +41 -0
  8. package/dist/cjs/types/displaybox/DisplayBoxControls.d.ts +5 -1
  9. package/dist/cjs/types/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.d.ts +3 -0
  10. package/dist/cjs/types/displaybox/demos/LayoutLabelReservedSpaceDemoTab.d.ts +3 -0
  11. package/dist/cjs/types/displaybox/demos/VertexControlLinkSessionDemoTab.d.ts +3 -0
  12. package/dist/cjs/types/displaybox/demos/asymmetricPortMultiAnchorDemo.d.ts +31 -0
  13. package/dist/cjs/types/displaybox/demos/labelStyleDemo.d.ts +2 -0
  14. package/dist/cjs/types/displaybox/demos/layoutLabelReservedSpaceDemo.d.ts +11 -0
  15. package/dist/cjs/types/displaybox/demos/portPositionLimitsDemo.d.ts +2 -0
  16. package/dist/cjs/types/displaybox/demos/vertexControlLinkSessionDemo.d.ts +12 -0
  17. package/dist/cjs/types/displaybox/useDemoControls.d.ts +4 -0
  18. package/dist/cjs/types/engine/AutoLayoutService.d.ts +2 -0
  19. package/dist/cjs/types/engine/DiagramEngine.d.ts +11 -0
  20. package/dist/cjs/types/engine/LinkRoutingService.d.ts +9 -1
  21. package/dist/cjs/types/models/PortModel.d.ts +5 -0
  22. package/dist/cjs/types/renderer/RenderTypes.d.ts +3 -1
  23. package/dist/cjs/types/renderer/konva/KonvaInteraction.d.ts +14 -0
  24. package/dist/cjs/types/renderer/konva/KonvaNodeFactory.d.ts +12 -0
  25. package/dist/cjs/types/renderer/konva/KonvaRenderer.d.ts +2 -1
  26. package/dist/cjs/types/shapes/BuiltInShapes.d.ts +3 -1
  27. package/dist/cjs/types/strategies/ObstacleRouter.d.ts +2 -0
  28. package/dist/cjs/types/utils/__tests__/portGeometry.test.d.ts +1 -0
  29. package/dist/cjs/types/utils/portGeometry.d.ts +44 -0
  30. package/dist/esm/examples.js +2617 -292
  31. package/dist/esm/examples.js.map +1 -1
  32. package/dist/esm/index.js +1186 -153
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/types/api/createDiagramEditor.d.ts +19 -1
  35. package/dist/esm/types/api/index.d.ts +1 -1
  36. package/dist/esm/types/api/types.d.ts +41 -0
  37. package/dist/esm/types/displaybox/DisplayBoxControls.d.ts +5 -1
  38. package/dist/esm/types/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.d.ts +3 -0
  39. package/dist/esm/types/displaybox/demos/LayoutLabelReservedSpaceDemoTab.d.ts +3 -0
  40. package/dist/esm/types/displaybox/demos/VertexControlLinkSessionDemoTab.d.ts +3 -0
  41. package/dist/esm/types/displaybox/demos/asymmetricPortMultiAnchorDemo.d.ts +31 -0
  42. package/dist/esm/types/displaybox/demos/labelStyleDemo.d.ts +2 -0
  43. package/dist/esm/types/displaybox/demos/layoutLabelReservedSpaceDemo.d.ts +11 -0
  44. package/dist/esm/types/displaybox/demos/portPositionLimitsDemo.d.ts +2 -0
  45. package/dist/esm/types/displaybox/demos/vertexControlLinkSessionDemo.d.ts +12 -0
  46. package/dist/esm/types/displaybox/useDemoControls.d.ts +4 -0
  47. package/dist/esm/types/engine/AutoLayoutService.d.ts +2 -0
  48. package/dist/esm/types/engine/DiagramEngine.d.ts +11 -0
  49. package/dist/esm/types/engine/LinkRoutingService.d.ts +9 -1
  50. package/dist/esm/types/models/PortModel.d.ts +5 -0
  51. package/dist/esm/types/renderer/RenderTypes.d.ts +3 -1
  52. package/dist/esm/types/renderer/konva/KonvaInteraction.d.ts +14 -0
  53. package/dist/esm/types/renderer/konva/KonvaNodeFactory.d.ts +12 -0
  54. package/dist/esm/types/renderer/konva/KonvaRenderer.d.ts +2 -1
  55. package/dist/esm/types/shapes/BuiltInShapes.d.ts +3 -1
  56. package/dist/esm/types/strategies/ObstacleRouter.d.ts +2 -0
  57. package/dist/esm/types/utils/__tests__/portGeometry.test.d.ts +1 -0
  58. package/dist/esm/types/utils/portGeometry.d.ts +44 -0
  59. package/dist/examples.d.ts +59 -0
  60. package/dist/index.d.ts +67 -1
  61. package/package.json +2 -1
  62. package/src/displaybox/demos/AsymmetricPortMultiAnchorDemoTab.tsx +269 -0
  63. package/src/displaybox/demos/AutoLayoutDemoTab.tsx +113 -11
  64. package/src/displaybox/demos/DeletionEventsDemoTab.tsx +6 -1
  65. package/src/displaybox/demos/EngineEventsDemoTab.tsx +5 -0
  66. package/src/displaybox/demos/EventHandlersDemoTab.tsx +5 -0
  67. package/src/displaybox/demos/ExternalDragDropDemoTab.tsx +5 -0
  68. package/src/displaybox/demos/LayoutLabelReservedSpaceDemoTab.tsx +291 -0
  69. package/src/displaybox/demos/LinkCancelDemoTab.tsx +5 -0
  70. package/src/displaybox/demos/ObstacleRoutingDemoTab.tsx +11 -10
  71. package/src/displaybox/demos/ShapeHoverControlsDemoTab.tsx +6 -1
  72. package/src/displaybox/demos/SimpleDemo.tsx +5 -0
  73. package/src/displaybox/demos/SvgPathDemoTab.tsx +5 -0
  74. package/src/displaybox/demos/TextLayoutDemoTab.tsx +6 -1
  75. package/src/displaybox/demos/VertexControlLinkSessionDemoTab.tsx +302 -0
  76. package/src/displaybox/demos/asymmetricPortMultiAnchorDemo.ts +357 -0
  77. package/src/displaybox/demos/autoLayoutDemo.ts +23 -5
  78. package/src/displaybox/demos/index.tsx +110 -80
  79. package/src/displaybox/demos/labelStyleDemo.ts +101 -0
  80. package/src/displaybox/demos/layoutLabelReservedSpaceDemo.ts +121 -0
  81. package/src/displaybox/demos/obstacleRoutingDemo.ts +212 -176
  82. package/src/displaybox/demos/portPositionLimitsDemo.ts +211 -0
  83. package/src/displaybox/demos/vertexControlLinkSessionDemo.ts +145 -0
@@ -0,0 +1,302 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type {
3
+ DiagramEditorHandle,
4
+ DiagramState,
5
+ ElementShapeHoverControlInteractionEvent,
6
+ ElementShapeHoverControls,
7
+ Point,
8
+ PortData,
9
+ } from '../../api';
10
+ import { createId } from '../../utils/ids';
11
+ import DisplayBoxControls from '../DisplayBoxControls';
12
+ import DisplayBoxStage from '../DisplayBoxStage';
13
+ import useDemoControls from '../useDemoControls';
14
+ import useDemoEditor from '../useDemoEditor';
15
+ import useOffsetSequence from '../useOffsetSequence';
16
+ import type { DemoActionHelpers } from '../types';
17
+ import { gridStageStyle } from './shared';
18
+ import { vertexControlLinkSessionDemoConfig, vertexSessionDemoIds } from './vertexControlLinkSessionDemo';
19
+
20
+ const VERTEX_CONTROL_ID = 'vertex-session-link';
21
+ const VERTEX_CONTROL_SVG = 'M2 8 H14 M8 2 V14';
22
+
23
+ type SessionTarget =
24
+ | { type: 'port'; id: string }
25
+ | { type: 'element'; id: string }
26
+ | { type: 'none' };
27
+
28
+ const pointInRect = (point: Point, rect: { x: number; y: number; width: number; height: number }): boolean =>
29
+ point.x >= rect.x &&
30
+ point.x <= rect.x + rect.width &&
31
+ point.y >= rect.y &&
32
+ point.y <= rect.y + rect.height;
33
+
34
+ const resolveTargetFromPointer = (
35
+ state: DiagramState,
36
+ pointer: Point,
37
+ sourcePortId: string,
38
+ ): SessionTarget => {
39
+ let closestPort: { id: string; distance: number } | null = null;
40
+ for (const port of state.ports) {
41
+ if (port.id === sourcePortId) continue;
42
+ const host = state.elements.find((element) => element.id === port.elementId);
43
+ if (!host) continue;
44
+ const world = {
45
+ x: host.position.x + port.position.x,
46
+ y: host.position.y + port.position.y,
47
+ };
48
+ const distance = Math.hypot(world.x - pointer.x, world.y - pointer.y);
49
+ if (distance > 16) continue;
50
+ if (!closestPort || distance < closestPort.distance) {
51
+ closestPort = { id: port.id, distance };
52
+ }
53
+ }
54
+ if (closestPort) {
55
+ return { type: 'port', id: closestPort.id };
56
+ }
57
+
58
+ const targetOrder = [vertexSessionDemoIds.elementTarget, vertexSessionDemoIds.portTarget];
59
+ for (let i = 0; i < targetOrder.length; i += 1) {
60
+ const element = state.elements.find((item) => item.id === targetOrder[i]);
61
+ if (!element) continue;
62
+ if (pointInRect(pointer, {
63
+ x: element.position.x,
64
+ y: element.position.y,
65
+ width: element.size.width,
66
+ height: element.size.height,
67
+ })) {
68
+ return { type: 'element', id: element.id };
69
+ }
70
+ }
71
+
72
+ return { type: 'none' };
73
+ };
74
+
75
+ const createVertexSessionSourcePort = (
76
+ editor: DiagramEditorHandle,
77
+ sourcePoint: Point,
78
+ ): string => {
79
+ const portId = `vertex-session-source-${createId()}`;
80
+ const port: PortData = {
81
+ id: portId,
82
+ elementId: vertexSessionDemoIds.source,
83
+ position: { x: 0, y: 0 },
84
+ shapeId: 'port-dark',
85
+ moveMode: 'border',
86
+ anchorCenter: true,
87
+ orientToHostBorder: true,
88
+ };
89
+ editor.addPortToElement(vertexSessionDemoIds.source, port);
90
+ editor.movePortTo(portId, sourcePoint.x, sourcePoint.y);
91
+ return portId;
92
+ };
93
+
94
+ const vertexControls: ElementShapeHoverControls = {
95
+ controls: [
96
+ {
97
+ id: VERTEX_CONTROL_ID,
98
+ targetKind: 'vertex',
99
+ allowAllTargets: true,
100
+ visibilityTriggers: ['target-hover'],
101
+ tolerance: 14,
102
+ icon: {
103
+ svgPath: VERTEX_CONTROL_SVG,
104
+ size: { width: 16, height: 16 },
105
+ style: { fill: 'none', stroke: '#6a3da3', strokeWidth: 2, lineCap: 'round' },
106
+ },
107
+ },
108
+ ],
109
+ };
110
+
111
+ const VertexControlLinkSessionDemo = () => {
112
+ const demo = vertexControlLinkSessionDemoConfig;
113
+ const editorHandleRef = useRef<DiagramEditorHandle | null>(null);
114
+ const activeSourcePortRef = useRef<string | null>(null);
115
+ const [cancelOnConnect, setCancelOnConnect] = useState(false);
116
+ const [eventLog, setEventLog] = useState<string[]>([]);
117
+ const [lastResolution, setLastResolution] = useState('none');
118
+ const [isLogExpanded, setIsLogExpanded] = useState(false);
119
+
120
+ const nextOffset = useOffsetSequence();
121
+ const actionHelpers: DemoActionHelpers = useMemo(() => ({ nextOffset }), [nextOffset]);
122
+
123
+ const onControlInteraction = useCallback((event: ElementShapeHoverControlInteractionEvent) => {
124
+ if (event.controlId !== VERTEX_CONTROL_ID || event.elementId !== vertexSessionDemoIds.source) return;
125
+ const editor = editorHandleRef.current;
126
+ if (!editor) return;
127
+
128
+ if (event.eventType === 'drag-start') {
129
+ const sourcePoint = event.vertex?.position ?? event.pointer.world;
130
+ const sourcePortId = createVertexSessionSourcePort(editor, sourcePoint);
131
+ activeSourcePortRef.current = sourcePortId;
132
+ editor.startLinkFromPort(sourcePortId, sourcePoint);
133
+ setLastResolution('started');
134
+ return;
135
+ }
136
+
137
+ if (event.eventType === 'drag-move') {
138
+ if (!activeSourcePortRef.current) return;
139
+ editor.updateLinkPreview(event.pointer.world);
140
+ return;
141
+ }
142
+
143
+ if (event.eventType === 'drag-end') {
144
+ const sourcePortId = activeSourcePortRef.current;
145
+ if (!sourcePortId) return;
146
+ const state = editor.getState();
147
+ const target = resolveTargetFromPointer(state, event.pointer.world, sourcePortId);
148
+ if (target.type === 'port') {
149
+ editor.completeLinkToPort(target.id);
150
+ setLastResolution(`completeLinkToPort(${target.id})`);
151
+ } else if (target.type === 'element') {
152
+ editor.completeLinkToElement(target.id, event.pointer.world);
153
+ setLastResolution(`completeLinkToElement(${target.id})`);
154
+ } else {
155
+ editor.cancelLink();
156
+ setLastResolution('cancelLink()');
157
+ }
158
+ activeSourcePortRef.current = null;
159
+ }
160
+ }, []);
161
+
162
+ const { containerRef, editorRef, diagramState, selection, snapEnabled, setSnapEnabled } = useDemoEditor({
163
+ createState: demo.createState,
164
+ elementShapes: demo.elementShapes,
165
+ portShapes: demo.portShapes,
166
+ elementShapeHoverControls: vertexControls,
167
+ onElementShapeHoverControlInteraction: onControlInteraction,
168
+ });
169
+
170
+ editorHandleRef.current = editorRef.current;
171
+
172
+ const controls = useDemoControls({
173
+ demo,
174
+ editorRef,
175
+ diagramState,
176
+ selection,
177
+ snapEnabled,
178
+ setSnapEnabled,
179
+ actionHelpers,
180
+ });
181
+
182
+ useEffect(() => {
183
+ const editor = editorRef.current;
184
+ if (!editor) return undefined;
185
+ const append = (entry: string) => {
186
+ setEventLog((prev) => [entry, ...prev].slice(0, 16));
187
+ };
188
+
189
+ const offStarted = editor.on('elementLinkStarted', (payload) => {
190
+ append(`elementLinkStarted source=${payload.sourcePortId} start=(${Math.round(payload.startWorld.x)},${Math.round(payload.startWorld.y)})`);
191
+ });
192
+ const offConnecting = editor.on('elementLinkConnecting', (payload) => {
193
+ if (cancelOnConnect) {
194
+ payload.cancel();
195
+ }
196
+ append(`elementLinkConnecting source=${payload.sourcePortId} target=${payload.targetPortId} cancelled=${payload.cancelled || cancelOnConnect}`);
197
+ });
198
+ const offEnded = editor.on('elementLinkEnded', (payload) => {
199
+ append(
200
+ `elementLinkEnded source=${payload.sourcePortId} targetPort=${payload.targetPortId ?? '-'} targetElement=${payload.targetElementId ?? '-'} cancelled=${payload.cancelled}`,
201
+ );
202
+ });
203
+
204
+ return () => {
205
+ offStarted();
206
+ offConnecting();
207
+ offEnded();
208
+ };
209
+ }, [editorRef, cancelOnConnect]);
210
+
211
+ const linksCount = diagramState?.links.length ?? 0;
212
+ const portsCount = diagramState?.ports.length ?? 0;
213
+
214
+ const handleManualCancel = () => {
215
+ editorRef.current?.cancelLink();
216
+ activeSourcePortRef.current = null;
217
+ setLastResolution('cancelLink()');
218
+ };
219
+
220
+ return (
221
+ <section>
222
+ <div style={{ marginBottom: 12 }}>
223
+ <h2 style={{ marginTop: 0, marginBottom: 4 }}>{demo.title}</h2>
224
+ <p style={{ marginTop: 0 }}>{demo.description}</p>
225
+ </div>
226
+ <DisplayBoxControls
227
+ actions={demo.actions}
228
+ snapEnabled={controls.snapEnabled}
229
+ selectedLinkRouting={controls.selectedLinkRouting}
230
+ canToggleLinkRouting={controls.canToggleLinkRouting}
231
+ onReload={controls.handleReload}
232
+ onZoomIn={controls.handleZoomIn}
233
+ onZoomOut={controls.handleZoomOut}
234
+ onResetViewport={controls.handleResetViewport}
235
+ onToggleSnap={controls.handleToggleSnap}
236
+ onManualRender={controls.handleManualRender}
237
+ onToggleLinkRouting={controls.handleToggleLinkRouting}
238
+ onAction={controls.handleAction}
239
+ onExportImage={controls.handleExportImage}
240
+ onClearExportPreview={controls.handleClearExportPreview}
241
+ exportPreviewDataUrl={controls.exportPreviewDataUrl}
242
+ exportError={controls.exportError}
243
+ />
244
+
245
+ <div style={{ marginBottom: 12, padding: 12, border: '1px solid #d9e1ec', borderRadius: 8, background: '#fbfdff' }}>
246
+ <div style={{ fontWeight: 700, marginBottom: 6 }}>Required scenarios</div>
247
+ <ol style={{ marginTop: 0, marginBottom: 10, paddingLeft: 20, fontSize: 13 }}>
248
+ <li>Drag source vertex control to the existing port on the left side of Scenario 1 target.</li>
249
+ <li>Drag source vertex control to Scenario 2 target body (not its border port).</li>
250
+ <li>Enable host cancellation, then repeat a completion drag to verify cancelled end event.</li>
251
+ <li>Start a drag and press Cancel Active Session to verify temp visuals clear and cancelled end event.</li>
252
+ <li>Use baseline native ports (bottom row) and drag from one port to the other to verify no regression.</li>
253
+ </ol>
254
+
255
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', marginBottom: 8 }}>
256
+ <label style={{ fontSize: 13 }}>
257
+ <input
258
+ type="checkbox"
259
+ checked={cancelOnConnect}
260
+ onChange={(event) => setCancelOnConnect(event.target.checked)}
261
+ style={{ marginRight: 6 }}
262
+ />
263
+ Host cancels on elementLinkConnecting
264
+ </label>
265
+ <button type="button" onClick={handleManualCancel} style={{ padding: '6px 10px' }}>
266
+ Cancel Active Session
267
+ </button>
268
+ <button type="button" onClick={() => setIsLogExpanded((prev) => !prev)} style={{ padding: '6px 10px' }}>
269
+ {isLogExpanded ? 'Collapse Lifecycle Log' : 'Expand Lifecycle Log'}
270
+ </button>
271
+ <span style={{ fontSize: 13 }}>
272
+ Ports: <strong>{portsCount}</strong> | Links: <strong>{linksCount}</strong> | Last resolution: <strong>{lastResolution}</strong>
273
+ </span>
274
+ </div>
275
+
276
+ <div style={{ fontSize: 12, color: '#2d3a4d' }}>
277
+ <strong>Lifecycle log:</strong> {isLogExpanded ? 'expanded' : 'collapsed'}
278
+ {isLogExpanded ? (
279
+ <pre
280
+ style={{
281
+ marginTop: 6,
282
+ padding: 8,
283
+ maxHeight: 170,
284
+ overflow: 'auto',
285
+ background: '#ffffff',
286
+ border: '1px solid #d9e1ec',
287
+ borderRadius: 6,
288
+ whiteSpace: 'pre-wrap',
289
+ }}
290
+ >
291
+ {eventLog.length ? eventLog.join('\n') : 'No events yet'}
292
+ </pre>
293
+ ) : null}
294
+ </div>
295
+ </div>
296
+
297
+ <DisplayBoxStage containerRef={containerRef} stageStyle={gridStageStyle} />
298
+ </section>
299
+ );
300
+ };
301
+
302
+ export default VertexControlLinkSessionDemo;
@@ -0,0 +1,357 @@
1
+ import type { DiagramState, Point, PortData } from '../../api';
2
+ import type { DemoConfig } from '../types';
3
+ import { baseElementShapes, basePortShapes } from './shared';
4
+
5
+ export const asymmetricPortMultiAnchorDemoId = 'asymmetric-port-multi-anchor';
6
+ export const asymmetricPortDefaultVariantId = 'classic';
7
+
8
+ export type AsymmetricPortShapeVariant = {
9
+ id: string;
10
+ label: string;
11
+ description: string;
12
+ shapeId: string;
13
+ svgPath: string;
14
+ svgSize: { width: number; height: number };
15
+ placementPoint: Point;
16
+ externalLinkAttachPoint: Point;
17
+ internalLinkAttachPoint: Point;
18
+ rotationPivot: Point;
19
+ };
20
+
21
+ export const asymmetricPortShapeVariants: AsymmetricPortShapeVariant[] = [
22
+ {
23
+ id: asymmetricPortDefaultVariantId,
24
+ label: 'Classic arm',
25
+ description: 'Baseline square-line-circle asymmetric glyph.',
26
+ shapeId: 'displaybox-asymmetric-anchor-port-classic',
27
+ svgPath: 'M2 6 H10 V14 H2 Z M10 10 H24 M28 10 m-4 0 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0',
28
+ svgSize: { width: 36, height: 20 },
29
+ placementPoint: { x: 6, y: 10 },
30
+ externalLinkAttachPoint: { x: 28, y: 10 },
31
+ internalLinkAttachPoint: { x: 6, y: 10 },
32
+ rotationPivot: { x: 6, y: 10 },
33
+ },
34
+ {
35
+ id: 'forked-arm',
36
+ label: 'Forked arm',
37
+ description: 'Connector splits before reaching the terminal circle.',
38
+ shapeId: 'displaybox-asymmetric-anchor-port-forked',
39
+ svgPath: 'M2 6 H10 V14 H2 Z M10 10 H18 M18 10 L24 6 M18 10 L24 14 M30 10 m-4 0 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0',
40
+ svgSize: { width: 38, height: 20 },
41
+ placementPoint: { x: 6, y: 10 },
42
+ externalLinkAttachPoint: { x: 30, y: 10 },
43
+ internalLinkAttachPoint: { x: 6, y: 10 },
44
+ rotationPivot: { x: 6, y: 10 },
45
+ },
46
+ {
47
+ id: 'zigzag-arm',
48
+ label: 'Zigzag arm',
49
+ description: 'Connector uses a zigzag body before the endpoint marker.',
50
+ shapeId: 'displaybox-asymmetric-anchor-port-zigzag',
51
+ svgPath: 'M2 6 H10 V14 H2 Z M10 10 L15 6 L20 14 L25 6 L28 10 M34 10 m-4 0 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0',
52
+ svgSize: { width: 42, height: 20 },
53
+ placementPoint: { x: 6, y: 10 },
54
+ externalLinkAttachPoint: { x: 34, y: 10 },
55
+ internalLinkAttachPoint: { x: 6, y: 10 },
56
+ rotationPivot: { x: 6, y: 10 },
57
+ },
58
+ {
59
+ id: 'dual-bar',
60
+ label: 'Dual bar',
61
+ description: 'Body includes dual bars between pivot and circle endpoint.',
62
+ shapeId: 'displaybox-asymmetric-anchor-port-dual-bar',
63
+ svgPath: 'M2 6 H10 V14 H2 Z M10 10 H18 M18 6 V14 M22 6 V14 M22 10 H26 M32 10 m-4 0 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0',
64
+ svgSize: { width: 40, height: 20 },
65
+ placementPoint: { x: 6, y: 10 },
66
+ externalLinkAttachPoint: { x: 32, y: 10 },
67
+ internalLinkAttachPoint: { x: 6, y: 10 },
68
+ rotationPivot: { x: 6, y: 10 },
69
+ },
70
+ ];
71
+
72
+ export const resolveAsymmetricPortShapeVariant = (variantId?: string): AsymmetricPortShapeVariant => {
73
+ if (!variantId) return asymmetricPortShapeVariants[0];
74
+ return asymmetricPortShapeVariants.find((variant) => variant.id === variantId) ?? asymmetricPortShapeVariants[0];
75
+ };
76
+
77
+ export const asymmetricPortMultiAnchorShapeId = resolveAsymmetricPortShapeVariant(asymmetricPortDefaultVariantId).shapeId;
78
+ export const asymmetricPortMultiAnchorShapeSvgPath =
79
+ resolveAsymmetricPortShapeVariant(asymmetricPortDefaultVariantId).svgPath;
80
+ export const asymmetricPortMultiAnchorShapeSize = resolveAsymmetricPortShapeVariant(asymmetricPortDefaultVariantId).svgSize;
81
+
82
+ export const multiAnchorHostId = 'multi-anchor-host';
83
+ export const multiAnchorExternalPortId = 'multi-anchor-port-external';
84
+
85
+ const multiAnchorStroke = '#1f4d99';
86
+ const legacyStroke = '#9b2c2c';
87
+
88
+ type RowMode = 'multi-anchor' | 'legacy';
89
+
90
+ type RowDefinition = {
91
+ elements: DiagramState['elements'];
92
+ ports: DiagramState['ports'];
93
+ links: DiagramState['links'];
94
+ texts: DiagramState['texts'];
95
+ };
96
+
97
+ const createAsymmetricPort = (options: {
98
+ id: string;
99
+ elementId: string;
100
+ position: Point;
101
+ stroke: string;
102
+ mode: RowMode;
103
+ shapeVariant: AsymmetricPortShapeVariant;
104
+ }): PortData => {
105
+ const basePort: PortData = {
106
+ id: options.id,
107
+ elementId: options.elementId,
108
+ position: options.position,
109
+ shapeId: options.shapeVariant.shapeId,
110
+ size: { ...options.shapeVariant.svgSize },
111
+ moveMode: 'border',
112
+ anchorCenter: true,
113
+ orientToHostBorder: true,
114
+ style: {
115
+ stroke: options.stroke,
116
+ strokeWidth: 2,
117
+ fill: 'transparent',
118
+ },
119
+ };
120
+
121
+ if (options.mode === 'legacy') {
122
+ return basePort;
123
+ }
124
+
125
+ return {
126
+ ...basePort,
127
+ placementPoint: { ...options.shapeVariant.placementPoint },
128
+ externalLinkAttachPoint: { ...options.shapeVariant.externalLinkAttachPoint },
129
+ internalLinkAttachPoint: { ...options.shapeVariant.internalLinkAttachPoint },
130
+ rotationPivot: { ...options.shapeVariant.rotationPivot },
131
+ };
132
+ };
133
+
134
+ const createComparisonRow = (options: {
135
+ prefix: 'multi-anchor' | 'legacy';
136
+ top: number;
137
+ mode: RowMode;
138
+ shapeVariant: AsymmetricPortShapeVariant;
139
+ }): RowDefinition => {
140
+ const { prefix, top, mode, shapeVariant } = options;
141
+ const hostId = `${prefix}-host`;
142
+ const peerId = `${prefix}-peer`;
143
+ const childId = `${prefix}-child`;
144
+ const externalPortId = `${prefix}-port-external`;
145
+ const topPortId = `${prefix}-port-top`;
146
+ const bottomPortId = `${prefix}-port-bottom`;
147
+ const internalPortId = `${prefix}-port-internal`;
148
+ const peerPortId = `${prefix}-peer-port`;
149
+ const childPortId = `${prefix}-child-port`;
150
+ const stroke = mode === 'multi-anchor' ? multiAnchorStroke : legacyStroke;
151
+ const title = mode === 'multi-anchor' ? 'Multi-anchor geometry' : 'Legacy/default geometry';
152
+ const subtitle =
153
+ mode === 'multi-anchor'
154
+ ? `${shapeVariant.label}: square pivot stays pinned to the border; right-side link starts at the circle endpoint.`
155
+ : 'Same svgPath without placement/link/pivot anchors for quick comparison.';
156
+
157
+ return {
158
+ elements: [
159
+ {
160
+ id: hostId,
161
+ position: { x: 80, y: top },
162
+ size: { width: 280, height: 180 },
163
+ shapeId: 'default',
164
+ },
165
+ {
166
+ id: peerId,
167
+ position: { x: 470, y: top + 36 },
168
+ size: { width: 170, height: 108 },
169
+ shapeId: 'panel',
170
+ },
171
+ {
172
+ id: childId,
173
+ position: { x: 88, y: 86 },
174
+ size: { width: 122, height: 72 },
175
+ shapeId: 'panel',
176
+ parentId: hostId,
177
+ },
178
+ ],
179
+ ports: [
180
+ createAsymmetricPort({
181
+ id: topPortId,
182
+ elementId: hostId,
183
+ position: { x: 112, y: 0 },
184
+ stroke,
185
+ mode,
186
+ shapeVariant,
187
+ }),
188
+ createAsymmetricPort({
189
+ id: externalPortId,
190
+ elementId: hostId,
191
+ position: { x: 280, y: 68 },
192
+ stroke,
193
+ mode,
194
+ shapeVariant,
195
+ }),
196
+ createAsymmetricPort({
197
+ id: bottomPortId,
198
+ elementId: hostId,
199
+ position: { x: 182, y: 180 },
200
+ stroke,
201
+ mode,
202
+ shapeVariant,
203
+ }),
204
+ createAsymmetricPort({
205
+ id: internalPortId,
206
+ elementId: hostId,
207
+ position: { x: 0, y: 130 },
208
+ stroke,
209
+ mode,
210
+ shapeVariant,
211
+ }),
212
+ {
213
+ id: peerPortId,
214
+ elementId: peerId,
215
+ position: { x: 0, y: 54 },
216
+ shapeId: 'port-dark',
217
+ moveMode: 'border',
218
+ anchorCenter: true,
219
+ },
220
+ {
221
+ id: childPortId,
222
+ elementId: childId,
223
+ position: { x: 0, y: 36 },
224
+ shapeId: 'port-circle',
225
+ moveMode: 'border',
226
+ anchorCenter: true,
227
+ },
228
+ ],
229
+ links: [
230
+ {
231
+ id: `${prefix}-link-external`,
232
+ sourcePortId: externalPortId,
233
+ targetPortId: peerPortId,
234
+ points: [],
235
+ style: {
236
+ stroke: stroke,
237
+ strokeWidth: 2,
238
+ },
239
+ },
240
+ {
241
+ id: `${prefix}-link-internal`,
242
+ sourcePortId: internalPortId,
243
+ targetPortId: childPortId,
244
+ points: [],
245
+ style: {
246
+ stroke: mode === 'multi-anchor' ? '#2f7a3d' : '#7a5f2f',
247
+ strokeWidth: 2,
248
+ dash: mode === 'multi-anchor' ? [8, 4] : [4, 4],
249
+ },
250
+ },
251
+ ],
252
+ texts: [
253
+ {
254
+ id: `${prefix}-title`,
255
+ content: title,
256
+ position: { x: 74, y: top - 42 },
257
+ style: { fontStyle: 'bold', fontSize: 20 },
258
+ },
259
+ {
260
+ id: `${prefix}-subtitle`,
261
+ content: subtitle,
262
+ position: { x: 74, y: top - 18 },
263
+ style: { fontSize: 13, fill: '#334155' },
264
+ },
265
+ {
266
+ id: `${prefix}-host-label`,
267
+ content: 'Drag border ports or use the side buttons below.',
268
+ position: { x: 14, y: -18 },
269
+ ownerId: hostId,
270
+ style: { fontSize: 12, fill: '#334155' },
271
+ },
272
+ {
273
+ id: `${prefix}-top-port-label`,
274
+ content: 'top',
275
+ position: { x: 10, y: -18 },
276
+ ownerId: topPortId,
277
+ style: { fontSize: 11 },
278
+ },
279
+ {
280
+ id: `${prefix}-external-port-label`,
281
+ content: 'external → circle',
282
+ position: { x: 12, y: -18 },
283
+ ownerId: externalPortId,
284
+ style: { fontSize: 11 },
285
+ },
286
+ {
287
+ id: `${prefix}-bottom-port-label`,
288
+ content: 'bottom',
289
+ position: { x: 12, y: 18 },
290
+ ownerId: bottomPortId,
291
+ style: { fontSize: 11 },
292
+ },
293
+ {
294
+ id: `${prefix}-internal-port-label`,
295
+ content: 'internal → square',
296
+ position: { x: 12, y: -18 },
297
+ ownerId: internalPortId,
298
+ style: { fontSize: 11 },
299
+ },
300
+ {
301
+ id: `${prefix}-peer-label`,
302
+ content: 'External peer',
303
+ position: { x: 12, y: -18 },
304
+ ownerId: peerId,
305
+ style: { fontSize: 12 },
306
+ },
307
+ {
308
+ id: `${prefix}-child-label`,
309
+ content: 'Child host for internal link',
310
+ position: { x: 8, y: -18 },
311
+ ownerId: childId,
312
+ style: { fontSize: 11 },
313
+ },
314
+ ],
315
+ };
316
+ };
317
+
318
+ export const createAsymmetricPortMultiAnchorState = (
319
+ showLegacyComparison = true,
320
+ variantId = asymmetricPortDefaultVariantId,
321
+ ): DiagramState => {
322
+ const shapeVariant = resolveAsymmetricPortShapeVariant(variantId);
323
+ const primaryRow = createComparisonRow({ prefix: 'multi-anchor', top: 92, mode: 'multi-anchor', shapeVariant });
324
+ const legacyRow = showLegacyComparison
325
+ ? createComparisonRow({ prefix: 'legacy', top: 356, mode: 'legacy', shapeVariant })
326
+ : { elements: [], ports: [], links: [], texts: [] };
327
+
328
+ return {
329
+ elements: [...primaryRow.elements, ...legacyRow.elements],
330
+ ports: [...primaryRow.ports, ...legacyRow.ports],
331
+ links: [...primaryRow.links, ...legacyRow.links],
332
+ texts: [
333
+ {
334
+ id: 'multi-anchor-legend',
335
+ content:
336
+ 'Legend: square = placement/rotation pivot, line = glyph body, circle = external attach endpoint. Internal link uses the square anchor.',
337
+ position: { x: 72, y: 28 },
338
+ style: { fontSize: 13, fill: '#1f2937' },
339
+ },
340
+ ...primaryRow.texts,
341
+ ...legacyRow.texts,
342
+ ],
343
+ };
344
+ };
345
+
346
+ export const asymmetricPortMultiAnchorDemoConfig: DemoConfig = {
347
+ id: asymmetricPortMultiAnchorDemoId,
348
+ title: 'Asymmetric Multi-Anchor Ports',
349
+ description:
350
+ 'Compares legacy/default origin-based svg glyph behavior with explicit placement, pivot, and external/internal link attach points.',
351
+ createState: () => createAsymmetricPortMultiAnchorState(true, asymmetricPortDefaultVariantId),
352
+ elementShapes: baseElementShapes,
353
+ portShapes: basePortShapes,
354
+ defaultElementShapeId: 'default',
355
+ defaultPortShapeId: 'port-circle',
356
+ actions: [],
357
+ };