@dforge-core/diagram 0.0.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dist/EntityNode.svelte +167 -0
  4. package/dist/EntityNode.svelte.d.ts +8 -0
  5. package/dist/EntityNode.svelte.d.ts.map +1 -0
  6. package/dist/IconButton.svelte +83 -0
  7. package/dist/IconButton.svelte.d.ts +13 -0
  8. package/dist/IconButton.svelte.d.ts.map +1 -0
  9. package/dist/SimpleDiagram/DiagramDebug.svelte +499 -0
  10. package/dist/SimpleDiagram/DiagramDebug.svelte.d.ts +4 -0
  11. package/dist/SimpleDiagram/DiagramDebug.svelte.d.ts.map +1 -0
  12. package/dist/SimpleDiagram/ModelDiagram.svelte +569 -0
  13. package/dist/SimpleDiagram/ModelDiagram.svelte.d.ts +18 -0
  14. package/dist/SimpleDiagram/ModelDiagram.svelte.d.ts.map +1 -0
  15. package/dist/SimpleDiagram/NOTE.md +14 -0
  16. package/dist/SimpleDiagram/SimpleDiagram.svelte +755 -0
  17. package/dist/SimpleDiagram/SimpleDiagram.svelte.d.ts +13 -0
  18. package/dist/SimpleDiagram/SimpleDiagram.svelte.d.ts.map +1 -0
  19. package/dist/SimpleDiagram/diagram-geometry.d.ts +33 -0
  20. package/dist/SimpleDiagram/diagram-geometry.d.ts.map +1 -0
  21. package/dist/SimpleDiagram/diagram-geometry.js +137 -0
  22. package/dist/SimpleDiagram/simple-layout.d.ts +27 -0
  23. package/dist/SimpleDiagram/simple-layout.d.ts.map +1 -0
  24. package/dist/SimpleDiagram/simple-layout.js +364 -0
  25. package/dist/WorkspaceMap/ModuleCard.svelte +165 -0
  26. package/dist/WorkspaceMap/ModuleCard.svelte.d.ts +8 -0
  27. package/dist/WorkspaceMap/ModuleCard.svelte.d.ts.map +1 -0
  28. package/dist/WorkspaceMap/WorkspaceMap.svelte +319 -0
  29. package/dist/WorkspaceMap/WorkspaceMap.svelte.d.ts +12 -0
  30. package/dist/WorkspaceMap/WorkspaceMap.svelte.d.ts.map +1 -0
  31. package/dist/WorkspaceMap/workspace-graph.d.ts +18 -0
  32. package/dist/WorkspaceMap/workspace-graph.d.ts.map +1 -0
  33. package/dist/WorkspaceMap/workspace-graph.js +168 -0
  34. package/dist/WorkspaceMap/workspace-types.d.ts +32 -0
  35. package/dist/WorkspaceMap/workspace-types.d.ts.map +1 -0
  36. package/dist/WorkspaceMap/workspace-types.js +1 -0
  37. package/dist/diagram-types.d.ts +47 -0
  38. package/dist/diagram-types.d.ts.map +1 -0
  39. package/dist/diagram-types.js +2 -0
  40. package/dist/edge-routing.d.ts +82 -0
  41. package/dist/edge-routing.d.ts.map +1 -0
  42. package/dist/edge-routing.js +388 -0
  43. package/dist/i18n.d.ts +13 -0
  44. package/dist/i18n.d.ts.map +1 -0
  45. package/dist/i18n.js +20 -0
  46. package/dist/index.d.ts +23 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +21 -0
  49. package/dist/local-storage-sync.d.ts +10 -0
  50. package/dist/local-storage-sync.d.ts.map +1 -0
  51. package/dist/local-storage-sync.js +39 -0
  52. package/dist/model-graph.d.ts +20 -0
  53. package/dist/model-graph.d.ts.map +1 -0
  54. package/dist/model-graph.js +100 -0
  55. package/dist/model-types.d.ts +55 -0
  56. package/dist/model-types.d.ts.map +1 -0
  57. package/dist/model-types.js +11 -0
  58. package/dist/pan-zoom.svelte.d.ts +34 -0
  59. package/dist/pan-zoom.svelte.d.ts.map +1 -0
  60. package/dist/pan-zoom.svelte.js +130 -0
  61. package/package.json +55 -0
