@memberjunction/ng-entity-relationship-diagram 0.0.1 → 2.130.0

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 (38) hide show
  1. package/README.md +754 -27
  2. package/dist/lib/components/entity-details/entity-details.component.d.ts +70 -0
  3. package/dist/lib/components/entity-details/entity-details.component.d.ts.map +1 -0
  4. package/dist/lib/components/entity-details/entity-details.component.js +694 -0
  5. package/dist/lib/components/entity-details/entity-details.component.js.map +1 -0
  6. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts +48 -0
  7. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts.map +1 -0
  8. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js +179 -0
  9. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js.map +1 -0
  10. package/dist/lib/components/erd-composite/erd-composite.component.d.ts +170 -0
  11. package/dist/lib/components/erd-composite/erd-composite.component.d.ts.map +1 -0
  12. package/dist/lib/components/erd-composite/erd-composite.component.js +391 -0
  13. package/dist/lib/components/erd-composite/erd-composite.component.js.map +1 -0
  14. package/dist/lib/components/erd-diagram.component.d.ts +486 -0
  15. package/dist/lib/components/erd-diagram.component.d.ts.map +1 -0
  16. package/dist/lib/components/erd-diagram.component.js +1936 -0
  17. package/dist/lib/components/erd-diagram.component.js.map +1 -0
  18. package/dist/lib/components/mj-entity-erd.component.d.ts +261 -0
  19. package/dist/lib/components/mj-entity-erd.component.d.ts.map +1 -0
  20. package/dist/lib/components/mj-entity-erd.component.js +416 -0
  21. package/dist/lib/components/mj-entity-erd.component.js.map +1 -0
  22. package/dist/lib/entity-relationship-diagram.module.d.ts +83 -0
  23. package/dist/lib/entity-relationship-diagram.module.d.ts.map +1 -0
  24. package/dist/lib/entity-relationship-diagram.module.js +121 -0
  25. package/dist/lib/entity-relationship-diagram.module.js.map +1 -0
  26. package/dist/lib/interfaces/erd-types.d.ts +491 -0
  27. package/dist/lib/interfaces/erd-types.d.ts.map +1 -0
  28. package/dist/lib/interfaces/erd-types.js +6 -0
  29. package/dist/lib/interfaces/erd-types.js.map +1 -0
  30. package/dist/lib/utils/entity-to-erd-adapter.d.ts +64 -0
  31. package/dist/lib/utils/entity-to-erd-adapter.d.ts.map +1 -0
  32. package/dist/lib/utils/entity-to-erd-adapter.js +231 -0
  33. package/dist/lib/utils/entity-to-erd-adapter.js.map +1 -0
  34. package/dist/public-api.d.ts +9 -0
  35. package/dist/public-api.d.ts.map +1 -0
  36. package/dist/public-api.js +16 -0
  37. package/dist/public-api.js.map +1 -0
  38. package/package.json +45 -6
