@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/EntityNode.svelte +167 -0
- package/dist/EntityNode.svelte.d.ts +8 -0
- package/dist/EntityNode.svelte.d.ts.map +1 -0
- package/dist/IconButton.svelte +83 -0
- package/dist/IconButton.svelte.d.ts +13 -0
- package/dist/IconButton.svelte.d.ts.map +1 -0
- package/dist/SimpleDiagram/DiagramDebug.svelte +499 -0
- package/dist/SimpleDiagram/DiagramDebug.svelte.d.ts +4 -0
- package/dist/SimpleDiagram/DiagramDebug.svelte.d.ts.map +1 -0
- package/dist/SimpleDiagram/ModelDiagram.svelte +569 -0
- package/dist/SimpleDiagram/ModelDiagram.svelte.d.ts +18 -0
- package/dist/SimpleDiagram/ModelDiagram.svelte.d.ts.map +1 -0
- package/dist/SimpleDiagram/NOTE.md +14 -0
- package/dist/SimpleDiagram/SimpleDiagram.svelte +755 -0
- package/dist/SimpleDiagram/SimpleDiagram.svelte.d.ts +13 -0
- package/dist/SimpleDiagram/SimpleDiagram.svelte.d.ts.map +1 -0
- package/dist/SimpleDiagram/diagram-geometry.d.ts +33 -0
- package/dist/SimpleDiagram/diagram-geometry.d.ts.map +1 -0
- package/dist/SimpleDiagram/diagram-geometry.js +137 -0
- package/dist/SimpleDiagram/simple-layout.d.ts +27 -0
- package/dist/SimpleDiagram/simple-layout.d.ts.map +1 -0
- package/dist/SimpleDiagram/simple-layout.js +364 -0
- package/dist/WorkspaceMap/ModuleCard.svelte +165 -0
- package/dist/WorkspaceMap/ModuleCard.svelte.d.ts +8 -0
- package/dist/WorkspaceMap/ModuleCard.svelte.d.ts.map +1 -0
- package/dist/WorkspaceMap/WorkspaceMap.svelte +319 -0
- package/dist/WorkspaceMap/WorkspaceMap.svelte.d.ts +12 -0
- package/dist/WorkspaceMap/WorkspaceMap.svelte.d.ts.map +1 -0
- package/dist/WorkspaceMap/workspace-graph.d.ts +18 -0
- package/dist/WorkspaceMap/workspace-graph.d.ts.map +1 -0
- package/dist/WorkspaceMap/workspace-graph.js +168 -0
- package/dist/WorkspaceMap/workspace-types.d.ts +32 -0
- package/dist/WorkspaceMap/workspace-types.d.ts.map +1 -0
- package/dist/WorkspaceMap/workspace-types.js +1 -0
- package/dist/diagram-types.d.ts +47 -0
- package/dist/diagram-types.d.ts.map +1 -0
- package/dist/diagram-types.js +2 -0
- package/dist/edge-routing.d.ts +82 -0
- package/dist/edge-routing.d.ts.map +1 -0
- package/dist/edge-routing.js +388 -0
- package/dist/i18n.d.ts +13 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +20 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/local-storage-sync.d.ts +10 -0
- package/dist/local-storage-sync.d.ts.map +1 -0
- package/dist/local-storage-sync.js +39 -0
- package/dist/model-graph.d.ts +20 -0
- package/dist/model-graph.d.ts.map +1 -0
- package/dist/model-graph.js +100 -0
- package/dist/model-types.d.ts +55 -0
- package/dist/model-types.d.ts.map +1 -0
- package/dist/model-types.js +11 -0
- package/dist/pan-zoom.svelte.d.ts +34 -0
- package/dist/pan-zoom.svelte.d.ts.map +1 -0
- package/dist/pan-zoom.svelte.js +130 -0
- 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">↺</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>
|