@@ -0,0 +1,755 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import IconButton from '../IconButton.svelte';
4
+ import type { DiagramNode, ResolvedEdge, Point } from '../diagram-types';
5
+ import { PORT_DOT_RADIUS } from '../edge-routing';
6
+ import { buildSvgPath, edgeLabelPos, prepareDragPoints, consolidatePoints, insertJog, getSegmentHandles, applySegmentDrag, type SegHandle } from './diagram-geometry';
7
+ import { routeEdge } from './simple-layout';
8
+ import { getDiagramTranslations } from '../i18n';
9
+ import { createPanZoom } from '../pan-zoom.svelte';
10
+ import { createLocalStorageSync } from '../local-storage-sync';
11
+ import EntityNode from '../EntityNode.svelte';
12
+
13
+ const t = getDiagramTranslations();
14
+
15
+ interface Props {
16
+ nodes: DiagramNode[];
17
+ edges: ResolvedEdge[];
18
+ storageKey?: string;
19
+ minZoom?: number;
20
+ maxZoom?: number;
21
+ onNodeDblClick?: (nodeId: string) => void;
22
+ }
23
+
24
+ let { nodes, edges, storageKey, minZoom = 0.1, maxZoom = 2, onNodeDblClick }: Props = $props();
25
+
26
+ let svgEl: SVGSVGElement;
27
+ let containerEl: HTMLDivElement;
28
+ let dragOffsets = $state<Record<string, { dx: number; dy: number }>>({});
29
+ let selectedNodeIds = $state<Set<string>>(new Set());
30
+ let selectedEdgeId = $state<string | null>(null);
31
+ let areaSelect = $state<{ startX: number; startY: number; endX: number; endY: number } | null>(null);
32
+ let edgeOverrides = $state<Record<string, Point[]>>({});
33
+ let dragging = $state<{ nodeId: string; offsets: Record<string, { offsetX: number; offsetY: number }> } | null>(null);
34
+ let draggingSeg = $state<{ edgeId: string; segIndex: number; orientation: 'H' | 'V' } | null>(null);
35
+ let patternId = `grid-${Math.random().toString(36).slice(2, 8)}`;
36
+
37
+ const pz = createPanZoom({
38
+ get minZoom() { return minZoom; },
39
+ get maxZoom() { return maxZoom; },
40
+ getSvgEl: () => svgEl,
41
+ getContainerEl: () => containerEl,
42
+ });
43
+
44
+ // Auto-routed edges (from layout or node drag)
45
+ let autoEdges = $state<ResolvedEdge[]>([]);
46
+
47
+ // Resolved = auto-routed with manual overrides applied
48
+ const resolvedEdges = $derived.by(() =>
49
+ autoEdges.map((e) => {
50
+ const pts = edgeOverrides[e.id];
51
+ return pts ? { ...e, points: pts } : e;
52
+ })
53
+ );
54
+
55
+ // --- localStorage persistence ---
56
+
57
+ interface DiagramState {
58
+ dragOffsets: Record<string, { dx: number; dy: number }>;
59
+ edgeOverrides: Record<string, Point[]>;
60
+ transform: { x: number; y: number; k: number };
61
+ }
62
+
63
+ const storage = createLocalStorageSync<DiagramState>(() => storageKey);
64
+ let restoredTransform = false;
65
+
66
+ // Persist on relevant state changes
67
+ $effect(() => {
68
+ const state: DiagramState = { dragOffsets, edgeOverrides, transform: pz.transform };
69
+ storage.save(state);
70
+ });
71
+
72
+ // Reset when input changes
73
+ const nodeIds = $derived(nodes.map((n) => n.id).join(','));
74
+ $effect(() => {
75
+ nodeIds; // track
76
+ const saved = storage.load();
77
+ if (saved && Object.keys(saved.dragOffsets).length > 0) {
78
+ const offsets = saved.dragOffsets;
79
+ dragOffsets = offsets;
80
+ edgeOverrides = saved.edgeOverrides;
81
+ pz.setTransform(saved.transform);
82
+ restoredTransform = true;
83
+ // Re-route edges for moved nodes (use local `offsets` to avoid reading $state)
84
+ const movedIds = new Set(Object.keys(offsets));
85
+ const posMap: Record<string, DiagramNode> = {};
86
+ for (const n of nodes) {
87
+ const off = offsets[n.id];
88
+ posMap[n.id] = off ? { ...n, x: n.x + off.dx, y: n.y + off.dy } : n;
89
+ }
90
+ const posLookup = (id: string) => posMap[id];
91
+ autoEdges = edges.map((e) => {
92
+ if (!movedIds.has(e.source) && !movedIds.has(e.target)) return e;
93
+ return routeEdge(e, posLookup);
94
+ });
95
+ } else if (saved) {
96
+ dragOffsets = {};
97
+ edgeOverrides = saved.edgeOverrides;
98
+ pz.setTransform(saved.transform);
99
+ autoEdges = edges;
100
+ restoredTransform = true;
101
+ } else {
102
+ dragOffsets = {};
103
+ edgeOverrides = {};
104
+ autoEdges = edges;
105
+ restoredTransform = false;
106
+ }
107
+ selectedNodeIds = new Set();
108
+ selectedEdgeId = null;
109
+ });
110
+
111
+ // Derive node positions with drag offsets applied
112
+ const nodePositions = $derived.by(() => {
113
+ const pos: Record<string, DiagramNode> = {};
114
+ for (const n of nodes) {
115
+ const off = dragOffsets[n.id];
116
+ pos[n.id] = off ? { ...n, x: n.x + off.dx, y: n.y + off.dy } : n;
117
+ }
118
+ return pos;
119
+ });
120
+
121
+ // Node lookup map for O(1) access during drag
122
+ const nodeMap = $derived(new Map(nodes.map((n) => [n.id, n])));
123
+
124
+ // Edges to highlight: connected to any selected node OR the selected edge itself
125
+ const highlightedEdges = $derived.by(() => {
126
+ const set = new Set<string>();
127
+ if (selectedNodeIds.size > 0) {
128
+ for (const e of resolvedEdges) {
129
+ if (selectedNodeIds.has(e.source) || selectedNodeIds.has(e.target)) set.add(e.id);
130
+ }
131
+ }
132
+ if (selectedEdgeId) set.add(selectedEdgeId);
133
+ return set;
134
+ });
135
+
136
+ // Edge lookup map for O(1) access
137
+ const resolvedEdgeMap = $derived(new Map(resolvedEdges.map((e) => [e.id, e])));
138
+
139
+ // Segment handles for selected edge
140
+ const selectedEdgeHandles = $derived.by((): SegHandle[] => {
141
+ if (!selectedEdgeId) return [];
142
+ const edge = resolvedEdgeMap.get(selectedEdgeId);
143
+ if (!edge || edge.points.length < 2) return [];
144
+ return getSegmentHandles(edge.points);
145
+ });
146
+
147
+ // --- Re-route edges for a dragged node ---
148
+
149
+ function rerouteEdges() {
150
+ const allMoved = new Set(Object.keys(dragOffsets));
151
+ const lookup = (id: string) => nodePositions[id];
152
+ autoEdges = edges.map((e) => {
153
+ if (!allMoved.has(e.source) && !allMoved.has(e.target)) return e;
154
+ return routeEdge(e, lookup);
155
+ });
156
+ // Clear overrides for edges connected to any dragged node
157
+ if (!dragging) return;
158
+ const draggedIds = new Set(Object.keys(dragging.offsets));
159
+ let changed = false;
160
+ const newOverrides = { ...edgeOverrides };
161
+ for (const e of edges) {
162
+ if (newOverrides[e.id] && (draggedIds.has(e.source) || draggedIds.has(e.target))) {
163
+ delete newOverrides[e.id];
164
+ changed = true;
165
+ }
166
+ }
167
+ if (changed) edgeOverrides = newOverrides;
168
+ }
169
+
170
+ /** Get the current points for an edge (override or auto). */
171
+ const getEdgePoints = (edgeId: string): Point[] =>
172
+ edgeOverrides[edgeId] ?? autoEdges.find((e) => e.id === edgeId)?.points ?? [];
173
+
174
+ // --- Delegated event helpers ---
175
+
176
+ /** Walk up from event target to find a data attribute (works across HTML/SVG boundary in foreignObject). */
177
+ function closest(target: Element, attr: string): Element | null {
178
+ let el: Element | null = target;
179
+ while (el && el !== svgEl) {
180
+ if (el.hasAttribute(attr)) return el;
181
+ // parentElement is null at HTML↔SVG boundary in some browsers; fall back to parentNode
182
+ el = el.parentElement ?? (el.parentNode as Element | null);
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function getDataAttr(el: Element, attr: string): string {
188
+ return el.getAttribute(attr) ?? '';
189
+ }
190
+
191
+ // --- Delegated pointer down (single handler on SVG) ---
192
+
193
+ function handlePointerDown(e: PointerEvent) {
194
+ const target = e.target as Element;
195
+
196
+ // Segment drag handle
197
+ const handle = closest(target, 'data-handle');
198
+ if (handle) {
199
+ const edgeId = getDataAttr(handle, 'data-edge-id');
200
+ const segIndex = Number(getDataAttr(handle, 'data-seg-index'));
201
+ const orient = getDataAttr(handle, 'data-seg-orient') as 'H' | 'V';
202
+ if (getDataAttr(handle, 'data-handle') === 'seg') {
203
+ startSegDrag(e, edgeId, segIndex, orient);
204
+ } else {
205
+ startSplitDrag(e, edgeId, segIndex, orient);
206
+ }
207
+ return;
208
+ }
209
+
210
+ // Edge reset button
211
+ if (closest(target, 'data-edge-reset')) {
212
+ if (selectedEdgeId) resetEdgeRoute(selectedEdgeId);
213
+ return;
214
+ }
215
+
216
+ // Edge hit area
217
+ const edgeHit = closest(target, 'data-edge-id');
218
+ if (edgeHit) {
219
+ selectedEdgeId = getDataAttr(edgeHit, 'data-edge-id');
220
+ selectedNodeIds = new Set();
221
+ return;
222
+ }
223
+
224
+ // Node
225
+ const nodeEl = closest(target, 'data-node-id');
226
+ if (nodeEl) {
227
+ onNodePointerDown(e, getDataAttr(nodeEl, 'data-node-id'));
228
+ return;
229
+ }
230
+
231
+ // Background (pan or area select)
232
+ if (target === svgEl || target.classList.contains('diagram-bg')) {
233
+ e.preventDefault();
234
+ if (e.shiftKey) {
235
+ const pos = pz.clientToSvg(e.clientX, e.clientY);
236
+ areaSelect = { startX: pos.x, startY: pos.y, endX: pos.x, endY: pos.y };
237
+ } else {
238
+ selectedNodeIds = new Set();
239
+ selectedEdgeId = null;
240
+ pz.startPan(e);
241
+ }
242
+ svgEl.setPointerCapture(e.pointerId);
243
+ }
244
+ }
245
+
246
+ // --- Delegated double-click (single handler on SVG) ---
247
+
248
+ function handleDblClick(e: MouseEvent) {
249
+ const target = e.target as Element;
250
+
251
+ // Edge hit — reset to auto-route
252
+ const edgeHit = closest(target, 'data-edge-id');
253
+ if (edgeHit) {
254
+ resetEdgeRoute(getDataAttr(edgeHit, 'data-edge-id'));
255
+ return;
256
+ }
257
+
258
+ // Node — navigate
259
+ const nodeEl = closest(target, 'data-node-id');
260
+ if (nodeEl) {
261
+ onNodeDblClick?.(getDataAttr(nodeEl, 'data-node-id'));
262
+ }
263
+ }
264
+
265
+ function handlePointerMove(e: PointerEvent) {
266
+ if (areaSelect) {
267
+ const pos = pz.clientToSvg(e.clientX, e.clientY);
268
+ areaSelect = { ...areaSelect, endX: pos.x, endY: pos.y };
269
+ return;
270
+ }
271
+ if (pz.onPointerMove(e)) return;
272
+ if (draggingSeg) {
273
+ const pos = pz.clientToSvg(e.clientX, e.clientY);
274
+ const pts = getEdgePoints(draggingSeg.edgeId);
275
+ if (pts.length === 0) return;
276
+ const result = applySegmentDrag(pts, draggingSeg.segIndex, draggingSeg.orientation, pos);
277
+ edgeOverrides = { ...edgeOverrides, [draggingSeg.edgeId]: result };
278
+ return;
279
+ }
280
+ if (dragging) {
281
+ const pos = pz.clientToSvg(e.clientX, e.clientY);
282
+ const newOffsets = { ...dragOffsets };
283
+ for (const [nid, off] of Object.entries(dragging.offsets)) {
284
+ const node = nodeMap.get(nid);
285
+ if (node) {
286
+ newOffsets[nid] = {
287
+ dx: pos.x - off.offsetX - node.x,
288
+ dy: pos.y - off.offsetY - node.y,
289
+ };
290
+ }
291
+ }
292
+ dragOffsets = newOffsets;
293
+ rerouteEdges();
294
+ }
295
+ }
296
+
297
+ function handlePointerUp(e: PointerEvent) {
298
+ if (areaSelect) {
299
+ svgEl.releasePointerCapture(e.pointerId);
300
+ const minX = Math.min(areaSelect.startX, areaSelect.endX);
301
+ const maxX = Math.max(areaSelect.startX, areaSelect.endX);
302
+ const minY = Math.min(areaSelect.startY, areaSelect.endY);
303
+ const maxY = Math.max(areaSelect.startY, areaSelect.endY);
304
+
305
+ const selected = new Set<string>();
306
+ for (const node of nodes) {
307
+ const pos = nodePositions[node.id] ?? node;
308
+ if (pos.x + node.width > minX && pos.x < maxX && pos.y + node.height > minY && pos.y < maxY) {
309
+ selected.add(node.id);
310
+ }
311
+ }
312
+ selectedNodeIds = selected;
313
+ selectedEdgeId = null;
314
+ areaSelect = null;
315
+ return;
316
+ }
317
+ pz.endPan(e);
318
+ if (draggingSeg) {
319
+ const edgeId = draggingSeg.edgeId;
320
+ const pts = edgeOverrides[edgeId];
321
+ if (pts) {
322
+ edgeOverrides = { ...edgeOverrides, [edgeId]: consolidatePoints(pts) };
323
+ }
324
+ }
325
+ dragging = null;
326
+ draggingSeg = null;
327
+ }
328
+
329
+ // --- Node interaction ---
330
+
331
+ function onNodePointerDown(e: PointerEvent, nodeId: string) {
332
+ selectedEdgeId = null;
333
+
334
+ if (e.shiftKey) {
335
+ const next = new Set(selectedNodeIds);
336
+ if (next.has(nodeId)) next.delete(nodeId);
337
+ else next.add(nodeId);
338
+ selectedNodeIds = next;
339
+ return;
340
+ }
341
+
342
+ if (!selectedNodeIds.has(nodeId)) {
343
+ selectedNodeIds = new Set([nodeId]);
344
+ }
345
+
346
+ const pos = pz.clientToSvg(e.clientX, e.clientY);
347
+ const offsets: Record<string, { offsetX: number; offsetY: number }> = {};
348
+ for (const nid of selectedNodeIds) {
349
+ const np = nodePositions[nid];
350
+ if (np) {
351
+ offsets[nid] = { offsetX: pos.x - np.x, offsetY: pos.y - np.y };
352
+ }
353
+ }
354
+ dragging = { nodeId, offsets };
355
+ }
356
+
357
+ // --- Edge interaction ---
358
+
359
+ function resetEdgeRoute(edgeId: string) {
360
+ const { [edgeId]: _, ...rest } = edgeOverrides;
361
+ edgeOverrides = rest;
362
+ }
363
+
364
+ function startSegDrag(_e: PointerEvent, edgeId: string, segIndex: number, orientation: 'H' | 'V') {
365
+ const pts = getEdgePoints(edgeId);
366
+ if (pts.length === 0) return;
367
+
368
+ if (!edgeOverrides[edgeId]) {
369
+ edgeOverrides = { ...edgeOverrides, [edgeId]: pts.map((p) => ({ ...p })) };
370
+ }
371
+
372
+ const prepared = prepareDragPoints(edgeOverrides[edgeId]!, segIndex, orientation);
373
+ edgeOverrides = { ...edgeOverrides, [edgeId]: prepared.points };
374
+ draggingSeg = { edgeId, segIndex: prepared.segIndex, orientation };
375
+ }
376
+
377
+ function startSplitDrag(_e: PointerEvent, edgeId: string, segIndex: number, orientation: 'H' | 'V') {
378
+ const pts = getEdgePoints(edgeId);
379
+ if (pts.length === 0) return;
380
+
381
+ const jog = insertJog(pts, segIndex, orientation);
382
+
383
+ const prepared = prepareDragPoints(jog.points, jog.segIndex, jog.orientation);
384
+ edgeOverrides = { ...edgeOverrides, [edgeId]: prepared.points };
385
+ draggingSeg = { edgeId, segIndex: prepared.segIndex, orientation: jog.orientation };
386
+ }
387
+
388
+ // --- Fit / reset ---
389
+
390
+ function computeBounds() {
391
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
392
+ for (const n of nodes) {
393
+ const pos = nodePositions[n.id] ?? n;
394
+ minX = Math.min(minX, pos.x);
395
+ minY = Math.min(minY, pos.y);
396
+ maxX = Math.max(maxX, pos.x + n.width);
397
+ maxY = Math.max(maxY, pos.y + n.height);
398
+ }
399
+ return { minX, minY, maxX, maxY };
400
+ }
401
+
402
+ function fitView() {
403
+ if (nodes.length === 0) return;
404
+ pz.fitView(computeBounds());
405
+ }
406
+
407
+ function resetLayout() {
408
+ dragOffsets = {};
409
+ edgeOverrides = {};
410
+ selectedNodeIds = new Set();
411
+ selectedEdgeId = null;
412
+ autoEdges = edges;
413
+ requestAnimationFrame(() => fitView());
414
+ }
415
+
416
+ onMount(() => {
417
+ svgEl.addEventListener('wheel', pz.handleWheel, { passive: false });
418
+ if (!restoredTransform) {
419
+ requestAnimationFrame(() => fitView());
420
+ }
421
+ return () => {
422
+ svgEl.removeEventListener('wheel', pz.handleWheel);
423
+ storage.clear();
424
+ };
425
+ });
426
+ </script>
427
+
428
+ <div class="model-diagram" bind:this={containerEl}>
429
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
430
+ <svg
431
+ bind:this={svgEl}
432
+ role="application"
433
+ onpointerdown={handlePointerDown}
434
+ onpointermove={handlePointerMove}
435
+ onpointerup={handlePointerUp}
436
+ ondblclick={handleDblClick}
437
+ >
438
+ <defs>
439
+ <pattern id={patternId} width="20" height="20" patternUnits="userSpaceOnUse"
440
+ patternTransform="translate({pz.transform.x},{pz.transform.y}) scale({pz.transform.k})"
441
+ >
442
+ <circle cx="1" cy="1" r="1" fill="var(--bs-border-color, #dee2e6)" />
443
+ </pattern>
444
+ </defs>
445
+
446
+ <!-- Background grid -->
447
+ <rect class="diagram-bg" width="100%" height="100%" fill="url(#{patternId})" />
448
+
449
+ <!-- Transformed content -->
450
+ <g style:transform="translate({pz.transform.x}px, {pz.transform.y}px) scale({pz.transform.k})"
451
+ style:transform-origin="0 0"
452
+ style:will-change="transform"
453
+ >
454
+ {#snippet renderEdge(edge: ResolvedEdge)}
455
+ {@const d = buildSvgPath(edge.points)}
456
+ {@const label = edgeLabelPos(edge.points)}
457
+ {@const srcPort = edge.points[0]}
458
+ {@const tgtPort = edge.points[edge.points.length - 1]}
459
+ {@const highlighted = highlightedEdges.has(edge.id)}
460
+ {@const selected = selectedEdgeId === edge.id}
461
+ {@const srcDir = edge.points.length >= 2 ? Math.sign(edge.points[1].x - edge.points[0].x) : 1}
462
+ {@const tgtDir = edge.points.length >= 2 ? Math.sign(edge.points[edge.points.length - 2].x - edge.points[edge.points.length - 1].x) : -1}
463
+
464
+ <path
465
+ d={d}
466
+ fill="none"
467
+ class="diagram-edge-hit"
468
+ data-edge-id={edge.id}
469
+ />
470
+ <path {d} fill="none" stroke={edge.color}
471
+ stroke-width={selected ? 2.5 : highlighted ? 2 : 1}
472
+ class="diagram-edge"
473
+ class:highlighted={highlighted && !selected}
474
+ class:selected />
475
+
476
+ {#if edge.label}
477
+ <text x={label.x} y={label.y} text-anchor="middle"
478
+ class="diagram-edge-label"
479
+ fill="var(--bs-secondary-color, #6c757d)"
480
+ >{edge.label}</text>
481
+ {/if}
482
+
483
+ {#if srcPort && tgtPort}
484
+ <circle cx={srcPort.x} cy={srcPort.y} r={PORT_DOT_RADIUS}
485
+ fill={edge.color} class="port-dot" />
486
+ <circle cx={tgtPort.x} cy={tgtPort.y} r={PORT_DOT_RADIUS}
487
+ fill={edge.color} class="port-dot" />
488
+ <text x={srcPort.x + srcDir * 8} y={srcPort.y - 6}
489
+ text-anchor={srcDir > 0 ? 'start' : 'end'}
490
+ class="cardinality-label" fill={edge.color}
491
+ >{edge.isNullable ? '0..N' : 'N'}</text>
492
+ <text x={tgtPort.x + tgtDir * 8} y={tgtPort.y - 6}
493
+ text-anchor={tgtDir > 0 ? 'start' : 'end'}
494
+ class="cardinality-label" fill={edge.color}
495
+ >1</text>
496
+ {/if}
497
+ {/snippet}
498
+
499
+ <!-- Non-selected edges (behind nodes) -->
500
+ {#each resolvedEdges as edge (edge.id)}
501
+ {#if edge.id !== selectedEdgeId}
502
+ {@render renderEdge(edge)}
503
+ {/if}
504
+ {/each}
505
+
506
+ <!-- Nodes -->
507
+ {#each nodes as node (node.id)}
508
+ {@const pos = nodePositions[node.id] ?? node}
509
+ {@const isSelected = selectedNodeIds.has(node.id)}
510
+ <foreignObject
511
+ x={pos.x}
512
+ y={pos.y}
513
+ width={node.width}
514
+ height={node.height}
515
+ class="diagram-node-fo"
516
+ class:selected={isSelected}
517
+ >
518
+ <div xmlns="http://www.w3.org/1999/xhtml" data-node-id={node.id}>
519
+ <EntityNode data={node.data} />
520
+ </div>
521
+ </foreignObject>
522
+ {/each}
523
+
524
+ <!-- Selected edge on top of nodes -->
525
+ {#if selectedEdgeId}
526
+ {#each resolvedEdges as edge (edge.id)}
527
+ {#if edge.id === selectedEdgeId}
528
+ {@render renderEdge(edge)}
529
+ {/if}
530
+ {/each}
531
+
532
+ <!-- Segment handles -->
533
+ {#each selectedEdgeHandles as handle}
534
+ {#if handle.draggable}
535
+ <rect
536
+ x={handle.x - 4} y={handle.y - 4} width="8" height="8" rx="1"
537
+ fill="var(--bs-primary, #0d6efd)"
538
+ stroke="white" stroke-width="1"
539
+ class="seg-handle"
540
+ class:seg-handle-h={handle.orientation === 'H'}
541
+ class:seg-handle-v={handle.orientation === 'V'}
542
+ data-handle="seg"
543
+ data-edge-id={selectedEdgeId}
544
+ data-seg-index={handle.segIndex}
545
+ data-seg-orient={handle.orientation}
546
+ />
547
+ {:else}
548
+ <circle
549
+ cx={handle.x} cy={handle.y} r="5"
550
+ fill="var(--bs-body-bg, #fff)"
551
+ stroke="var(--bs-primary, #0d6efd)" stroke-width="1.5"
552
+ class="split-handle"
553
+ class:split-handle-h={handle.orientation === 'H'}
554
+ class:split-handle-v={handle.orientation === 'V'}
555
+ data-handle="split"
556
+ data-edge-id={selectedEdgeId}
557
+ data-seg-index={handle.segIndex}
558
+ data-seg-orient={handle.orientation}
559
+ />
560
+ {/if}
561
+ {/each}
562
+
563
+ <!-- Reset button -->
564
+ {#if edgeOverrides[selectedEdgeId]}
565
+ {@const ep = resolvedEdgeMap.get(selectedEdgeId)}
566
+ {#if ep}
567
+ {@const lbl = edgeLabelPos(ep.points)}
568
+ <g class="edge-reset-btn" data-edge-reset>
569
+ <rect x={lbl.x - 11} y={lbl.y - 24} width="22" height="20" rx="4" />
570
+ <text x={lbl.x} y={lbl.y - 14} text-anchor="middle" dominant-baseline="central">&#x21ba;</text>
571
+ </g>
572
+ {/if}
573
+ {/if}
574
+ {/if}
575
+
576
+ <!-- Area selection rectangle -->
577
+ {#if areaSelect}
578
+ {@const rx = Math.min(areaSelect.startX, areaSelect.endX)}
579
+ {@const ry = Math.min(areaSelect.startY, areaSelect.endY)}
580
+ {@const rw = Math.abs(areaSelect.endX - areaSelect.startX)}
581
+ {@const rh = Math.abs(areaSelect.endY - areaSelect.startY)}
582
+ <rect
583
+ x={rx} y={ry} width={rw} height={rh}
584
+ class="area-select-rect"
585
+ />
586
+ {/if}
587
+ </g>
588
+ </svg>
589
+
590
+ <!-- Controls -->
591
+ <div class="diagram-controls">
592
+ <IconButton icon="bi-plus-lg" title={t.zoom_in} size="sm" onclick={() => pz.zoomBy(1.3)} />
593
+ <IconButton icon="bi-dash-lg" title={t.zoom_out} size="sm" onclick={() => pz.zoomBy(1 / 1.3)} />
594
+ <IconButton icon="bi-fullscreen" title={t.fit_view} size="sm" onclick={fitView} />
595
+ <IconButton icon="bi-arrow-counterclockwise" title={t.reset_layout} size="sm" onclick={resetLayout} />
596
+ </div>
597
+ </div>
598
+
599
+ <style>
600
+ .model-diagram {
601
+ width: 100%;
602
+ height: 100%;
603
+ position: relative;
604
+ overflow: hidden;
605
+ }
606
+
607
+ .model-diagram svg {
608
+ width: 100%;
609
+ height: 100%;
610
+ cursor: grab;
611
+ display: block;
612
+ }
613
+
614
+ .model-diagram svg:active {
615
+ cursor: grabbing;
616
+ }
617
+
618
+ .diagram-node-fo {
619
+ cursor: grab;
620
+ overflow: visible;
621
+ }
622
+
623
+ .diagram-node-fo.selected {
624
+ filter: drop-shadow(0 0 4px var(--bs-primary, #0d6efd));
625
+ }
626
+
627
+ .diagram-node-fo:active {
628
+ cursor: grabbing;
629
+ }
630
+
631
+ .area-select-rect {
632
+ fill: rgba(13, 110, 253, 0.1);
633
+ stroke: var(--bs-primary, #0d6efd);
634
+ stroke-width: 1;
635
+ stroke-dasharray: 4 2;
636
+ pointer-events: none;
637
+ }
638
+
639
+ .diagram-edge {
640
+ stroke-dasharray: 6 3;
641
+ pointer-events: none;
642
+ }
643
+
644
+ .diagram-edge.highlighted {
645
+ animation: dash-flow 0.5s linear infinite;
646
+ }
647
+
648
+ .diagram-edge.selected {
649
+ stroke-dasharray: none;
650
+ animation: none;
651
+ }
652
+
653
+ .diagram-edge-hit {
654
+ stroke: transparent;
655
+ stroke-width: 12;
656
+ pointer-events: stroke;
657
+ cursor: pointer;
658
+ }
659
+
660
+ .diagram-edge-label {
661
+ font-size: 9px;
662
+ pointer-events: none;
663
+ user-select: none;
664
+ }
665
+
666
+ .port-dot {
667
+ pointer-events: none;
668
+ }
669
+
670
+ .cardinality-label {
671
+ font-size: 8px;
672
+ font-weight: 600;
673
+ pointer-events: none;
674
+ user-select: none;
675
+ }
676
+
677
+ @keyframes dash-flow {
678
+ to {
679
+ stroke-dashoffset: -9;
680
+ }
681
+ }
682
+
683
+ .seg-handle {
684
+ pointer-events: auto;
685
+ }
686
+
687
+ .seg-handle-h {
688
+ cursor: ns-resize;
689
+ }
690
+
691
+ .seg-handle-v {
692
+ cursor: ew-resize;
693
+ }
694
+
695
+ .seg-handle:hover {
696
+ filter: brightness(1.2);
697
+ }
698
+
699
+ .split-handle {
700
+ pointer-events: auto;
701
+ }
702
+
703
+ .split-handle-h {
704
+ cursor: ns-resize;
705
+ }
706
+
707
+ .split-handle-v {
708
+ cursor: ew-resize;
709
+ }
710
+
711
+ .split-handle:hover {
712
+ fill: color-mix(in srgb, var(--bs-primary, #0d6efd) 15%, var(--bs-body-bg, #fff));
713
+ }
714
+
715
+ .edge-reset-btn {
716
+ cursor: pointer;
717
+ pointer-events: auto;
718
+ }
719
+
720
+ .edge-reset-btn rect {
721
+ fill: var(--bs-body-bg, #fff);
722
+ stroke: var(--bs-border-color, #dee2e6);
723
+ stroke-width: 1;
724
+ }
725
+
726
+ .edge-reset-btn text {
727
+ font-size: 13px;
728
+ fill: var(--bs-secondary-color, #6c757d);
729
+ pointer-events: none;
730
+ user-select: none;
731
+ }
732
+
733
+ .edge-reset-btn:hover rect {
734
+ fill: var(--bs-primary, #0d6efd);
735
+ stroke: var(--bs-primary, #0d6efd);
736
+ }
737
+
738
+ .edge-reset-btn:hover text {
739
+ fill: white;
740
+ }
741
+
742
+ .diagram-controls {
743
+ position: absolute;
744
+ bottom: 12px;
745
+ left: 12px;
746
+ display: flex;
747
+ flex-direction: column;
748
+ gap: 2px;
749
+ background: var(--bs-body-bg, #fff);
750
+ border: 1px solid var(--bs-border-color, #dee2e6);
751
+ border-radius: 6px;
752
+ padding: 4px;
753
+ box-shadow: 0 2px 6px var(--df-shadow-color);
754
+ }
755
+ </style>