@@ -0,0 +1,1936 @@
1
+ import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core';
2
+ import * as d3 from 'd3';
3
+ import * as dagre from '@dagrejs/dagre';
4
+ import * as i0 from "@angular/core";
5
+ const _c0 = ["erdContainer"];
6
+ function ERDDiagramComponent_Conditional_1_Template(rf, ctx) { if (rf & 1) {
7
+ const _r1 = i0.ɵɵgetCurrentView();
8
+ i0.ɵɵelementStart(0, "div", 2)(1, "h3");
9
+ i0.ɵɵtext(2);
10
+ i0.ɵɵelementEnd();
11
+ i0.ɵɵelementStart(3, "div", 5)(4, "button", 6);
12
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_4_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.zoomIn()); });
13
+ i0.ɵɵelement(5, "span", 7);
14
+ i0.ɵɵelementEnd();
15
+ i0.ɵɵelementStart(6, "button", 8);
16
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_6_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.zoomOut()); });
17
+ i0.ɵɵelement(7, "span", 9);
18
+ i0.ɵɵelementEnd();
19
+ i0.ɵɵelementStart(8, "button", 10);
20
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_8_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.resetZoom()); });
21
+ i0.ɵɵelement(9, "span", 11);
22
+ i0.ɵɵelementEnd();
23
+ i0.ɵɵelementStart(10, "button", 12);
24
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_10_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); ctx_r1.refresh(); return i0.ɵɵresetView(ctx_r1.refreshRequested.emit()); });
25
+ i0.ɵɵelement(11, "span", 13);
26
+ i0.ɵɵelementEnd()()();
27
+ } if (rf & 2) {
28
+ const ctx_r1 = i0.ɵɵnextContext();
29
+ i0.ɵɵadvance(2);
30
+ i0.ɵɵtextInterpolate(ctx_r1.headerTitle);
31
+ } }
32
+ function ERDDiagramComponent_Conditional_4_Template(rf, ctx) { if (rf & 1) {
33
+ i0.ɵɵelementStart(0, "div", 4)(1, "div", 14)(2, "div", 15);
34
+ i0.ɵɵelement(3, "div", 16)(4, "div", 16)(5, "div", 16);
35
+ i0.ɵɵelementEnd();
36
+ i0.ɵɵelementStart(6, "div", 17);
37
+ i0.ɵɵtext(7, "Loading diagram...");
38
+ i0.ɵɵelementEnd()()();
39
+ } }
40
+ /**
41
+ * Default configuration values for the ERD diagram.
42
+ * These values provide a good starting point for most use cases.
43
+ */
44
+ /** Default dagre configuration */
45
+ const DEFAULT_DAGRE_CONFIG = {
46
+ rankDir: 'LR',
47
+ nodeSep: 80, // Increased from 50 - more vertical space between nodes in same rank
48
+ rankSep: 150, // Increased from 100 - more horizontal space between ranks for labels
49
+ edgeSep: 20, // Increased from 10 - more space between edge paths
50
+ ranker: 'network-simplex',
51
+ align: undefined
52
+ };
53
+ const DEFAULT_CONFIG = {
54
+ nodeWidth: 180,
55
+ nodeBaseHeight: 60,
56
+ fieldHeight: 20,
57
+ maxNodeHeight: 300,
58
+ chargeStrength: -800,
59
+ linkDistance: 80,
60
+ collisionPadding: 20,
61
+ showFieldDetails: true,
62
+ showRelationshipLabels: true,
63
+ showHeader: true,
64
+ showNodeCount: true,
65
+ showMinimap: false,
66
+ showLegend: false,
67
+ enableDragging: true,
68
+ enableZoom: true,
69
+ enablePan: true,
70
+ minZoom: 0.1,
71
+ maxZoom: 4,
72
+ initialZoom: 1,
73
+ enableMultiSelect: false,
74
+ animationDuration: 750,
75
+ fitOnLoad: true,
76
+ skipAnimation: false,
77
+ emptyStateMessage: 'No entities to display',
78
+ emptyStateIcon: 'fa-solid fa-diagram-project',
79
+ layoutAlgorithm: 'dagre',
80
+ dagreConfig: DEFAULT_DAGRE_CONFIG,
81
+ colors: {
82
+ nodeBackground: '#f8f9fa',
83
+ nodeBorder: '#333',
84
+ nodeHeader: '#007bff',
85
+ nodeHeaderText: 'white',
86
+ primaryKeyBackground: '#fff3cd',
87
+ primaryKeyText: '#856404',
88
+ foreignKeyBackground: '#cce5ff',
89
+ foreignKeyText: '#004085',
90
+ linkColor: '#666',
91
+ selectedBorder: '#4CAF50',
92
+ highlightBorder: '#ff9800',
93
+ relatedBorder: '#ff6b35'
94
+ }
95
+ };
96
+ /**
97
+ * Entity Relationship Diagram component using D3.js force-directed graph.
98
+ *
99
+ * This is a **generic, reusable Angular component** that can visualize any data
100
+ * that conforms to the `ERDNode` interface. It is completely decoupled from
101
+ * MemberJunction-specific types and can be used in any Angular project.
102
+ *
103
+ * ## Features
104
+ *
105
+ * - **Interactive Force-Directed Layout**: Nodes automatically arrange themselves
106
+ * using physics-based simulation with customizable forces
107
+ * - **Zoom and Pan**: Mouse wheel zoom and drag-to-pan with configurable limits
108
+ * - **Node Selection**: Click to select nodes with visual highlighting
109
+ * - **Focus Mode**: Show a single node with its N-hop related entities
110
+ * - **Relationship Visualization**: Arrows show foreign key relationships
111
+ * - **Customizable Styling**: Full control over colors, sizes, and appearance
112
+ * - **Rich Event System**: Comprehensive events for integration with parent components
113
+ * - **State Persistence**: Save and restore diagram state (zoom, pan, selection, positions)
114
+ *
115
+ * ## Basic Usage
116
+ *
117
+ * ```html
118
+ * <mj-erd-diagram
119
+ * [nodes]="entityNodes"
120
+ * [selectedNodeId]="selectedId"
121
+ * [config]="erdConfig"
122
+ * (nodeClick)="onNodeClick($event)"
123
+ * (nodeSelected)="onNodeSelected($event)"
124
+ * (zoomChange)="onZoomChange($event)">
125
+ * </mj-erd-diagram>
126
+ * ```
127
+ *
128
+ * ## Focus Mode Example
129
+ *
130
+ * Show a single entity with its immediate relationships:
131
+ *
132
+ * ```html
133
+ * <mj-erd-diagram
134
+ * [nodes]="allNodes"
135
+ * [focusNodeId]="'user-entity-id'"
136
+ * [focusDepth]="1"
137
+ * (nodeSelected)="onNodeSelected($event)">
138
+ * </mj-erd-diagram>
139
+ * ```
140
+ *
141
+ * ## State Persistence Example
142
+ *
143
+ * ```typescript
144
+ * // Save state
145
+ * const state = this.erdDiagram.getState();
146
+ * localStorage.setItem('erd-state', JSON.stringify(state));
147
+ *
148
+ * // Restore state
149
+ * const savedState = JSON.parse(localStorage.getItem('erd-state'));
150
+ * this.erdDiagram.setState(savedState);
151
+ * ```
152
+ *
153
+ * @see ERDNode for the node data structure
154
+ * @see ERDConfig for configuration options
155
+ * @see ERDState for state persistence
156
+ */
157
+ export class ERDDiagramComponent {
158
+ erdContainer;
159
+ // ============================================================================
160
+ // INPUTS - Data
161
+ // ============================================================================
162
+ /**
163
+ * The nodes (entities) to display in the diagram.
164
+ * Each node should conform to the `ERDNode` interface.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * nodes: ERDNode[] = [
169
+ * {
170
+ * id: 'user-1',
171
+ * name: 'Users',
172
+ * fields: [
173
+ * { id: 'id', name: 'ID', isPrimaryKey: true },
174
+ * { id: 'email', name: 'Email', isPrimaryKey: false }
175
+ * ]
176
+ * }
177
+ * ];
178
+ * ```
179
+ */
180
+ nodes = [];
181
+ /**
182
+ * ID of the currently selected node.
183
+ * Use this for external control of selection state.
184
+ * The component will highlight this node and emit selection events accordingly.
185
+ *
186
+ * @example `[selectedNodeId]="'entity-123'"`
187
+ */
188
+ selectedNodeId = null;
189
+ /**
190
+ * IDs of nodes to highlight (in addition to selected node).
191
+ * Highlighted nodes receive a distinct visual style (orange border by default).
192
+ * Useful for showing search results or related items.
193
+ *
194
+ * @example `[highlightedNodeIds]="['entity-1', 'entity-2']"`
195
+ */
196
+ highlightedNodeIds = [];
197
+ /**
198
+ * ID of the node to focus on.
199
+ * When set, the diagram will only show this node and its related nodes
200
+ * up to `focusDepth` hops away. This is useful for entity-centric views.
201
+ *
202
+ * Set to `null` to show all nodes.
203
+ *
204
+ * @example `[focusNodeId]="'user-entity-id'"`
205
+ */
206
+ focusNodeId = null;
207
+ /**
208
+ * Number of relationship hops to include when in focus mode.
209
+ * - `0`: Show only the focus node
210
+ * - `1`: Show focus node + directly related nodes
211
+ * - `2`: Show focus node + nodes within 2 relationship hops
212
+ *
213
+ * Only applies when `focusNodeId` is set.
214
+ *
215
+ * @default 1
216
+ * @example `[focusDepth]="2"`
217
+ */
218
+ focusDepth = 1;
219
+ // ============================================================================
220
+ // INPUTS - State
221
+ // ============================================================================
222
+ /**
223
+ * Whether the diagram is currently refreshing/loading.
224
+ * When `true`, displays a loading overlay on the diagram.
225
+ *
226
+ * @example `[isRefreshing]="isLoadingData"`
227
+ */
228
+ isRefreshing = false;
229
+ /**
230
+ * Whether the diagram is in read-only mode.
231
+ * When `true`, disables dragging and selection (zoom/pan still work).
232
+ *
233
+ * @default false
234
+ */
235
+ readOnly = false;
236
+ // ============================================================================
237
+ // INPUTS - Configuration
238
+ // ============================================================================
239
+ /**
240
+ * Configuration options for the diagram.
241
+ * All options are optional with sensible defaults.
242
+ *
243
+ * @see ERDConfig for all available options
244
+ * @example
245
+ * ```typescript
246
+ * config: ERDConfig = {
247
+ * nodeWidth: 200,
248
+ * chargeStrength: -1000,
249
+ * colors: {
250
+ * nodeHeader: '#0066cc'
251
+ * }
252
+ * };
253
+ * ```
254
+ */
255
+ config = {};
256
+ /**
257
+ * Whether to show the header bar with zoom controls.
258
+ * Set to `false` to hide the header and use programmatic control instead.
259
+ *
260
+ * @default true
261
+ */
262
+ showHeader = true;
263
+ /**
264
+ * Title to display in the header bar.
265
+ * Only visible when `showHeader` is `true`.
266
+ *
267
+ * @default 'Entity Relationship Diagram'
268
+ */
269
+ headerTitle = 'Entity Relationship Diagram';
270
+ // ============================================================================
271
+ // OUTPUTS - Selection Events
272
+ // ============================================================================
273
+ /**
274
+ * Emitted when a node is clicked.
275
+ * Set `event.cancel = true` to prevent the default selection behavior.
276
+ *
277
+ * @example
278
+ * ```typescript
279
+ * onNodeClick(event: ERDNodeClickEvent) {
280
+ * console.log('Clicked:', event.node.name);
281
+ * // Prevent selection for certain nodes
282
+ * if (event.node.customData?.['locked']) {
283
+ * event.cancel = true;
284
+ * }
285
+ * }
286
+ * ```
287
+ */
288
+ nodeClick = new EventEmitter();
289
+ /**
290
+ * Emitted when a node is double-clicked.
291
+ * Useful for opening detail views or triggering actions.
292
+ *
293
+ * @example
294
+ * ```typescript
295
+ * onNodeDoubleClick(event: ERDNodeDoubleClickEvent) {
296
+ * this.openEntityDetail(event.node.id);
297
+ * }
298
+ * ```
299
+ */
300
+ nodeDoubleClick = new EventEmitter();
301
+ /**
302
+ * Emitted when a node is selected (after click, unless cancelled).
303
+ * The node parameter contains the full `ERDNode` data.
304
+ */
305
+ nodeSelected = new EventEmitter();
306
+ /**
307
+ * Emitted when the current node is deselected.
308
+ * This happens when clicking the background or selecting a different node.
309
+ */
310
+ nodeDeselected = new EventEmitter();
311
+ /**
312
+ * Emitted when a relationship link is clicked.
313
+ * The event includes both source and target nodes for context.
314
+ */
315
+ linkClick = new EventEmitter();
316
+ // ============================================================================
317
+ // OUTPUTS - Hover Events
318
+ // ============================================================================
319
+ /**
320
+ * Emitted when the mouse enters a node.
321
+ * Includes related nodes and position for tooltip display.
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * onNodeHover(event: ERDNodeHoverEvent) {
326
+ * this.showTooltip(event.node, event.position);
327
+ * }
328
+ * ```
329
+ */
330
+ nodeHover = new EventEmitter();
331
+ /**
332
+ * Emitted when the mouse leaves a node.
333
+ */
334
+ nodeHoverEnd = new EventEmitter();
335
+ /**
336
+ * Emitted when the mouse enters a relationship link.
337
+ */
338
+ linkHover = new EventEmitter();
339
+ /**
340
+ * Emitted when the mouse leaves a relationship link.
341
+ */
342
+ linkHoverEnd = new EventEmitter();
343
+ // ============================================================================
344
+ // OUTPUTS - Context Menu Events
345
+ // ============================================================================
346
+ /**
347
+ * Emitted when a node is right-clicked.
348
+ * Set `event.cancel = true` to prevent the default browser context menu.
349
+ *
350
+ * @example
351
+ * ```typescript
352
+ * onNodeContextMenu(event: ERDNodeContextMenuEvent) {
353
+ * event.cancel = true;
354
+ * this.showContextMenu(event.node, event.position);
355
+ * }
356
+ * ```
357
+ */
358
+ nodeContextMenu = new EventEmitter();
359
+ /**
360
+ * Emitted when a relationship link is right-clicked.
361
+ */
362
+ linkContextMenu = new EventEmitter();
363
+ /**
364
+ * Emitted when the diagram background is right-clicked.
365
+ * Includes diagram coordinates for adding new nodes at click position.
366
+ */
367
+ diagramContextMenu = new EventEmitter();
368
+ // ============================================================================
369
+ // OUTPUTS - Drag Events
370
+ // ============================================================================
371
+ /**
372
+ * Emitted when a node drag operation starts.
373
+ * Set `event.cancel = true` to prevent the drag.
374
+ */
375
+ nodeDragStart = new EventEmitter();
376
+ /**
377
+ * Emitted when a node drag operation ends.
378
+ */
379
+ nodeDragEnd = new EventEmitter();
380
+ // ============================================================================
381
+ // OUTPUTS - Diagram Events
382
+ // ============================================================================
383
+ /**
384
+ * Emitted when zoom level or pan position changes.
385
+ * Useful for synchronizing zoom controls or saving view state.
386
+ */
387
+ zoomChange = new EventEmitter();
388
+ /**
389
+ * Emitted when the diagram requests a refresh.
390
+ * Triggered by the refresh button in the header.
391
+ */
392
+ refreshRequested = new EventEmitter();
393
+ /**
394
+ * Emitted when the force simulation completes initial layout.
395
+ * Useful for performing actions after nodes have settled.
396
+ */
397
+ layoutComplete = new EventEmitter();
398
+ /**
399
+ * Emitted when diagram state changes (selection, zoom, highlights).
400
+ * Useful for auto-saving state or syncing with other components.
401
+ */
402
+ stateChange = new EventEmitter();
403
+ // ============================================================================
404
+ // PRIVATE STATE
405
+ // ============================================================================
406
+ svg = null;
407
+ simulation = null;
408
+ internalNodes = [];
409
+ internalLinks = [];
410
+ visibleNodes = [];
411
+ visibleLinks = [];
412
+ zoom = null;
413
+ resizeObserver;
414
+ lastKnownSize = { width: 0, height: 0 };
415
+ resizeTimeout;
416
+ mergedConfig = DEFAULT_CONFIG;
417
+ layoutCompleted = false;
418
+ isLayoutFrozen = false;
419
+ // ============================================================================
420
+ // LIFECYCLE HOOKS
421
+ // ============================================================================
422
+ ngAfterViewInit() {
423
+ setTimeout(() => {
424
+ this.setupERD();
425
+ }, 100);
426
+ this.setupResizeObserver();
427
+ }
428
+ ngOnChanges(changes) {
429
+ if (changes['nodes'] && !changes['nodes'].firstChange) {
430
+ this.setupERD();
431
+ }
432
+ if (changes['selectedNodeId'] && !changes['selectedNodeId'].firstChange) {
433
+ this.updateSelectionHighlighting();
434
+ this.emitStateChange();
435
+ }
436
+ if (changes['highlightedNodeIds'] && !changes['highlightedNodeIds'].firstChange) {
437
+ this.updateHighlighting();
438
+ this.emitStateChange();
439
+ }
440
+ if (changes['focusNodeId'] && !changes['focusNodeId'].firstChange) {
441
+ this.setupERD();
442
+ this.emitStateChange();
443
+ }
444
+ if (changes['focusDepth'] && !changes['focusDepth'].firstChange) {
445
+ if (this.focusNodeId) {
446
+ this.setupERD();
447
+ this.emitStateChange();
448
+ }
449
+ }
450
+ if (changes['config'] && !changes['config'].firstChange) {
451
+ this.mergeConfig();
452
+ this.setupERD();
453
+ }
454
+ }
455
+ ngOnDestroy() {
456
+ if (this.simulation) {
457
+ this.simulation.stop();
458
+ }
459
+ if (this.resizeObserver) {
460
+ this.resizeObserver.disconnect();
461
+ }
462
+ if (this.resizeTimeout) {
463
+ clearTimeout(this.resizeTimeout);
464
+ }
465
+ }
466
+ // ============================================================================
467
+ // PUBLIC API - Zoom Control
468
+ // ============================================================================
469
+ /**
470
+ * Zoom in on the diagram by a fixed factor (1.2x).
471
+ * Uses smooth animation transition.
472
+ */
473
+ zoomIn() {
474
+ if (this.zoom && this.svg) {
475
+ this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.2);
476
+ }
477
+ }
478
+ /**
479
+ * Zoom out on the diagram by a fixed factor (0.83x).
480
+ * Uses smooth animation transition.
481
+ */
482
+ zoomOut() {
483
+ if (this.zoom && this.svg) {
484
+ this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.83);
485
+ }
486
+ }
487
+ /**
488
+ * Reset zoom to default level (1x) and center the diagram.
489
+ * Uses smooth animation transition.
490
+ */
491
+ resetZoom() {
492
+ if (this.zoom && this.svg) {
493
+ this.svg.transition().duration(500).call(this.zoom.transform, d3.zoomIdentity);
494
+ }
495
+ }
496
+ /**
497
+ * Zoom and pan to center on a specific node.
498
+ *
499
+ * @param nodeId - ID of the node to center on
500
+ * @param scale - Optional zoom scale (default: 1.5)
501
+ *
502
+ * @example
503
+ * ```typescript
504
+ * // Center on node with default zoom
505
+ * this.erdDiagram.zoomToNode('user-entity-id');
506
+ *
507
+ * // Center with custom zoom level
508
+ * this.erdDiagram.zoomToNode('user-entity-id', 2.0);
509
+ * ```
510
+ */
511
+ zoomToNode(nodeId, scale = 1.5) {
512
+ const node = this.internalNodes.find(n => n.id === nodeId);
513
+ if (!node || !this.zoom || !this.svg)
514
+ return;
515
+ const container = this.erdContainer.nativeElement;
516
+ const width = container.clientWidth;
517
+ const height = container.clientHeight;
518
+ const x = width / 2 - (node.x || 0) * scale;
519
+ const y = height / 2 - (node.y || 0) * scale;
520
+ this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale));
521
+ }
522
+ /**
523
+ * Fit all visible nodes within the viewport.
524
+ * Calculates optimal zoom level and position to show all nodes.
525
+ *
526
+ * @param padding - Padding around nodes (default: 50px)
527
+ */
528
+ zoomToFit(padding = 50) {
529
+ if (!this.svg || !this.zoom || this.visibleNodes.length === 0)
530
+ return;
531
+ const container = this.erdContainer.nativeElement;
532
+ const width = container.clientWidth;
533
+ const height = container.clientHeight;
534
+ // Guard against zero container dimensions (container not laid out yet)
535
+ if (width === 0 || height === 0)
536
+ return;
537
+ // Calculate bounds
538
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
539
+ this.visibleNodes.forEach(node => {
540
+ const halfWidth = node.width / 2;
541
+ const halfHeight = node.height / 2;
542
+ minX = Math.min(minX, (node.x || 0) - halfWidth);
543
+ minY = Math.min(minY, (node.y || 0) - halfHeight);
544
+ maxX = Math.max(maxX, (node.x || 0) + halfWidth);
545
+ maxY = Math.max(maxY, (node.y || 0) + halfHeight);
546
+ });
547
+ // Guard against invalid bounds (nodes without valid positions)
548
+ if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY))
549
+ return;
550
+ const boundsWidth = maxX - minX + padding * 2;
551
+ const boundsHeight = maxY - minY + padding * 2;
552
+ // Guard against zero-dimension bounds
553
+ if (boundsWidth <= 0 || boundsHeight <= 0)
554
+ return;
555
+ const centerX = (minX + maxX) / 2;
556
+ const centerY = (minY + maxY) / 2;
557
+ const scale = Math.min(width / boundsWidth, height / boundsHeight, this.mergedConfig.maxZoom);
558
+ // Final sanity check before applying transform
559
+ if (!isFinite(scale) || scale <= 0)
560
+ return;
561
+ const translateX = width / 2 - centerX * scale;
562
+ const translateY = height / 2 - centerY * scale;
563
+ this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale));
564
+ }
565
+ // ============================================================================
566
+ // PUBLIC API - Selection
567
+ // ============================================================================
568
+ /**
569
+ * Programmatically select a node by ID.
570
+ *
571
+ * @param nodeId - ID of the node to select
572
+ * @returns `true` if node was found and selected, `false` otherwise
573
+ */
574
+ selectNode(nodeId) {
575
+ const node = this.nodes.find(n => n.id === nodeId);
576
+ if (node) {
577
+ this.nodeSelected.emit(node);
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+ /**
583
+ * Clear all selections.
584
+ */
585
+ deselectAll() {
586
+ this.nodeDeselected.emit();
587
+ this.clearAllHighlighting();
588
+ }
589
+ // ============================================================================
590
+ // PUBLIC API - Highlighting
591
+ // ============================================================================
592
+ /**
593
+ * Highlight a specific node.
594
+ *
595
+ * @param nodeId - ID of the node to highlight
596
+ */
597
+ highlightNode(nodeId) {
598
+ if (!this.highlightedNodeIds.includes(nodeId)) {
599
+ this.highlightedNodeIds = [...this.highlightedNodeIds, nodeId];
600
+ this.updateHighlighting();
601
+ this.emitStateChange();
602
+ }
603
+ }
604
+ /**
605
+ * Clear all highlights.
606
+ */
607
+ clearHighlights() {
608
+ this.highlightedNodeIds = [];
609
+ this.updateHighlighting();
610
+ this.emitStateChange();
611
+ }
612
+ /**
613
+ * Highlight a node and all its directly related nodes.
614
+ *
615
+ * @param nodeId - ID of the center node
616
+ * @param depth - Number of relationship hops to highlight (default: 1)
617
+ */
618
+ highlightRelated(nodeId, depth = 1) {
619
+ const relatedIds = this.getRelatedNodeIds(nodeId, depth);
620
+ this.highlightedNodeIds = [nodeId, ...relatedIds];
621
+ this.updateHighlighting();
622
+ this.emitStateChange();
623
+ }
624
+ /**
625
+ * Get nodes that are related to a specific node.
626
+ *
627
+ * @param nodeId - ID of the node to find relationships for
628
+ * @param depth - Number of relationship hops (default: 1)
629
+ * @returns Array of relationship information
630
+ */
631
+ getRelatedNodes(nodeId, depth = 1) {
632
+ const result = [];
633
+ const visited = new Set([nodeId]);
634
+ const toProcess = [{ id: nodeId, currentDepth: 0 }];
635
+ while (toProcess.length > 0) {
636
+ const current = toProcess.shift();
637
+ if (current.currentDepth >= depth)
638
+ continue;
639
+ // Find outgoing relationships
640
+ this.nodes.forEach(node => {
641
+ node.fields.forEach(field => {
642
+ if (field.relatedNodeId) {
643
+ if (node.id === current.id && !visited.has(field.relatedNodeId)) {
644
+ const targetNode = this.nodes.find(n => n.id === field.relatedNodeId);
645
+ if (targetNode) {
646
+ visited.add(field.relatedNodeId);
647
+ toProcess.push({ id: field.relatedNodeId, currentDepth: current.currentDepth + 1 });
648
+ result.push({
649
+ node: targetNode,
650
+ link: {
651
+ sourceNodeId: node.id,
652
+ targetNodeId: field.relatedNodeId,
653
+ sourceField: field,
654
+ isSelfReference: node.id === field.relatedNodeId
655
+ },
656
+ direction: 'outgoing',
657
+ field
658
+ });
659
+ }
660
+ }
661
+ // Incoming relationships
662
+ if (field.relatedNodeId === current.id && !visited.has(node.id)) {
663
+ visited.add(node.id);
664
+ toProcess.push({ id: node.id, currentDepth: current.currentDepth + 1 });
665
+ result.push({
666
+ node,
667
+ link: {
668
+ sourceNodeId: node.id,
669
+ targetNodeId: field.relatedNodeId,
670
+ sourceField: field,
671
+ isSelfReference: node.id === field.relatedNodeId
672
+ },
673
+ direction: 'incoming',
674
+ field
675
+ });
676
+ }
677
+ }
678
+ });
679
+ });
680
+ }
681
+ return result;
682
+ }
683
+ // ============================================================================
684
+ // PUBLIC API - State Management
685
+ // ============================================================================
686
+ /**
687
+ * Get the current state of the diagram for persistence.
688
+ * Includes selection, highlights, zoom, pan, and node positions.
689
+ *
690
+ * @returns Current diagram state
691
+ *
692
+ * @example
693
+ * ```typescript
694
+ * // Save to localStorage
695
+ * const state = erdDiagram.getState();
696
+ * localStorage.setItem('erd-state', JSON.stringify(state));
697
+ * ```
698
+ */
699
+ getState() {
700
+ const zoomState = this.getZoomState();
701
+ const nodePositions = {};
702
+ this.internalNodes.forEach(node => {
703
+ nodePositions[node.id] = {
704
+ x: node.x || 0,
705
+ y: node.y || 0,
706
+ fx: node.fx,
707
+ fy: node.fy
708
+ };
709
+ });
710
+ return {
711
+ selectedNodeId: this.selectedNodeId,
712
+ highlightedNodeIds: [...this.highlightedNodeIds],
713
+ zoomLevel: zoomState.zoomLevel,
714
+ translateX: zoomState.translateX,
715
+ translateY: zoomState.translateY,
716
+ focusNodeId: this.focusNodeId,
717
+ focusDepth: this.focusDepth,
718
+ nodePositions
719
+ };
720
+ }
721
+ /**
722
+ * Restore a previously saved diagram state.
723
+ *
724
+ * @param state - State object from `getState()`
725
+ * @param restorePositions - Whether to restore node positions (default: true)
726
+ *
727
+ * @example
728
+ * ```typescript
729
+ * // Restore from localStorage
730
+ * const savedState = JSON.parse(localStorage.getItem('erd-state'));
731
+ * if (savedState) {
732
+ * erdDiagram.setState(savedState);
733
+ * }
734
+ * ```
735
+ */
736
+ setState(state, restorePositions = true) {
737
+ if (state.highlightedNodeIds) {
738
+ this.highlightedNodeIds = [...state.highlightedNodeIds];
739
+ }
740
+ // Restore zoom/pan
741
+ if (this.svg && this.zoom && state.zoomLevel != null) {
742
+ this.svg.call(this.zoom.transform, d3.zoomIdentity
743
+ .translate(state.translateX || 0, state.translateY || 0)
744
+ .scale(state.zoomLevel));
745
+ }
746
+ // Restore node positions
747
+ if (restorePositions && state.nodePositions) {
748
+ this.internalNodes.forEach(node => {
749
+ const savedPos = state.nodePositions?.[node.id];
750
+ if (savedPos) {
751
+ node.x = savedPos.x;
752
+ node.y = savedPos.y;
753
+ node.fx = savedPos.fx;
754
+ node.fy = savedPos.fy;
755
+ }
756
+ });
757
+ // Restart simulation briefly to update display
758
+ if (this.simulation) {
759
+ this.simulation.alpha(0.1).restart();
760
+ }
761
+ }
762
+ this.updateSelectionHighlighting();
763
+ this.updateHighlighting();
764
+ }
765
+ // ============================================================================
766
+ // PUBLIC API - Layout Control
767
+ // ============================================================================
768
+ /**
769
+ * Freeze the layout simulation.
770
+ * Nodes will stay in their current positions and won't move.
771
+ */
772
+ freezeLayout() {
773
+ this.isLayoutFrozen = true;
774
+ if (this.simulation) {
775
+ this.simulation.stop();
776
+ // Fix all node positions
777
+ this.internalNodes.forEach(node => {
778
+ node.fx = node.x;
779
+ node.fy = node.y;
780
+ });
781
+ }
782
+ }
783
+ /**
784
+ * Unfreeze the layout simulation.
785
+ * Nodes will resume natural movement based on forces.
786
+ */
787
+ unfreezeLayout() {
788
+ this.isLayoutFrozen = false;
789
+ // Unfix all node positions
790
+ this.internalNodes.forEach(node => {
791
+ node.fx = null;
792
+ node.fy = null;
793
+ });
794
+ if (this.simulation) {
795
+ this.simulation.alpha(0.3).restart();
796
+ }
797
+ }
798
+ /**
799
+ * Center the diagram without changing zoom level.
800
+ */
801
+ centerDiagram() {
802
+ if (!this.svg || !this.zoom || this.visibleNodes.length === 0)
803
+ return;
804
+ const container = this.erdContainer.nativeElement;
805
+ const width = container.clientWidth;
806
+ const height = container.clientHeight;
807
+ // Calculate center of all nodes
808
+ let sumX = 0, sumY = 0;
809
+ this.visibleNodes.forEach(node => {
810
+ sumX += node.x || 0;
811
+ sumY += node.y || 0;
812
+ });
813
+ const centerX = sumX / this.visibleNodes.length;
814
+ const centerY = sumY / this.visibleNodes.length;
815
+ const currentTransform = d3.zoomTransform(this.svg.node());
816
+ const translateX = width / 2 - centerX * currentTransform.k;
817
+ const translateY = height / 2 - centerY * currentTransform.k;
818
+ this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(currentTransform.k));
819
+ }
820
+ // ============================================================================
821
+ // PUBLIC API - Utilities
822
+ // ============================================================================
823
+ /**
824
+ * Rebuild the entire ERD visualization.
825
+ */
826
+ refresh() {
827
+ this.setupERD();
828
+ }
829
+ /**
830
+ * Trigger a resize recalculation.
831
+ * Call this when the container size changes.
832
+ */
833
+ triggerResize() {
834
+ if (this.resizeTimeout) {
835
+ clearTimeout(this.resizeTimeout);
836
+ }
837
+ this.resizeTimeout = window.setTimeout(() => {
838
+ this.resizeSVG();
839
+ }, 100);
840
+ }
841
+ /**
842
+ * Get current zoom state.
843
+ *
844
+ * @returns Current zoom level and translation
845
+ */
846
+ getZoomState() {
847
+ if (!this.svg) {
848
+ return { zoomLevel: 1, translateX: 0, translateY: 0 };
849
+ }
850
+ const transform = d3.zoomTransform(this.svg.node());
851
+ return {
852
+ zoomLevel: transform.k,
853
+ translateX: transform.x,
854
+ translateY: transform.y
855
+ };
856
+ }
857
+ /**
858
+ * Export the diagram as an SVG string.
859
+ *
860
+ * @returns SVG markup string
861
+ */
862
+ exportAsSVG() {
863
+ if (!this.svg)
864
+ return '';
865
+ const svgNode = this.svg.node();
866
+ if (!svgNode)
867
+ return '';
868
+ const serializer = new XMLSerializer();
869
+ return serializer.serializeToString(svgNode);
870
+ }
871
+ // ============================================================================
872
+ // SETUP METHODS
873
+ // ============================================================================
874
+ setupERD() {
875
+ if (!this.erdContainer?.nativeElement) {
876
+ return;
877
+ }
878
+ this.layoutCompleted = false;
879
+ this.mergeConfig();
880
+ this.clearVisualization();
881
+ this.createInternalNodes();
882
+ this.createInternalLinks();
883
+ this.applyFocusMode();
884
+ this.createVisualization();
885
+ }
886
+ mergeConfig() {
887
+ this.mergedConfig = {
888
+ ...DEFAULT_CONFIG,
889
+ ...this.config,
890
+ colors: {
891
+ ...DEFAULT_CONFIG.colors,
892
+ ...this.config.colors
893
+ },
894
+ dagreConfig: {
895
+ ...DEFAULT_DAGRE_CONFIG,
896
+ ...this.config.dagreConfig
897
+ }
898
+ };
899
+ // Map 'horizontal' and 'vertical' layout algorithms to dagre with appropriate direction
900
+ if (this.mergedConfig.layoutAlgorithm === 'horizontal') {
901
+ this.mergedConfig.dagreConfig.rankDir = 'LR';
902
+ }
903
+ else if (this.mergedConfig.layoutAlgorithm === 'vertical') {
904
+ this.mergedConfig.dagreConfig.rankDir = 'TB';
905
+ }
906
+ }
907
+ resizeSVG() {
908
+ if (!this.svg || !this.erdContainer?.nativeElement) {
909
+ return;
910
+ }
911
+ const container = this.erdContainer.nativeElement;
912
+ const width = container.clientWidth || 800;
913
+ const height = container.clientHeight || 600;
914
+ if (Math.abs(this.lastKnownSize.width - width) < 5 &&
915
+ Math.abs(this.lastKnownSize.height - height) < 5) {
916
+ return;
917
+ }
918
+ this.lastKnownSize = { width, height };
919
+ const svgElement = this.svg.node();
920
+ if (svgElement) {
921
+ svgElement.style.width = '100%';
922
+ svgElement.style.height = '100%';
923
+ }
924
+ this.svg.attr('viewBox', `0 0 ${width} ${height}`);
925
+ if (this.simulation && !this.isLayoutFrozen) {
926
+ this.simulation.force('center', d3.forceCenter(width / 2, height / 2));
927
+ this.simulation.alpha(0.3).restart();
928
+ }
929
+ }
930
+ setupResizeObserver() {
931
+ if (!this.erdContainer?.nativeElement) {
932
+ return;
933
+ }
934
+ this.resizeObserver = new ResizeObserver(() => {
935
+ if (this.resizeTimeout) {
936
+ clearTimeout(this.resizeTimeout);
937
+ }
938
+ this.resizeTimeout = window.setTimeout(() => {
939
+ const container = this.erdContainer?.nativeElement;
940
+ if (!container)
941
+ return;
942
+ const newWidth = container.clientWidth;
943
+ const newHeight = container.clientHeight;
944
+ if (Math.abs(this.lastKnownSize.width - newWidth) >= 5 ||
945
+ Math.abs(this.lastKnownSize.height - newHeight) >= 5) {
946
+ requestAnimationFrame(() => {
947
+ this.resizeSVG();
948
+ });
949
+ }
950
+ }, 50);
951
+ });
952
+ this.resizeObserver.observe(this.erdContainer.nativeElement);
953
+ }
954
+ clearVisualization() {
955
+ if (this.simulation) {
956
+ this.simulation.stop();
957
+ }
958
+ d3.select(this.erdContainer.nativeElement).selectAll('*').remove();
959
+ }
960
+ // ============================================================================
961
+ // FOCUS MODE
962
+ // ============================================================================
963
+ applyFocusMode() {
964
+ if (!this.focusNodeId) {
965
+ // No focus mode - show all nodes
966
+ this.visibleNodes = [...this.internalNodes];
967
+ this.visibleLinks = [...this.internalLinks];
968
+ return;
969
+ }
970
+ // Get IDs of nodes within focus depth
971
+ const visibleIds = new Set([this.focusNodeId]);
972
+ const relatedIds = this.getRelatedNodeIds(this.focusNodeId, this.focusDepth);
973
+ relatedIds.forEach(id => visibleIds.add(id));
974
+ // Filter nodes
975
+ this.visibleNodes = this.internalNodes.filter(n => visibleIds.has(n.id));
976
+ // Filter links - only show links between visible nodes
977
+ this.visibleLinks = this.internalLinks.filter(link => {
978
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
979
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
980
+ return visibleIds.has(sourceId) && visibleIds.has(targetId);
981
+ });
982
+ }
983
+ getRelatedNodeIds(nodeId, depth) {
984
+ const visited = new Set([nodeId]);
985
+ const toProcess = [{ id: nodeId, currentDepth: 0 }];
986
+ const result = [];
987
+ while (toProcess.length > 0) {
988
+ const current = toProcess.shift();
989
+ if (current.currentDepth >= depth)
990
+ continue;
991
+ // Find relationships
992
+ this.nodes.forEach(node => {
993
+ node.fields.forEach(field => {
994
+ if (field.relatedNodeId) {
995
+ // Outgoing from current
996
+ if (node.id === current.id && !visited.has(field.relatedNodeId)) {
997
+ visited.add(field.relatedNodeId);
998
+ result.push(field.relatedNodeId);
999
+ toProcess.push({ id: field.relatedNodeId, currentDepth: current.currentDepth + 1 });
1000
+ }
1001
+ // Incoming to current
1002
+ if (field.relatedNodeId === current.id && !visited.has(node.id)) {
1003
+ visited.add(node.id);
1004
+ result.push(node.id);
1005
+ toProcess.push({ id: node.id, currentDepth: current.currentDepth + 1 });
1006
+ }
1007
+ }
1008
+ });
1009
+ });
1010
+ }
1011
+ return result;
1012
+ }
1013
+ // ============================================================================
1014
+ // NODE/LINK CREATION
1015
+ // ============================================================================
1016
+ createInternalNodes() {
1017
+ const cfg = this.mergedConfig;
1018
+ this.internalNodes = this.nodes.map(node => {
1019
+ const primaryKeys = node.fields.filter(f => f.isPrimaryKey);
1020
+ const foreignKeys = node.fields.filter(f => f.relatedNodeId && !f.isPrimaryKey);
1021
+ const fieldCount = Math.max(1, primaryKeys.length + foreignKeys.length);
1022
+ const calculatedHeight = Math.min(cfg.nodeBaseHeight + (fieldCount * cfg.fieldHeight), cfg.maxNodeHeight);
1023
+ return {
1024
+ id: node.id,
1025
+ name: node.name || node.schemaName || 'Unknown',
1026
+ node: node,
1027
+ width: cfg.nodeWidth,
1028
+ height: calculatedHeight,
1029
+ primaryKeys: primaryKeys,
1030
+ foreignKeys: foreignKeys
1031
+ };
1032
+ });
1033
+ }
1034
+ createInternalLinks() {
1035
+ this.internalLinks = [];
1036
+ const nodeMap = new Map(this.internalNodes.map(n => [n.id, n]));
1037
+ this.nodes.forEach(node => {
1038
+ node.fields.forEach(field => {
1039
+ if (field.relatedNodeId && !field.isPrimaryKey) {
1040
+ const sourceNode = nodeMap.get(node.id);
1041
+ const targetNode = nodeMap.get(field.relatedNodeId);
1042
+ if (sourceNode && targetNode) {
1043
+ const isSelfReference = node.id === field.relatedNodeId;
1044
+ const targetField = targetNode.primaryKeys.find(pk => pk.name === field.relatedFieldName);
1045
+ this.internalLinks.push({
1046
+ source: sourceNode,
1047
+ target: targetNode,
1048
+ sourceField: field,
1049
+ targetField: targetField,
1050
+ isSelfReference: isSelfReference
1051
+ });
1052
+ }
1053
+ }
1054
+ });
1055
+ });
1056
+ }
1057
+ // ============================================================================
1058
+ // DAGRE LAYOUT
1059
+ // ============================================================================
1060
+ /**
1061
+ * Applies dagre hierarchical layout to position nodes and compute edge paths.
1062
+ * This provides clean, orthogonal edge routing.
1063
+ */
1064
+ applyDagreLayout() {
1065
+ const cfg = this.mergedConfig;
1066
+ const dagreCfg = cfg.dagreConfig;
1067
+ // Create a new directed graph
1068
+ const g = new dagre.graphlib.Graph({ directed: true, multigraph: true });
1069
+ // Set graph options
1070
+ g.setGraph({
1071
+ rankdir: dagreCfg.rankDir,
1072
+ nodesep: dagreCfg.nodeSep,
1073
+ ranksep: dagreCfg.rankSep,
1074
+ edgesep: dagreCfg.edgeSep,
1075
+ ranker: dagreCfg.ranker,
1076
+ align: dagreCfg.align
1077
+ });
1078
+ // Default edge label (required by dagre)
1079
+ g.setDefaultEdgeLabel(() => ({}));
1080
+ // Add nodes to the graph
1081
+ this.visibleNodes.forEach(node => {
1082
+ g.setNode(node.id, {
1083
+ width: node.width,
1084
+ height: node.height,
1085
+ label: node.name
1086
+ });
1087
+ });
1088
+ // Add edges to the graph
1089
+ this.visibleLinks.forEach((link, index) => {
1090
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
1091
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
1092
+ // Use index as edge name for multigraph support (multiple edges between same nodes)
1093
+ g.setEdge(sourceId, targetId, {
1094
+ label: link.sourceField.name || '',
1095
+ labelpos: 'c',
1096
+ labeloffset: 10
1097
+ }, `edge-${index}`);
1098
+ });
1099
+ // Run the layout algorithm
1100
+ dagre.layout(g);
1101
+ // Apply computed positions to nodes
1102
+ this.visibleNodes.forEach(node => {
1103
+ const dagreNode = g.node(node.id);
1104
+ if (dagreNode) {
1105
+ node.x = dagreNode.x;
1106
+ node.y = dagreNode.y;
1107
+ // Fix position to prevent force simulation from moving nodes
1108
+ node.fx = dagreNode.x;
1109
+ node.fy = dagreNode.y;
1110
+ }
1111
+ });
1112
+ // Store edge points for orthogonal path rendering
1113
+ this.visibleLinks.forEach((link, index) => {
1114
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
1115
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
1116
+ const edge = g.edge(sourceId, targetId, `edge-${index}`);
1117
+ if (edge && edge.points) {
1118
+ link.points = edge.points;
1119
+ }
1120
+ });
1121
+ }
1122
+ /**
1123
+ * Checks if the current layout algorithm uses dagre.
1124
+ */
1125
+ usesDagreLayout() {
1126
+ const algo = this.mergedConfig.layoutAlgorithm;
1127
+ return algo === 'dagre' || algo === 'horizontal' || algo === 'vertical';
1128
+ }
1129
+ // ============================================================================
1130
+ // VISUALIZATION
1131
+ // ============================================================================
1132
+ createVisualization() {
1133
+ const container = this.erdContainer.nativeElement;
1134
+ const width = container.clientWidth || 800;
1135
+ const height = container.clientHeight || 600;
1136
+ const cfg = this.mergedConfig;
1137
+ const colors = cfg.colors;
1138
+ this.lastKnownSize = { width, height };
1139
+ // Create zoom behavior
1140
+ if (cfg.enableZoom) {
1141
+ this.zoom = d3.zoom()
1142
+ .scaleExtent([cfg.minZoom, cfg.maxZoom])
1143
+ .on('zoom', (event) => {
1144
+ if (this.svg) {
1145
+ this.svg.select('.chart-area').attr('transform', event.transform);
1146
+ this.zoomChange.emit({
1147
+ zoomLevel: event.transform.k,
1148
+ translateX: event.transform.x,
1149
+ translateY: event.transform.y
1150
+ });
1151
+ }
1152
+ });
1153
+ }
1154
+ // Create SVG
1155
+ this.svg = d3
1156
+ .select(container)
1157
+ .append('svg')
1158
+ .attr('viewBox', `0 0 ${width} ${height}`)
1159
+ .style('width', '100%')
1160
+ .style('height', '100%')
1161
+ .style('position', 'absolute')
1162
+ .style('top', '0')
1163
+ .style('left', '0');
1164
+ if (this.zoom) {
1165
+ this.svg.call(this.zoom);
1166
+ }
1167
+ // Background click handler
1168
+ this.svg.on('click', (event) => {
1169
+ if (event.target === event.currentTarget) {
1170
+ this.nodeDeselected.emit();
1171
+ this.clearAllHighlighting();
1172
+ }
1173
+ });
1174
+ // Background context menu handler
1175
+ this.svg.on('contextmenu', (event) => {
1176
+ if (event.target === event.currentTarget) {
1177
+ const contextEvent = {
1178
+ mouseEvent: event,
1179
+ cancel: false,
1180
+ position: { x: event.clientX, y: event.clientY },
1181
+ diagramPosition: this.screenToDiagramCoords(event.clientX, event.clientY)
1182
+ };
1183
+ this.diagramContextMenu.emit(contextEvent);
1184
+ if (contextEvent.cancel) {
1185
+ event.preventDefault();
1186
+ }
1187
+ }
1188
+ });
1189
+ const chartArea = this.svg.append('g').attr('class', 'chart-area');
1190
+ // Define arrow marker
1191
+ this.svg
1192
+ .append('defs')
1193
+ .selectAll('marker')
1194
+ .data(['end-arrow'])
1195
+ .enter()
1196
+ .append('marker')
1197
+ .attr('id', 'end-arrow')
1198
+ .attr('viewBox', '0 -5 10 10')
1199
+ .attr('refX', 8)
1200
+ .attr('refY', 0)
1201
+ .attr('markerWidth', 6)
1202
+ .attr('markerHeight', 6)
1203
+ .attr('orient', 'auto')
1204
+ .append('path')
1205
+ .attr('d', 'M0,-5L10,0L0,5')
1206
+ .attr('fill', colors.linkColor);
1207
+ // Apply layout based on algorithm
1208
+ if (this.usesDagreLayout()) {
1209
+ // Use dagre for hierarchical layout with orthogonal edges
1210
+ this.applyDagreLayout();
1211
+ this.simulation = null; // No force simulation needed
1212
+ }
1213
+ else {
1214
+ // Create force simulation with visible nodes only (original behavior)
1215
+ this.simulation = d3
1216
+ .forceSimulation(this.visibleNodes)
1217
+ .force('link', d3
1218
+ .forceLink(this.visibleLinks)
1219
+ .id((d) => d.id)
1220
+ .distance((d) => {
1221
+ const source = d.source;
1222
+ const target = d.target;
1223
+ const sourceSize = Math.max(source.width, source.height);
1224
+ const targetSize = Math.max(target.width, target.height);
1225
+ return (sourceSize + targetSize) / 2 + cfg.linkDistance;
1226
+ }))
1227
+ .force('charge', d3.forceManyBody().strength(cfg.chargeStrength))
1228
+ .force('center', d3.forceCenter(width / 2, height / 2))
1229
+ .force('collision', d3.forceCollide().radius((d) => {
1230
+ return Math.max(d.width, d.height) / 2 + cfg.collisionPadding;
1231
+ }));
1232
+ }
1233
+ // Create links
1234
+ const link = chartArea
1235
+ .selectAll('.link')
1236
+ .data(this.visibleLinks)
1237
+ .enter()
1238
+ .append('g')
1239
+ .attr('class', 'link-group');
1240
+ link
1241
+ .append('path')
1242
+ .attr('class', 'link')
1243
+ .attr('stroke', colors.linkColor)
1244
+ .attr('stroke-opacity', 0.8)
1245
+ .attr('stroke-width', 2)
1246
+ .attr('fill', 'none')
1247
+ .attr('marker-end', (d) => (d.isSelfReference ? 'none' : 'url(#end-arrow)'));
1248
+ if (cfg.showRelationshipLabels) {
1249
+ // Add background rect for label (will be sized after text is rendered)
1250
+ // Self-referencing links get a green background to stand out
1251
+ link
1252
+ .append('rect')
1253
+ .attr('class', 'link-label-bg')
1254
+ .attr('fill', (d) => d.isSelfReference ? '#e8f5e9' : 'white')
1255
+ .attr('stroke', (d) => d.isSelfReference ? '#4CAF50' : colors.linkColor)
1256
+ .attr('stroke-width', 0.5)
1257
+ .attr('stroke-opacity', (d) => d.isSelfReference ? 0.6 : 0.4)
1258
+ .attr('rx', 2)
1259
+ .attr('ry', 2);
1260
+ // Add the label text
1261
+ link
1262
+ .append('text')
1263
+ .attr('class', 'link-label')
1264
+ .attr('font-size', '10px')
1265
+ .attr('fill', (d) => d.isSelfReference ? '#2e7d32' : colors.linkColor)
1266
+ .attr('text-anchor', 'middle')
1267
+ .attr('dominant-baseline', 'middle')
1268
+ .text((d) => d.sourceField.name || '');
1269
+ }
1270
+ // Link event handlers
1271
+ this.attachLinkEventHandlers(link);
1272
+ // Create nodes
1273
+ const nodeGroup = chartArea
1274
+ .selectAll('.node')
1275
+ .data(this.visibleNodes)
1276
+ .enter()
1277
+ .append('g')
1278
+ .attr('class', 'node')
1279
+ .style('cursor', this.readOnly ? 'default' : 'pointer');
1280
+ if (cfg.enableDragging && !this.readOnly) {
1281
+ nodeGroup.call(d3.drag()
1282
+ .on('start', (event, d) => this.dragstarted(event, d))
1283
+ .on('drag', (event, d) => this.dragged(event, d))
1284
+ .on('end', (event, d) => this.dragended(event, d)));
1285
+ }
1286
+ // Node rectangle
1287
+ nodeGroup
1288
+ .append('rect')
1289
+ .attr('class', 'entity-rect')
1290
+ .attr('width', (d) => d.width)
1291
+ .attr('height', (d) => d.height)
1292
+ .attr('x', (d) => -d.width / 2)
1293
+ .attr('y', (d) => -d.height / 2)
1294
+ .attr('fill', colors.nodeBackground)
1295
+ .attr('stroke', colors.nodeBorder)
1296
+ .attr('stroke-width', 2)
1297
+ .attr('rx', 4);
1298
+ // Node header
1299
+ nodeGroup
1300
+ .append('rect')
1301
+ .attr('class', 'entity-header')
1302
+ .attr('width', (d) => d.width)
1303
+ .attr('height', 30)
1304
+ .attr('x', (d) => -d.width / 2)
1305
+ .attr('y', (d) => -d.height / 2)
1306
+ .attr('fill', colors.nodeHeader)
1307
+ .attr('rx', 4);
1308
+ nodeGroup
1309
+ .append('rect')
1310
+ .attr('class', 'entity-header-bottom')
1311
+ .attr('width', (d) => d.width)
1312
+ .attr('height', 15)
1313
+ .attr('x', (d) => -d.width / 2)
1314
+ .attr('y', (d) => -d.height / 2 + 15)
1315
+ .attr('fill', colors.nodeHeader);
1316
+ // Node name
1317
+ nodeGroup
1318
+ .append('text')
1319
+ .attr('class', 'entity-name')
1320
+ .attr('text-anchor', 'middle')
1321
+ .attr('y', (d) => -d.height / 2 + 20)
1322
+ .attr('font-size', '12px')
1323
+ .attr('font-weight', 'bold')
1324
+ .attr('fill', colors.nodeHeaderText)
1325
+ .text((d) => d.name);
1326
+ // Add fields if configured
1327
+ if (cfg.showFieldDetails) {
1328
+ this.renderNodeFields(nodeGroup, colors);
1329
+ }
1330
+ // Event handlers
1331
+ this.attachNodeEventHandlers(nodeGroup);
1332
+ // Tooltips
1333
+ nodeGroup
1334
+ .append('title')
1335
+ .text((d) => `${d.name}\nPrimary Keys: ${d.primaryKeys.length}\nForeign Keys: ${d.foreignKeys.length}`);
1336
+ // Handle layout completion based on algorithm type
1337
+ if (this.usesDagreLayout()) {
1338
+ // Dagre layout is synchronous - positions are already set
1339
+ this.updatePositions(link, nodeGroup);
1340
+ this.layoutCompleted = true;
1341
+ this.layoutComplete.emit();
1342
+ // Fit to view after positions are set
1343
+ if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1344
+ setTimeout(() => this.zoomToFit(), 50);
1345
+ }
1346
+ }
1347
+ else if (this.simulation) {
1348
+ // Force simulation layout
1349
+ if (cfg.skipAnimation) {
1350
+ // Run simulation to completion without animation
1351
+ this.simulation.stop();
1352
+ // Run enough ticks to settle the layout (typically 300 is enough)
1353
+ const tickCount = 300;
1354
+ for (let i = 0; i < tickCount; i++) {
1355
+ this.simulation.tick();
1356
+ }
1357
+ // Update positions once at the end
1358
+ this.updatePositions(link, nodeGroup);
1359
+ this.layoutCompleted = true;
1360
+ this.layoutComplete.emit();
1361
+ // Fit to view after positions are set
1362
+ if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1363
+ setTimeout(() => this.zoomToFit(), 50);
1364
+ }
1365
+ }
1366
+ else {
1367
+ // Normal animated mode
1368
+ // Simulation tick
1369
+ this.simulation.on('tick', () => {
1370
+ this.updatePositions(link, nodeGroup);
1371
+ });
1372
+ // Layout complete detection
1373
+ this.simulation.on('end', () => {
1374
+ if (!this.layoutCompleted) {
1375
+ this.layoutCompleted = true;
1376
+ this.layoutComplete.emit();
1377
+ if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1378
+ setTimeout(() => this.zoomToFit(), 100);
1379
+ }
1380
+ }
1381
+ });
1382
+ }
1383
+ }
1384
+ // Apply initial selection highlighting
1385
+ this.updateSelectionHighlighting();
1386
+ this.updateHighlighting();
1387
+ }
1388
+ renderNodeFields(nodeGroup, colors) {
1389
+ nodeGroup.each((d, i, nodes) => {
1390
+ const group = d3.select(nodes[i]);
1391
+ let currentY = -d.height / 2 + 40;
1392
+ // Primary keys
1393
+ d.primaryKeys.forEach((pk) => {
1394
+ const fieldGroup = group.append('g').attr('class', 'field-group primary-key');
1395
+ fieldGroup
1396
+ .append('rect')
1397
+ .attr('class', 'field-bg')
1398
+ .attr('x', -d.width / 2 + 2)
1399
+ .attr('y', currentY - 15)
1400
+ .attr('width', d.width - 4)
1401
+ .attr('height', 18)
1402
+ .attr('fill', colors.primaryKeyBackground);
1403
+ fieldGroup
1404
+ .append('text')
1405
+ .attr('class', 'field-icon')
1406
+ .attr('x', -d.width / 2 + 8)
1407
+ .attr('y', currentY - 2)
1408
+ .attr('font-size', '10px')
1409
+ .attr('fill', colors.primaryKeyText)
1410
+ .text('🔑');
1411
+ fieldGroup
1412
+ .append('text')
1413
+ .attr('class', 'field-name')
1414
+ .attr('x', -d.width / 2 + 25)
1415
+ .attr('y', currentY - 2)
1416
+ .attr('font-size', '11px')
1417
+ .attr('font-weight', 'bold')
1418
+ .attr('fill', colors.primaryKeyText)
1419
+ .text(pk.name || '');
1420
+ currentY += 20;
1421
+ });
1422
+ // Foreign keys
1423
+ d.foreignKeys.forEach((fk) => {
1424
+ const fieldGroup = group.append('g').attr('class', 'field-group foreign-key');
1425
+ fieldGroup
1426
+ .append('rect')
1427
+ .attr('class', 'field-bg')
1428
+ .attr('x', -d.width / 2 + 2)
1429
+ .attr('y', currentY - 15)
1430
+ .attr('width', d.width - 4)
1431
+ .attr('height', 18)
1432
+ .attr('fill', colors.foreignKeyBackground);
1433
+ fieldGroup
1434
+ .append('text')
1435
+ .attr('class', 'field-icon')
1436
+ .attr('x', -d.width / 2 + 8)
1437
+ .attr('y', currentY - 2)
1438
+ .attr('font-size', '10px')
1439
+ .attr('fill', colors.foreignKeyText)
1440
+ .text('🔗');
1441
+ fieldGroup
1442
+ .append('text')
1443
+ .attr('class', 'field-name')
1444
+ .attr('x', -d.width / 2 + 25)
1445
+ .attr('y', currentY - 2)
1446
+ .attr('font-size', '11px')
1447
+ .attr('fill', colors.foreignKeyText)
1448
+ .text(fk.name || '');
1449
+ currentY += 20;
1450
+ });
1451
+ });
1452
+ }
1453
+ attachNodeEventHandlers(nodeGroup) {
1454
+ nodeGroup
1455
+ .on('click', (event, d) => {
1456
+ if (this.readOnly)
1457
+ return;
1458
+ event.stopPropagation();
1459
+ // Emit click event with cancel pattern
1460
+ const clickEvent = {
1461
+ node: d.node,
1462
+ mouseEvent: event,
1463
+ cancel: false
1464
+ };
1465
+ this.nodeClick.emit(clickEvent);
1466
+ if (clickEvent.cancel) {
1467
+ return;
1468
+ }
1469
+ // Handle selection
1470
+ if (this.selectedNodeId === d.node.id) {
1471
+ this.nodeDeselected.emit();
1472
+ }
1473
+ else {
1474
+ this.nodeSelected.emit(d.node);
1475
+ // Auto-zoom if node is too small
1476
+ if (this.svg) {
1477
+ const currentTransform = d3.zoomTransform(this.svg.node());
1478
+ const currentRenderedWidth = d.width * currentTransform.k;
1479
+ if (currentRenderedWidth < 20) {
1480
+ this.zoomToNode(d.node.id);
1481
+ }
1482
+ }
1483
+ }
1484
+ this.updateSelectionHighlighting();
1485
+ })
1486
+ .on('dblclick', (event, d) => {
1487
+ event.stopPropagation();
1488
+ const dblClickEvent = {
1489
+ node: d.node,
1490
+ mouseEvent: event,
1491
+ cancel: false
1492
+ };
1493
+ this.nodeDoubleClick.emit(dblClickEvent);
1494
+ })
1495
+ .on('mouseenter', (event, d) => {
1496
+ const relatedInfo = this.getRelatedNodes(d.node.id, 1);
1497
+ const relatedNodes = relatedInfo.map(r => r.node);
1498
+ const hoverEvent = {
1499
+ node: d.node,
1500
+ mouseEvent: event,
1501
+ relatedNodes,
1502
+ position: { x: event.clientX, y: event.clientY }
1503
+ };
1504
+ this.nodeHover.emit(hoverEvent);
1505
+ })
1506
+ .on('mouseleave', (_event, d) => {
1507
+ this.nodeHoverEnd.emit(d.node);
1508
+ })
1509
+ .on('contextmenu', (event, d) => {
1510
+ const contextEvent = {
1511
+ node: d.node,
1512
+ mouseEvent: event,
1513
+ cancel: false,
1514
+ position: { x: event.clientX, y: event.clientY }
1515
+ };
1516
+ this.nodeContextMenu.emit(contextEvent);
1517
+ if (contextEvent.cancel) {
1518
+ event.preventDefault();
1519
+ }
1520
+ });
1521
+ }
1522
+ attachLinkEventHandlers(linkGroup) {
1523
+ linkGroup
1524
+ .on('click', (event, d) => {
1525
+ event.stopPropagation();
1526
+ const sourceNode = d.source.node;
1527
+ const targetNode = d.target.node;
1528
+ const link = {
1529
+ sourceNodeId: sourceNode.id,
1530
+ targetNodeId: targetNode.id,
1531
+ sourceField: d.sourceField,
1532
+ targetField: d.targetField,
1533
+ isSelfReference: d.isSelfReference
1534
+ };
1535
+ const clickEvent = {
1536
+ link,
1537
+ sourceNode,
1538
+ targetNode,
1539
+ mouseEvent: event,
1540
+ cancel: false
1541
+ };
1542
+ this.linkClick.emit(clickEvent);
1543
+ })
1544
+ .on('mouseenter', (event, d) => {
1545
+ const sourceNode = d.source.node;
1546
+ const targetNode = d.target.node;
1547
+ const link = {
1548
+ sourceNodeId: sourceNode.id,
1549
+ targetNodeId: targetNode.id,
1550
+ sourceField: d.sourceField,
1551
+ targetField: d.targetField,
1552
+ isSelfReference: d.isSelfReference
1553
+ };
1554
+ const hoverEvent = {
1555
+ link,
1556
+ sourceNode,
1557
+ targetNode,
1558
+ mouseEvent: event,
1559
+ position: { x: event.clientX, y: event.clientY }
1560
+ };
1561
+ this.linkHover.emit(hoverEvent);
1562
+ })
1563
+ .on('mouseleave', (_event, d) => {
1564
+ const sourceNode = d.source.node;
1565
+ const targetNode = d.target.node;
1566
+ const link = {
1567
+ sourceNodeId: sourceNode.id,
1568
+ targetNodeId: targetNode.id,
1569
+ sourceField: d.sourceField,
1570
+ targetField: d.targetField,
1571
+ isSelfReference: d.isSelfReference
1572
+ };
1573
+ this.linkHoverEnd.emit(link);
1574
+ })
1575
+ .on('contextmenu', (event, d) => {
1576
+ const sourceNode = d.source.node;
1577
+ const targetNode = d.target.node;
1578
+ const link = {
1579
+ sourceNodeId: sourceNode.id,
1580
+ targetNodeId: targetNode.id,
1581
+ sourceField: d.sourceField,
1582
+ targetField: d.targetField,
1583
+ isSelfReference: d.isSelfReference
1584
+ };
1585
+ const contextEvent = {
1586
+ link,
1587
+ sourceNode,
1588
+ targetNode,
1589
+ mouseEvent: event,
1590
+ cancel: false,
1591
+ position: { x: event.clientX, y: event.clientY }
1592
+ };
1593
+ this.linkContextMenu.emit(contextEvent);
1594
+ if (contextEvent.cancel) {
1595
+ event.preventDefault();
1596
+ }
1597
+ });
1598
+ }
1599
+ updatePositions(link, nodeGroup) {
1600
+ const usesDagre = this.usesDagreLayout();
1601
+ // Update link paths
1602
+ link.select('path').attr('d', (d) => {
1603
+ const source = d.source;
1604
+ const target = d.target;
1605
+ if (d.isSelfReference) {
1606
+ // Create a larger loop that goes around the bottom of the entity
1607
+ // Start from right side, loop down and around, return to left side
1608
+ const startX = source.x + source.width / 2; // Right edge center
1609
+ const startY = source.y;
1610
+ const endY = source.y + source.height / 2; // Bottom edge Y
1611
+ const loopExtent = 60; // How far out the loop extends
1612
+ // Use a path that goes: right edge -> curves down-right -> bottom -> curves up to bottom edge
1613
+ return `M ${startX} ${startY}
1614
+ C ${startX + loopExtent} ${startY},
1615
+ ${startX + loopExtent} ${endY + loopExtent},
1616
+ ${source.x} ${endY + loopExtent}
1617
+ C ${source.x - loopExtent} ${endY + loopExtent},
1618
+ ${source.x - source.width / 2 - loopExtent / 2} ${endY},
1619
+ ${source.x - source.width / 2} ${endY}`;
1620
+ }
1621
+ // Use dagre edge points if available (provides cleaner orthogonal paths)
1622
+ if (usesDagre && d.points && d.points.length >= 2) {
1623
+ return this.createPathFromDagrePoints(d.points);
1624
+ }
1625
+ // Fallback to manual orthogonal path
1626
+ const sourcePoint = this.getSourceConnectionPoint(source, d.sourceField);
1627
+ const targetPoint = this.getTargetConnectionPoint(target, d.targetField);
1628
+ return this.createOrthogonalPath(sourcePoint, targetPoint);
1629
+ });
1630
+ // Update link labels and their background boxes
1631
+ link.select('text').attr('transform', (d) => {
1632
+ const source = d.source;
1633
+ const target = d.target;
1634
+ if (d.isSelfReference) {
1635
+ // Position label at the bottom of the loop (below the entity)
1636
+ const loopExtent = 60;
1637
+ return `translate(${source.x}, ${source.y + source.height / 2 + loopExtent + 8})`;
1638
+ }
1639
+ // Use midpoint of dagre edge points if available
1640
+ if (usesDagre && d.points && d.points.length >= 2) {
1641
+ const midIndex = Math.floor(d.points.length / 2);
1642
+ const midPoint = d.points[midIndex];
1643
+ return `translate(${midPoint.x}, ${midPoint.y})`;
1644
+ }
1645
+ // Fallback to midpoint between nodes
1646
+ const midX = (source.x + target.x) / 2;
1647
+ const midY = (source.y + target.y) / 2;
1648
+ return `translate(${midX}, ${midY - 8})`;
1649
+ });
1650
+ // Update label background rects to match text size and position
1651
+ link.each(function () {
1652
+ const group = d3.select(this);
1653
+ const textEl = group.select('text.link-label').node();
1654
+ const bgRect = group.select('rect.link-label-bg');
1655
+ if (textEl && !bgRect.empty()) {
1656
+ const bbox = textEl.getBBox();
1657
+ const padding = 3;
1658
+ // Get the transform from the text element
1659
+ const transform = group.select('text.link-label').attr('transform');
1660
+ bgRect
1661
+ .attr('x', bbox.x - padding)
1662
+ .attr('y', bbox.y - padding)
1663
+ .attr('width', bbox.width + padding * 2)
1664
+ .attr('height', bbox.height + padding * 2)
1665
+ .attr('transform', transform);
1666
+ }
1667
+ });
1668
+ // Update node positions
1669
+ nodeGroup.attr('transform', (d) => `translate(${d.x},${d.y})`);
1670
+ }
1671
+ // ============================================================================
1672
+ // GEOMETRY HELPERS
1673
+ // ============================================================================
1674
+ getSourceConnectionPoint(sourceNode, field) {
1675
+ let connectY = sourceNode.y;
1676
+ if (field) {
1677
+ const fkIndex = sourceNode.foreignKeys.findIndex((fk) => fk.id === field.id);
1678
+ if (fkIndex >= 0) {
1679
+ const fieldY = -sourceNode.height / 2 + 40 + sourceNode.primaryKeys.length * 20 + fkIndex * 20;
1680
+ connectY = sourceNode.y + fieldY + 10;
1681
+ }
1682
+ }
1683
+ return {
1684
+ x: sourceNode.x + sourceNode.width / 2,
1685
+ y: connectY,
1686
+ };
1687
+ }
1688
+ getTargetConnectionPoint(targetNode, targetField) {
1689
+ let connectY = targetNode.y - targetNode.height / 2 + 40;
1690
+ if (targetField && targetField.isPrimaryKey) {
1691
+ const pkIndex = targetNode.primaryKeys.findIndex((pk) => pk.id === targetField.id);
1692
+ if (pkIndex >= 0) {
1693
+ const fieldY = -targetNode.height / 2 + 40 + pkIndex * 20;
1694
+ connectY = targetNode.y + fieldY + 10;
1695
+ }
1696
+ }
1697
+ return {
1698
+ x: targetNode.x - targetNode.width / 2,
1699
+ y: connectY,
1700
+ };
1701
+ }
1702
+ createOrthogonalPath(source, target) {
1703
+ const dx = target.x - source.x;
1704
+ let midX;
1705
+ if (dx > 0) {
1706
+ midX = source.x + dx * 0.7;
1707
+ }
1708
+ else {
1709
+ midX = source.x + Math.max(dx * 0.3, -50);
1710
+ }
1711
+ return `M ${source.x} ${source.y}
1712
+ L ${midX} ${source.y}
1713
+ L ${midX} ${target.y}
1714
+ L ${target.x} ${target.y}`;
1715
+ }
1716
+ /**
1717
+ * Creates an SVG path string from dagre's edge points.
1718
+ * Dagre provides an array of points for each edge that forms a clean orthogonal path.
1719
+ * @param points Array of {x, y} coordinates from dagre layout
1720
+ * @returns SVG path string
1721
+ */
1722
+ createPathFromDagrePoints(points) {
1723
+ if (!points || points.length < 2) {
1724
+ return '';
1725
+ }
1726
+ // Start with Move to first point
1727
+ let path = `M ${points[0].x} ${points[0].y}`;
1728
+ // Add Line to each subsequent point
1729
+ for (let i = 1; i < points.length; i++) {
1730
+ path += ` L ${points[i].x} ${points[i].y}`;
1731
+ }
1732
+ return path;
1733
+ }
1734
+ screenToDiagramCoords(screenX, screenY) {
1735
+ if (!this.svg)
1736
+ return { x: 0, y: 0 };
1737
+ const svgNode = this.svg.node();
1738
+ if (!svgNode)
1739
+ return { x: 0, y: 0 };
1740
+ const rect = svgNode.getBoundingClientRect();
1741
+ const transform = d3.zoomTransform(svgNode);
1742
+ return {
1743
+ x: (screenX - rect.left - transform.x) / transform.k,
1744
+ y: (screenY - rect.top - transform.y) / transform.k
1745
+ };
1746
+ }
1747
+ // ============================================================================
1748
+ // HIGHLIGHTING
1749
+ // ============================================================================
1750
+ clearAllHighlighting() {
1751
+ if (!this.svg)
1752
+ return;
1753
+ this.svg.selectAll('.node')
1754
+ .classed('selected', false)
1755
+ .classed('highlighted', false)
1756
+ .classed('relationship-connected', false)
1757
+ .classed('entity-connections-highlighted', false);
1758
+ this.svg.selectAll('.entity-rect')
1759
+ .classed('highlighted', false)
1760
+ .classed('relationship-highlighted', false)
1761
+ .classed('connection-highlighted', false)
1762
+ .style('stroke', this.mergedConfig.colors?.nodeBorder || '#333')
1763
+ .style('stroke-width', '2px')
1764
+ .style('filter', null);
1765
+ this.svg.selectAll('.link-group')
1766
+ .classed('highlighted', false);
1767
+ this.svg.selectAll('.link')
1768
+ .classed('highlighted', false);
1769
+ this.svg.selectAll('.link-label')
1770
+ .classed('highlighted', false);
1771
+ }
1772
+ updateSelectionHighlighting() {
1773
+ if (!this.svg)
1774
+ return;
1775
+ // Clear previous selection styling
1776
+ this.svg.selectAll('.node')
1777
+ .classed('selected', false)
1778
+ .select('.entity-rect')
1779
+ .style('stroke', this.mergedConfig.colors?.nodeBorder || '#333')
1780
+ .style('stroke-width', '2px')
1781
+ .style('filter', null);
1782
+ if (this.selectedNodeId) {
1783
+ const selectedColor = this.mergedConfig.colors?.selectedBorder || '#4CAF50';
1784
+ this.svg.selectAll('.node')
1785
+ .filter((d) => d.id === this.selectedNodeId)
1786
+ .classed('selected', true)
1787
+ .select('.entity-rect')
1788
+ .style('stroke', selectedColor)
1789
+ .style('stroke-width', '4px')
1790
+ .style('filter', `drop-shadow(0 0 8px ${selectedColor}80)`);
1791
+ }
1792
+ }
1793
+ updateHighlighting() {
1794
+ if (!this.svg)
1795
+ return;
1796
+ const highlightColor = this.mergedConfig.colors?.highlightBorder || '#ff9800';
1797
+ const highlightSet = new Set(this.highlightedNodeIds);
1798
+ this.svg.selectAll('.node')
1799
+ .classed('highlighted', (d) => highlightSet.has(d.id))
1800
+ .filter((d) => highlightSet.has(d.id))
1801
+ .select('.entity-rect')
1802
+ .style('stroke', highlightColor)
1803
+ .style('stroke-width', '3px')
1804
+ .style('filter', `drop-shadow(0 0 6px ${highlightColor}80)`);
1805
+ }
1806
+ emitStateChange() {
1807
+ const state = this.getState();
1808
+ this.stateChange.emit(state);
1809
+ }
1810
+ // ============================================================================
1811
+ // DRAG HANDLERS
1812
+ // ============================================================================
1813
+ dragstarted(event, d) {
1814
+ if (this.isLayoutFrozen)
1815
+ return;
1816
+ const dragEvent = {
1817
+ node: d.node,
1818
+ startPosition: { x: d.x || 0, y: d.y || 0 },
1819
+ currentPosition: { x: d.x || 0, y: d.y || 0 },
1820
+ cancel: false
1821
+ };
1822
+ this.nodeDragStart.emit(dragEvent);
1823
+ if (dragEvent.cancel)
1824
+ return;
1825
+ if (!event.active && this.simulation) {
1826
+ this.simulation.alphaTarget(0.3).restart();
1827
+ }
1828
+ d.fx = d.x;
1829
+ d.fy = d.y;
1830
+ }
1831
+ dragged(event, d) {
1832
+ if (this.isLayoutFrozen)
1833
+ return;
1834
+ d.fx = event.x;
1835
+ d.fy = event.y;
1836
+ }
1837
+ dragended(event, d) {
1838
+ if (this.isLayoutFrozen)
1839
+ return;
1840
+ const dragEvent = {
1841
+ node: d.node,
1842
+ startPosition: { x: 0, y: 0 }, // Start position not tracked in drag
1843
+ currentPosition: { x: d.x || 0, y: d.y || 0 },
1844
+ cancel: false
1845
+ };
1846
+ this.nodeDragEnd.emit(dragEvent);
1847
+ if (!event.active && this.simulation) {
1848
+ this.simulation.alphaTarget(0);
1849
+ }
1850
+ d.fx = null;
1851
+ d.fy = null;
1852
+ }
1853
+ static ɵfac = function ERDDiagramComponent_Factory(t) { return new (t || ERDDiagramComponent)(); };
1854
+ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ERDDiagramComponent, selectors: [["mj-erd-diagram"]], viewQuery: function ERDDiagramComponent_Query(rf, ctx) { if (rf & 1) {
1855
+ i0.ɵɵviewQuery(_c0, 5);
1856
+ } if (rf & 2) {
1857
+ let _t;
1858
+ i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.erdContainer = _t.first);
1859
+ } }, inputs: { nodes: "nodes", selectedNodeId: "selectedNodeId", highlightedNodeIds: "highlightedNodeIds", focusNodeId: "focusNodeId", focusDepth: "focusDepth", isRefreshing: "isRefreshing", readOnly: "readOnly", config: "config", showHeader: "showHeader", headerTitle: "headerTitle" }, outputs: { nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", nodeSelected: "nodeSelected", nodeDeselected: "nodeDeselected", linkClick: "linkClick", nodeHover: "nodeHover", nodeHoverEnd: "nodeHoverEnd", linkHover: "linkHover", linkHoverEnd: "linkHoverEnd", nodeContextMenu: "nodeContextMenu", linkContextMenu: "linkContextMenu", diagramContextMenu: "diagramContextMenu", nodeDragStart: "nodeDragStart", nodeDragEnd: "nodeDragEnd", zoomChange: "zoomChange", refreshRequested: "refreshRequested", layoutComplete: "layoutComplete", stateChange: "stateChange" }, features: [i0.ɵɵNgOnChangesFeature], decls: 5, vars: 2, consts: [["erdContainer", ""], [1, "erd-section"], [1, "section-header"], [1, "erd-container"], [1, "loading-overlay"], [1, "erd-controls"], ["title", "Zoom In", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-search-plus"], ["title", "Zoom Out", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-search-minus"], ["title", "Reset Zoom", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-expand"], ["title", "Refresh Diagram", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-refresh"], [1, "loading-content"], [1, "loading-spinner"], [1, "spinner-ring"], [1, "loading-text"]], template: function ERDDiagramComponent_Template(rf, ctx) { if (rf & 1) {
1860
+ i0.ɵɵelementStart(0, "div", 1);
1861
+ i0.ɵɵtemplate(1, ERDDiagramComponent_Conditional_1_Template, 12, 1, "div", 2);
1862
+ i0.ɵɵelementStart(2, "div", 3, 0);
1863
+ i0.ɵɵtemplate(4, ERDDiagramComponent_Conditional_4_Template, 8, 0, "div", 4);
1864
+ i0.ɵɵelementEnd()();
1865
+ } if (rf & 2) {
1866
+ i0.ɵɵadvance();
1867
+ i0.ɵɵconditional(ctx.showHeader ? 1 : -1);
1868
+ i0.ɵɵadvance(3);
1869
+ i0.ɵɵconditional(ctx.isRefreshing ? 4 : -1);
1870
+ } }, styles: ["[_nghost-%COMP%] {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.erd-section[_ngcontent-%COMP%] {\n background: white;\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.section-header[_ngcontent-%COMP%] {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n background-color: #f5f5f5;\n border-bottom: 1px solid #e0e0e0;\n flex-shrink: 0;\n}\n\n.section-header[_ngcontent-%COMP%] h3[_ngcontent-%COMP%] {\n margin: 0;\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.erd-controls[_ngcontent-%COMP%] {\n display: flex;\n gap: 8px;\n}\n\n.control-btn[_ngcontent-%COMP%] {\n padding: 4px 8px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: white;\n cursor: pointer;\n font-size: 12px;\n transition: background-color 0.2s ease;\n}\n\n.control-btn[_ngcontent-%COMP%]:hover {\n background-color: #f0f0f0;\n}\n\n.control-btn[_ngcontent-%COMP%]:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.erd-container[_ngcontent-%COMP%] {\n flex: 1;\n position: relative;\n background-color: #fafafa;\n overflow: hidden;\n width: 100%;\n min-height: 400px;\n box-sizing: border-box;\n}\n\n.erd-container[_ngcontent-%COMP%] svg[_ngcontent-%COMP%] {\n position: absolute;\n top: 0;\n left: 0;\n width: 100% !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n\n\n\n.loading-overlay[_ngcontent-%COMP%] {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(250, 250, 250, 0.95);\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n animation: _ngcontent-%COMP%_fadeIn 0.3s ease-in-out;\n}\n\n.loading-content[_ngcontent-%COMP%] {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 16px;\n}\n\n.loading-spinner[_ngcontent-%COMP%] {\n position: relative;\n width: 60px;\n height: 60px;\n}\n\n.spinner-ring[_ngcontent-%COMP%] {\n position: absolute;\n width: 100%;\n height: 100%;\n border: 3px solid transparent;\n border-radius: 50%;\n animation: _ngcontent-%COMP%_spin 1.5s linear infinite;\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(1) {\n border-top-color: #2196f3;\n animation-delay: 0s;\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(2) {\n border-top-color: #9c27b0;\n animation-delay: 0.3s;\n transform: scale(0.8);\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(3) {\n border-top-color: #ff6b35;\n animation-delay: 0.6s;\n transform: scale(0.6);\n}\n\n.loading-text[_ngcontent-%COMP%] {\n font-size: 14px;\n color: #666;\n font-weight: 500;\n text-align: center;\n}\n\n\n\n@keyframes _ngcontent-%COMP%_spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n\n@keyframes _ngcontent-%COMP%_fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .node .entity-rect.highlighted {\n stroke: #ff9800 !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 8px rgba(255, 152, 0, 0.6));\n animation: _ngcontent-%COMP%_pulse 1s ease-in-out infinite alternate;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-rect {\n stroke: #2196f3 !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 12px rgba(33, 150, 243, 0.8));\n fill: #f3f8ff !important;\n animation: _ngcontent-%COMP%_selectedPulse 2s ease-in-out infinite alternate;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-header {\n fill: #2196f3 !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-header-bottom {\n fill: #2196f3 !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-name {\n fill: white !important;\n font-weight: bold !important;\n}\n\n@keyframes _ngcontent-%COMP%_selectedPulse {\n 0% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 12px rgba(33, 150, 243, 0.8));\n }\n 100% {\n stroke-width: 5px;\n filter: drop-shadow(0 0 16px rgb(33, 150, 243));\n }\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-rect.relationship-highlighted {\n stroke: #ff6b35 !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.5));\n animation: _ngcontent-%COMP%_entityPulse 1.5s ease-in-out infinite alternate;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-header {\n fill: #ff6b35 !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-header-bottom {\n fill: #ff6b35 !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-rect.connection-highlighted {\n stroke: #9b59b6 !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px rgba(155, 89, 182, 0.5));\n opacity: 0.9;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-header {\n fill: #9b59b6 !important;\n opacity: 0.8;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-header-bottom {\n fill: #9b59b6 !important;\n opacity: 0.8;\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .link-group .link {\n stroke-width: 2px;\n transition: all 0.2s ease;\n cursor: pointer;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link:hover {\n stroke-width: 3px;\n stroke: #ff6b35;\n opacity: 1;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link.highlighted {\n stroke: #ff6b35 !important;\n stroke-width: 4px !important;\n opacity: 1 !important;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.6));\n animation: _ngcontent-%COMP%_linkPulse 1.5s ease-in-out infinite alternate;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label {\n font-family: \"Segoe UI\", Tahoma, Geneva, Verdana, sans-serif;\n opacity: 0.8;\n pointer-events: none;\n transition: opacity 0.2s ease;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label.highlighted {\n opacity: 1;\n font-weight: bold;\n fill: #ff6b35;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group.highlighted {\n z-index: 10;\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .field-group .field-bg {\n transition: fill 0.2s;\n}\n\n[_nghost-%COMP%] .erd-container svg .field-group:hover .field-bg {\n fill: #e3f2fd !important;\n}\n\n@keyframes _ngcontent-%COMP%_pulse {\n 0% {\n stroke-width: 4px;\n }\n 100% {\n stroke-width: 6px;\n }\n}\n\n@keyframes _ngcontent-%COMP%_linkPulse {\n 0% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.6));\n }\n 100% {\n stroke-width: 5px;\n filter: drop-shadow(0 0 10px rgba(255, 107, 53, 0.8));\n }\n}\n\n@keyframes _ngcontent-%COMP%_entityPulse {\n 0% {\n stroke-width: 3px;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.5));\n }\n 100% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 8px rgba(255, 107, 53, 0.7));\n }\n}"] });
1871
+ }
1872
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ERDDiagramComponent, [{
1873
+ type: Component,
1874
+ args: [{ selector: 'mj-erd-diagram', template: "<div class=\"erd-section\">\n @if (showHeader) {\n <div class=\"section-header\">\n <h3>{{ headerTitle }}</h3>\n <div class=\"erd-controls\">\n <button class=\"control-btn\" (click)=\"zoomIn()\" title=\"Zoom In\">\n <span class=\"fa-solid fa-search-plus\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"zoomOut()\" title=\"Zoom Out\">\n <span class=\"fa-solid fa-search-minus\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"resetZoom()\" title=\"Reset Zoom\">\n <span class=\"fa-solid fa-expand\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"refresh(); refreshRequested.emit()\" title=\"Refresh Diagram\">\n <span class=\"fa-solid fa-refresh\"></span>\n </button>\n </div>\n </div>\n }\n <div class=\"erd-container\" #erdContainer>\n @if (isRefreshing) {\n <div class=\"loading-overlay\">\n <div class=\"loading-content\">\n <div class=\"loading-spinner\">\n <div class=\"spinner-ring\"></div>\n <div class=\"spinner-ring\"></div>\n <div class=\"spinner-ring\"></div>\n </div>\n <div class=\"loading-text\">Loading diagram...</div>\n </div>\n </div>\n }\n </div>\n</div>\n", styles: [":host {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.erd-section {\n background: white;\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.section-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n background-color: #f5f5f5;\n border-bottom: 1px solid #e0e0e0;\n flex-shrink: 0;\n}\n\n.section-header h3 {\n margin: 0;\n font-size: 14px;\n font-weight: 600;\n color: #333;\n}\n\n.erd-controls {\n display: flex;\n gap: 8px;\n}\n\n.control-btn {\n padding: 4px 8px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: white;\n cursor: pointer;\n font-size: 12px;\n transition: background-color 0.2s ease;\n}\n\n.control-btn:hover {\n background-color: #f0f0f0;\n}\n\n.control-btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.erd-container {\n flex: 1;\n position: relative;\n background-color: #fafafa;\n overflow: hidden;\n width: 100%;\n min-height: 400px;\n box-sizing: border-box;\n}\n\n.erd-container svg {\n position: absolute;\n top: 0;\n left: 0;\n width: 100% !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n\n/* Loading overlay */\n.loading-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(250, 250, 250, 0.95);\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n animation: fadeIn 0.3s ease-in-out;\n}\n\n.loading-content {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 16px;\n}\n\n.loading-spinner {\n position: relative;\n width: 60px;\n height: 60px;\n}\n\n.spinner-ring {\n position: absolute;\n width: 100%;\n height: 100%;\n border: 3px solid transparent;\n border-radius: 50%;\n animation: spin 1.5s linear infinite;\n}\n\n.spinner-ring:nth-child(1) {\n border-top-color: #2196f3;\n animation-delay: 0s;\n}\n\n.spinner-ring:nth-child(2) {\n border-top-color: #9c27b0;\n animation-delay: 0.3s;\n transform: scale(0.8);\n}\n\n.spinner-ring:nth-child(3) {\n border-top-color: #ff6b35;\n animation-delay: 0.6s;\n transform: scale(0.6);\n}\n\n.loading-text {\n font-size: 14px;\n color: #666;\n font-weight: 500;\n text-align: center;\n}\n\n/* Animations */\n@keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n\n@keyframes fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n/* Node highlighting styles */\n:host ::ng-deep .erd-container svg .node .entity-rect.highlighted {\n stroke: #ff9800 !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 8px rgba(255, 152, 0, 0.6));\n animation: pulse 1s ease-in-out infinite alternate;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-rect {\n stroke: #2196f3 !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 12px rgba(33, 150, 243, 0.8));\n fill: #f3f8ff !important;\n animation: selectedPulse 2s ease-in-out infinite alternate;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-header {\n fill: #2196f3 !important;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-header-bottom {\n fill: #2196f3 !important;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-name {\n fill: white !important;\n font-weight: bold !important;\n}\n\n@keyframes selectedPulse {\n 0% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 12px rgba(33, 150, 243, 0.8));\n }\n 100% {\n stroke-width: 5px;\n filter: drop-shadow(0 0 16px rgb(33, 150, 243));\n }\n}\n\n/* Related/connected nodes */\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-rect.relationship-highlighted {\n stroke: #ff6b35 !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.5));\n animation: entityPulse 1.5s ease-in-out infinite alternate;\n}\n\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-header {\n fill: #ff6b35 !important;\n}\n\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-header-bottom {\n fill: #ff6b35 !important;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-rect.connection-highlighted {\n stroke: #9b59b6 !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px rgba(155, 89, 182, 0.5));\n opacity: 0.9;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-header {\n fill: #9b59b6 !important;\n opacity: 0.8;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-header-bottom {\n fill: #9b59b6 !important;\n opacity: 0.8;\n}\n\n/* Link styles */\n:host ::ng-deep .erd-container svg .link-group .link {\n stroke-width: 2px;\n transition: all 0.2s ease;\n cursor: pointer;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link:hover {\n stroke-width: 3px;\n stroke: #ff6b35;\n opacity: 1;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link.highlighted {\n stroke: #ff6b35 !important;\n stroke-width: 4px !important;\n opacity: 1 !important;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.6));\n animation: linkPulse 1.5s ease-in-out infinite alternate;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link-label {\n font-family: \"Segoe UI\", Tahoma, Geneva, Verdana, sans-serif;\n opacity: 0.8;\n pointer-events: none;\n transition: opacity 0.2s ease;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link-label.highlighted {\n opacity: 1;\n font-weight: bold;\n fill: #ff6b35;\n}\n\n:host ::ng-deep .erd-container svg .link-group.highlighted {\n z-index: 10;\n}\n\n/* Field hover */\n:host ::ng-deep .erd-container svg .field-group .field-bg {\n transition: fill 0.2s;\n}\n\n:host ::ng-deep .erd-container svg .field-group:hover .field-bg {\n fill: #e3f2fd !important;\n}\n\n@keyframes pulse {\n 0% {\n stroke-width: 4px;\n }\n 100% {\n stroke-width: 6px;\n }\n}\n\n@keyframes linkPulse {\n 0% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.6));\n }\n 100% {\n stroke-width: 5px;\n filter: drop-shadow(0 0 10px rgba(255, 107, 53, 0.8));\n }\n}\n\n@keyframes entityPulse {\n 0% {\n stroke-width: 3px;\n filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.5));\n }\n 100% {\n stroke-width: 4px;\n filter: drop-shadow(0 0 8px rgba(255, 107, 53, 0.7));\n }\n}\n"] }]
1875
+ }], null, { erdContainer: [{
1876
+ type: ViewChild,
1877
+ args: ['erdContainer', { static: false }]
1878
+ }], nodes: [{
1879
+ type: Input
1880
+ }], selectedNodeId: [{
1881
+ type: Input
1882
+ }], highlightedNodeIds: [{
1883
+ type: Input
1884
+ }], focusNodeId: [{
1885
+ type: Input
1886
+ }], focusDepth: [{
1887
+ type: Input
1888
+ }], isRefreshing: [{
1889
+ type: Input
1890
+ }], readOnly: [{
1891
+ type: Input
1892
+ }], config: [{
1893
+ type: Input
1894
+ }], showHeader: [{
1895
+ type: Input
1896
+ }], headerTitle: [{
1897
+ type: Input
1898
+ }], nodeClick: [{
1899
+ type: Output
1900
+ }], nodeDoubleClick: [{
1901
+ type: Output
1902
+ }], nodeSelected: [{
1903
+ type: Output
1904
+ }], nodeDeselected: [{
1905
+ type: Output
1906
+ }], linkClick: [{
1907
+ type: Output
1908
+ }], nodeHover: [{
1909
+ type: Output
1910
+ }], nodeHoverEnd: [{
1911
+ type: Output
1912
+ }], linkHover: [{
1913
+ type: Output
1914
+ }], linkHoverEnd: [{
1915
+ type: Output
1916
+ }], nodeContextMenu: [{
1917
+ type: Output
1918
+ }], linkContextMenu: [{
1919
+ type: Output
1920
+ }], diagramContextMenu: [{
1921
+ type: Output
1922
+ }], nodeDragStart: [{
1923
+ type: Output
1924
+ }], nodeDragEnd: [{
1925
+ type: Output
1926
+ }], zoomChange: [{
1927
+ type: Output
1928
+ }], refreshRequested: [{
1929
+ type: Output
1930
+ }], layoutComplete: [{
1931
+ type: Output
1932
+ }], stateChange: [{
1933
+ type: Output
1934
+ }] }); })();
1935
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ERDDiagramComponent, { className: "ERDDiagramComponent", filePath: "src/lib/components/erd-diagram.component.ts", lineNumber: 194 }); })();
1936
+ //# sourceMappingURL=erd-diagram.component.js.map