@peter.prib/renderengine 0.13.0 → 0.15.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.
- package/README.md +480 -302
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49217 -37484
- package/dist/renderengine.umd.js +24899 -13093
- package/documentation/REFERENCE.md +68 -1
- package/package.json +13 -3
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +269 -3
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +7 -2
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +142 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +69 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +6 -4
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +25 -3
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +107 -4
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +5 -2
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +6 -3
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +12 -5
- package/src/charting/charts/RadialBarChart.js +6 -3
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +129 -6
- package/src/charting/charts/ScatterChart.js +19 -3
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +125 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
- package/src/charting/charts/StreamGraphChart.js +18 -3
- package/src/charting/charts/SunburstChart.js +28 -8
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +31 -11
- package/src/charting/charts/ViolinChart.js +20 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +310 -13
- package/src/charting/pivot/PivotGridChart.js +322 -34
- package/src/charting/pivot/PivotView.js +626 -21
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2899
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -1,1350 +1,1424 @@
|
|
|
1
|
-
import { TextStyle } from '../TextStyle.js';
|
|
2
|
-
import { Diagram } from '../Diagram.js';
|
|
3
|
-
import { CommandHistory } from '../CommandHistory.js';
|
|
4
|
-
import { GraphMetrics } from './GraphMetrics.js';
|
|
5
|
-
import { LabelHelper } from './LabelHelper.js';
|
|
6
|
-
import { NodeGeometry } from './NodeGeometry.js';
|
|
7
|
-
import { SvgCoordinate } from './SvgCoordinate.js';
|
|
8
|
-
import { EdgeGeometry } from './EdgeGeometry.js';
|
|
9
|
-
import { ContextMenu } from './ContextMenu.js';
|
|
10
|
-
import { GridOverlay } from './GridOverlay.js';
|
|
11
|
-
import { LINE_STYLE_DASH } from '../edgeStyle/EdgeStyleSchema.js';
|
|
12
|
-
import {
|
|
13
|
-
AddNodeCommand, DeleteNodeCommand,
|
|
14
|
-
AddEdgeCommand, DeleteEdgeCommand, InvertEdgeCommand,
|
|
15
|
-
RenameNodeCommand, RenameEdgeCommand,
|
|
16
|
-
AddWaypointCommand,
|
|
17
|
-
} from './commands/index.js';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* GraphDiagram — renders an interactive 2D node/edge graph diagram.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** Marks a selected line's end and bend-point handles (removed when the line is deselected). */
|
|
24
|
-
const EDGE_HANDLE_ATTR = 'data-edge-handle';
|
|
25
|
-
|
|
26
|
-
export class GraphDiagram extends Diagram {
|
|
27
|
-
/**
|
|
28
|
-
* Pure. Whether a node without a picture shows centred text: its icon text,
|
|
29
|
-
* or (standing in for a name drawn below the shape) its initials. A node
|
|
30
|
-
* whose name is written inside it needs no initials.
|
|
31
|
-
*/
|
|
32
|
-
static showsInitials(node, defaultLabelPosition = 'below') {
|
|
33
|
-
if (node.icon) return true;
|
|
34
|
-
return (node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition)) !== 'inside';
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
static diagramType = 'graph';
|
|
38
|
-
|
|
39
|
-
constructor(renderer, nodes, edges, options = {}, engine = null, folders = []) {
|
|
40
|
-
super(renderer, { nodes, edges, folders }, options);
|
|
41
|
-
this.engine = engine;
|
|
42
|
-
this._chartState = { type: 'graph', nodes, edges, folders, options };
|
|
43
|
-
this._graph = null;
|
|
44
|
-
this._graphCollapsedNodes = new Set();
|
|
45
|
-
this._graphFilterKeyword = '';
|
|
46
|
-
this._graphFixedPos = new Set();
|
|
47
|
-
this._hiddenGraphNodes = new Set();
|
|
48
|
-
this._hiddenGraphGroups = new Set();
|
|
49
|
-
this._collapsedFolders = new Set(); // Track which folders are collapsed
|
|
50
|
-
// A host that re-renders the diagram hands in its own history so undo
|
|
51
|
-
// outlives this instance; that host then owns the undo/redo keys too.
|
|
52
|
-
this.hostOwnsHistory = !!options.history;
|
|
53
|
-
this.history = options.history ?? new CommandHistory({
|
|
54
|
-
maxSize: options.historySize ?? 200,
|
|
55
|
-
onChange: (canUndo, canRedo) => {
|
|
56
|
-
options?.onHistoryChange?.(canUndo, canRedo);
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Fields that are not caller "extra data" for tooltips, search and the
|
|
63
|
-
* properties dialog: the engine's own, plus the host's `internalKeys`
|
|
64
|
-
* (bookkeeping a host adds to the items it hands in).
|
|
65
|
-
* @param {'node'|'table'|'edge'} kind
|
|
66
|
-
* @returns {Set<string>}
|
|
67
|
-
*/
|
|
68
|
-
structuralKeys(kind) {
|
|
69
|
-
const E = this.engine?.constructor ?? {};
|
|
70
|
-
const own = { node: E.NODE_STRUCTURAL_KEYS, table: E.TABLE_NODE_STRUCTURAL_KEYS, edge: E.EDGE_STRUCTURAL_KEYS }[kind];
|
|
71
|
-
return new Set([...(own ?? []), ...(this.options?.internalKeys ?? [])]);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
_render() {
|
|
75
|
-
const { nodes, edges } = this.data;
|
|
76
|
-
const options = this.options;
|
|
77
|
-
this._chartState = { type: 'graph', nodes, edges, options };
|
|
78
|
-
if (!this.renderer || !this.renderer.supportsSceneGraph) {
|
|
79
|
-
console.warn('Advanced graph diagram is only supported in SVG renderer.');
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
this.renderer.clear();
|
|
83
|
-
|
|
84
|
-
const { nodeRadius = 14, onNodeClick, onEdgeClick, legend: showLegend = false, colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452'], edgeColor: defaultEdgeColor = '#bdc3c7', contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable = false } = options;
|
|
85
|
-
const { compactness = 1.0 } = options;
|
|
86
|
-
// Default auto-routing for edges that don't set their own `edge.routing`
|
|
87
|
-
// — 'straight' (default), 'curved', 'orthogonal', or 'polyline' (basic
|
|
88
|
-
// node-avoidance). See _computeEdgePath. Editable diagrams also let
|
|
89
|
-
// the user drag a manual bend point onto any edge (double-click to
|
|
90
|
-
// add/remove, drag to reposition — see _addDragListeners), which
|
|
91
|
-
// always wins over whatever routing style is set.
|
|
92
|
-
const { edgeRouting: defaultEdgeRouting = 'straight' } = options;
|
|
93
|
-
// Resize handle (see _addResizeListeners): off unless `resizable` is
|
|
94
|
-
// true (a node can still opt out/in individually via its own
|
|
95
|
-
// `node.resizable`), corner defaults to 'top-right', overridable per
|
|
96
|
-
// shape (`resizeCornerByShape: {shape: corner}`) or per node
|
|
97
|
-
// (`node.resizeCorner`) — see _nodeResizeCorner.
|
|
98
|
-
const { resizable = false, resizeCorner = 'top-right', resizeCornerByShape } = options;
|
|
99
|
-
// Where a node's icon (icon/iconUrl, see the nodes param doc above)
|
|
100
|
-
// sits: 'center' (default — replaces the initials) or 'corner' (a
|
|
101
|
-
// small badge, positioned like the resize handle via the same
|
|
102
|
-
// node > per-shape > diagram-wide precedence — see _nodeIconCorner).
|
|
103
|
-
// A node's own `node.iconPosition` overrides this diagram default.
|
|
104
|
-
const { iconPosition: defaultIconPosition = 'center', iconCorner = 'top-right', iconCornerByShape } = options;
|
|
105
|
-
// 'below' (default, the original caption-under-the-shape convention)
|
|
106
|
-
// or 'inside' (label left-aligned inside the box — see the render
|
|
107
|
-
// loop below). A node's own `node.labelPosition` overrides this.
|
|
108
|
-
const { labelPosition: defaultLabelPosition = 'below' } = options;
|
|
109
|
-
// Horizontal alignment of the label: 'left' | 'center' | 'right'.
|
|
110
|
-
// Default keeps the existing behaviour: centred for captions below the
|
|
111
|
-
// shape, left-aligned for wrapped labels inside a box. Per-node values
|
|
112
|
-
// override the diagram default.
|
|
113
|
-
const { textAlign: defaultTextAlign = null } = options;
|
|
114
|
-
// Lines over boxes (Archi's connection layer) instead of under them.
|
|
115
|
-
const { edgesOnTop = false } = options;
|
|
116
|
-
// Vertical alignment, only meaningful for `labelPosition: 'inside'`:
|
|
117
|
-
// 'top' | 'middle' | 'bottom'. Default 'top' preserves existing layout.
|
|
118
|
-
const { verticalAlign: defaultVerticalAlign = null } = options;
|
|
119
|
-
// `sizeBy: 'degree'` sizes nodes by connection count instead of the
|
|
120
|
-
// caller pre-populating `node.size` — see _computeNodeDegrees.
|
|
121
|
-
const { minRadius = nodeRadius, maxRadius = nodeRadius * 2.5, sizeScale = 'linear', sizeBy } = options;
|
|
122
|
-
// Same pattern, for edge thickness — `edge.weight` drives stroke width.
|
|
123
|
-
const { minEdgeWidth = 1.4, maxEdgeWidth = minEdgeWidth * 4, edgeWidthScale = 'linear' } = options;
|
|
124
|
-
// Grid: `gridSize` > 0 snaps user-driven positions (drag, add node) to
|
|
125
|
-
// this many SVG units; `showGrid` draws the grid behind the diagram.
|
|
126
|
-
const { gridSize = 0, showGrid = false } = options;
|
|
127
|
-
|
|
128
|
-
// Initialize GridOverlay for responsive grid rendering
|
|
129
|
-
if (showGrid && gridSize > 0) {
|
|
130
|
-
if (!this._gridOverlay) {
|
|
131
|
-
this._gridOverlay = new GridOverlay(this.renderer.container, { gridSize, color: '#e5e5e5', width: 0.5 });
|
|
132
|
-
} else {
|
|
133
|
-
this._gridOverlay.setGridSize(gridSize);
|
|
134
|
-
}
|
|
135
|
-
} else if (this._gridOverlay) {
|
|
136
|
-
this._gridOverlay.destroy();
|
|
137
|
-
this._gridOverlay = null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const W = this.renderer.width, H = this.renderer.height;
|
|
141
|
-
const R = nodeRadius;
|
|
142
|
-
const PAD = R + 6;
|
|
143
|
-
// Degree is computed from the caller's own `edges`, not whatever
|
|
144
|
-
// `effectiveEdges` layout inference below may substitute — "number of
|
|
145
|
-
// connections" means the graph the caller described, not an internal
|
|
146
|
-
// tree-layout artifact.
|
|
147
|
-
const degreeMap = sizeBy === 'degree' ? GraphMetrics.computeNodeDegrees(nodes, edges) : null;
|
|
148
|
-
const sizeDomain = GraphMetrics.computeSizeDomain(nodes, { sizeBy, degreeMap });
|
|
149
|
-
const sizeOptions = { minRadius, maxRadius, sizeScale, sizeBy, degreeMap };
|
|
150
|
-
|
|
151
|
-
// A hierarchical-key node set (see _inferHierarchyEdges) replaces the
|
|
152
|
-
// caller's edges with the inferred parent/child structure (plus any
|
|
153
|
-
// "quasi-leaf" edges it chose to keep) and switches layout from
|
|
154
|
-
// force-directed to a top-down tree — the two layouts don't blend.
|
|
155
|
-
const hierarchyEdges = this._inferHierarchyEdges(nodes, edges);
|
|
156
|
-
const useTreeLayout = hierarchyEdges !== null;
|
|
157
|
-
const effectiveEdges = useTreeLayout ? hierarchyEdges : edges;
|
|
158
|
-
const childrenMap = useTreeLayout ? this._hierarchyChildrenMap(effectiveEdges) : null;
|
|
159
|
-
// Over effectiveEdges, not the caller's raw edges — this is the domain
|
|
160
|
-
// for what actually gets drawn below, and in tree-layout mode that's a
|
|
161
|
-
// synthesized edge set the caller's `.weight` values don't apply to.
|
|
162
|
-
const edgeWidthDomain = GraphMetrics.computeEdgeWidthDomain(effectiveEdges);
|
|
163
|
-
const edgeWidthOptions = { minEdgeWidth, maxEdgeWidth, widthScale: edgeWidthScale };
|
|
164
|
-
// Collapsed subtrees (toggled via each node's +/− badge, see nodeEls
|
|
165
|
-
// below) hide their descendants — not the collapsed node itself,
|
|
166
|
-
// whose badge must stay visible/clickable to re-expand it.
|
|
167
|
-
const hiddenByCollapse = useTreeLayout
|
|
168
|
-
? this._collapsedDescendantIds(this._graphCollapsedNodes, childrenMap)
|
|
169
|
-
: new Set();
|
|
170
|
-
// Live keyword filter (see filterGraphNodes) — recomputed every render
|
|
171
|
-
// so typing in a search box just calls filterGraphNodes and re-renders.
|
|
172
|
-
const filterKeyword = (this._graphFilterKeyword || '').trim();
|
|
173
|
-
const hiddenByFilter = this._hiddenByKeywordFilter(nodes, filterKeyword, childrenMap);
|
|
174
|
-
|
|
175
|
-
// Initialize positions, respecting any fixed positions from the input
|
|
176
|
-
this._graphFixedPos = new Set(nodes.filter(n => n.x != null || n.y != null).map(n => n.id));
|
|
177
|
-
let pos;
|
|
178
|
-
if (useTreeLayout) {
|
|
179
|
-
pos = this._computeTreeLayout(nodes, effectiveEdges, W, H, PAD);
|
|
180
|
-
nodes.forEach(n => { // an explicit x/y still overrides the computed tree slot
|
|
181
|
-
if (this._graphFixedPos.has(n.id)) {
|
|
182
|
-
const p = pos.get(n.id);
|
|
183
|
-
if (n.x != null) p.x = n.x;
|
|
184
|
-
if (n.y != null) p.y = n.y;
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
} else {
|
|
188
|
-
pos = new Map(nodes.map(n => [n.id, {
|
|
189
|
-
x: n.x ?? (Math.random() * (W - 2 * PAD) + PAD),
|
|
190
|
-
y: n.y ?? (Math.random() * (H - 2 * PAD) + PAD),
|
|
191
|
-
vx: 0, vy: 0 }]));
|
|
192
|
-
const K_FR = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.9 * compactness;
|
|
193
|
-
const frStep = (temp) => {
|
|
194
|
-
nodes.forEach(n => { if (!this._graphFixedPos.has(n.id)) { const p = pos.get(n.id); p.vx = 0; p.vy = 0; } });
|
|
195
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
196
|
-
for (let j = i + 1; j < nodes.length; j++) {
|
|
197
|
-
const a = pos.get(nodes[i].id), b = pos.get(nodes[j].id);
|
|
198
|
-
const dx = b.x - a.x, dy = b.y - a.y;
|
|
199
|
-
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
200
|
-
const f = K_FR * K_FR / dist;
|
|
201
|
-
a.vx -= dx / dist * f; a.vy -= dy / dist * f;
|
|
202
|
-
b.vx += dx / dist * f; b.vy += dy / dist * f;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
effectiveEdges.forEach(e => {
|
|
206
|
-
const a = pos.get(e.source), b = pos.get(e.target);
|
|
207
|
-
if (!a || !b) return;
|
|
208
|
-
const dx = b.x - a.x, dy = b.y - a.y;
|
|
209
|
-
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
210
|
-
const f = (dist * dist) / (K_FR * 1.5);
|
|
211
|
-
a.vx += dx / dist * f; a.vy += dy / dist * f;
|
|
212
|
-
b.vx -= dx / dist * f; b.vy -= dy / dist * f;
|
|
213
|
-
});
|
|
214
|
-
nodes.forEach(n => {
|
|
215
|
-
if (this._graphFixedPos.has(n.id)) return;
|
|
216
|
-
const p = pos.get(n.id);
|
|
217
|
-
p.vx += (W / 2 - p.x) * 0.006;
|
|
218
|
-
p.vy += (H / 2 - p.y) * 0.006;
|
|
219
|
-
const mag = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1;
|
|
220
|
-
const dt = Math.min(mag, temp) / mag;
|
|
221
|
-
p.x = Math.max(PAD, Math.min(W - PAD, p.x + p.vx * dt));
|
|
222
|
-
p.y = Math.max(PAD, Math.min(H - PAD, p.y + p.vy * dt));
|
|
223
|
-
});
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
if (!this._graph) { // Only run full simulation on initial draw
|
|
227
|
-
// Run simulation synchronously before drawing
|
|
228
|
-
for (let iter = 0; iter < 180; iter++) {
|
|
229
|
-
frStep(K_FR * (1 - iter / 180) * 0.25);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Other nodes as circular obstacles for 'polyline' routing (see
|
|
235
|
-
// _polylineEdgePath) — computed once per render, source/target of
|
|
236
|
-
// each edge excluded per-edge below rather than filtered out here.
|
|
237
|
-
const routingObstacles = nodes.map(n => {
|
|
238
|
-
const p = pos.get(n.id);
|
|
239
|
-
if (!p) return null;
|
|
240
|
-
const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
|
|
241
|
-
return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
|
|
242
|
-
}).filter(Boolean);
|
|
243
|
-
|
|
244
|
-
// The selected line's handles are drawn after the nodes: an end handle
|
|
245
|
-
// sits on a node's rim, and a node drawn over it would hide half of it.
|
|
246
|
-
const selectionHandles = [];
|
|
247
|
-
const svgRoot = this.renderer.svg;
|
|
248
|
-
const edgesFrom = svgRoot?.childNodes?.length ?? 0;
|
|
249
|
-
const edgeEls = effectiveEdges.map(edge => {
|
|
250
|
-
const sourceNode = pos.get(edge.source);
|
|
251
|
-
const targetNode = pos.get(edge.target);
|
|
252
|
-
if (!sourceNode || !targetNode) return null;
|
|
253
|
-
const sourceInfo = nodes.find(n => n.id === edge.source);
|
|
254
|
-
const targetInfo = nodes.find(n => n.id === edge.target);
|
|
255
|
-
const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
|
|
256
|
-
const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
|
|
257
|
-
const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
|
|
258
|
-
|
|
259
|
-
const { x1, y1, x2, y2, d } = EdgeGeometry.computeEdgePath(
|
|
260
|
-
sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const edgeColor = edge.color || defaultEdgeColor;
|
|
264
|
-
const arrowhead = edge.arrowhead || 'arrow'; // default to 'arrow'
|
|
265
|
-
const markerId = arrowhead === 'none' ? '' : this.renderer.markerUrl(arrowhead);
|
|
266
|
-
const edgeWidth = GraphMetrics.effectiveEdgeWidth(edge, 1.4, edgeWidthDomain, edgeWidthOptions);
|
|
267
|
-
const path = this.renderer.path(d, 'none', { color: edgeColor, width: edgeWidth }, { isEdge: true, edge, tooltip: this._edgeTooltipText(edge) });
|
|
268
|
-
if (edge.id) path.setAttribute('edgeId', edge.id);
|
|
269
|
-
if (edge.opacity != null && edge.opacity !== 1) {
|
|
270
|
-
path.setAttribute('opacity', String(edge.opacity));
|
|
271
|
-
}
|
|
272
|
-
if (markerId) path.setAttribute('marker-end', markerId);
|
|
273
|
-
// marker-start — a second, independent arrowhead/glyph at the
|
|
274
|
-
// SOURCE end (e.g. ArchiMate's composition/aggregation diamond,
|
|
275
|
-
// or assignment's filled ball, both drawn at the "whole"/source
|
|
276
|
-
// end while marker-end carries a different glyph or none at all).
|
|
277
|
-
// Existing edges that only ever set `arrowhead` are unaffected —
|
|
278
|
-
// this is purely additive.
|
|
279
|
-
if (edge.arrowheadStart && edge.arrowheadStart !== 'none') path.setAttribute('marker-start', this.renderer.markerUrl(edge.arrowheadStart));
|
|
280
|
-
// `lineStyle` ('solid'|'dashed'|'dotted') is the general form of
|
|
281
|
-
// the older boolean `edge.dashed` (kept working, still meaning
|
|
282
|
-
// 'dashed', for callers who never adopted lineStyle) — 'dotted'
|
|
283
|
-
// is the extra case neither `dashed` nor the old fixed pattern
|
|
284
|
-
// could express (ArchiMate's realization/access relationships).
|
|
285
|
-
const dashArray = LINE_STYLE_DASH[edge.lineStyle ?? (edge.dashed ? 'dashed' : 'solid')] ?? null;
|
|
286
|
-
if (dashArray) path.setAttribute('stroke-dasharray', dashArray);
|
|
287
|
-
// onEdgeClick was accepted as an option but never actually wired
|
|
288
|
-
// up to anything — direct DOM listener, same pattern node click
|
|
289
|
-
// already uses (bubbles fine regardless of hitTest).
|
|
290
|
-
if (onEdgeClick) path.style.cursor = 'pointer';
|
|
291
|
-
if (onEdgeClick) path.addEventListener('click', () => onEdgeClick(edge));
|
|
292
|
-
|
|
293
|
-
// Track edge selection for waypoint visibility (only show waypoints on selected edge)
|
|
294
|
-
path.addEventListener('click', (e) => {
|
|
295
|
-
e.stopPropagation(); // Prevent canvas deselection
|
|
296
|
-
this._selectedEdge = edge;
|
|
297
|
-
this._rerenderGraph(); // Redraw to update waypoint visibility
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// A collapsed ancestor or the keyword filter hides some nodes
|
|
301
|
-
// (see nodeEls below) — hide their connecting edges too, or
|
|
302
|
-
// they'd dangle toward an invisible node instead of disappearing
|
|
303
|
-
// with it.
|
|
304
|
-
const edgeHidden = hiddenByCollapse.has(edge.source) || hiddenByCollapse.has(edge.target)
|
|
305
|
-
|| hiddenByFilter.has(edge.source) || hiddenByFilter.has(edge.target);
|
|
306
|
-
if (edgeHidden) path.setAttribute('visibility', 'hidden');
|
|
307
|
-
|
|
308
|
-
if (edge.label) {
|
|
309
|
-
const midX = (x1 + x2) / 2;
|
|
310
|
-
const midY = (y1 + y2) / 2;
|
|
311
|
-
const defaultTooltip = `${sourceInfo.label} → ${targetInfo.label}: ${edge.label}`;
|
|
312
|
-
|
|
313
|
-
const labelEl = this.renderer.text(edge.label, midX, midY, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', tooltip: edge.tooltip || defaultTooltip });
|
|
314
|
-
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Independent end labels — a UML association's multiplicity
|
|
318
|
-
// ('0..1', '1..*', ...) shown near EACH end, distinct from the
|
|
319
|
-
// one midpoint `edge.label` above (e.g. the relationship's own
|
|
320
|
-
// name) — see dataModelDiagram's 'uml' notation, though this is
|
|
321
|
-
// a plain graphDiagram field, not data-model-specific. Placed
|
|
322
|
-
// 18% of the way in from each anchor, not right at it, so the
|
|
323
|
-
// text doesn't sit on top of an arrowhead/cardinality glyph.
|
|
324
|
-
if (edge.sourceLabel) {
|
|
325
|
-
const lx = x1 + (x2 - x1) * 0.18, ly = y1 + (y2 - y1) * 0.18;
|
|
326
|
-
const labelEl = this.renderer.text(edge.sourceLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
|
|
327
|
-
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
328
|
-
}
|
|
329
|
-
if (edge.targetLabel) {
|
|
330
|
-
const lx = x2 + (x1 - x2) * 0.18, ly = y2 + (y1 - y2) * 0.18;
|
|
331
|
-
const labelEl = this.renderer.text(edge.targetLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
|
|
332
|
-
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Manual bend points (see _computeEdgePath's waypoints branch and
|
|
336
|
-
// the drag/double-click handlers in _addDragListeners) only need a
|
|
337
|
-
// visible/draggable handle in editable mode — a non-editable
|
|
338
|
-
// diagram still routes through them, it just can't gain new ones.
|
|
339
|
-
// Only show waypoints when this edge is selected (reduces clutter).
|
|
340
|
-
if (editable && this._selectedEdge === edge) selectionHandles.push(() => {
|
|
341
|
-
// Endpoint handles (source and target) — red circles, draggable to reposition on node rim
|
|
342
|
-
const endpointHandleColor = '#ff6b6b';
|
|
343
|
-
{
|
|
344
|
-
const sourceHandle = this.renderer.circle(x1, y1, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'source' });
|
|
345
|
-
sourceHandle.style.cursor = 'grab';
|
|
346
|
-
sourceHandle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
347
|
-
if (edgeHidden) sourceHandle.setAttribute('visibility', 'hidden');
|
|
348
|
-
|
|
349
|
-
const targetHandle = this.renderer.circle(x2, y2, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'target' });
|
|
350
|
-
targetHandle.style.cursor = 'grab';
|
|
351
|
-
targetHandle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
352
|
-
if (edgeHidden) targetHandle.setAttribute('visibility', 'hidden');
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Waypoint handles (blue circles for manual bends)
|
|
356
|
-
if (edge.waypoints?.length) {
|
|
357
|
-
edge.waypoints.forEach((wp, waypointIndex) => {
|
|
358
|
-
const handle = this.renderer.circle(wp.x, wp.y, 4, '#ffffff', { color: '#4f8ef7', width: 2 }, { draggable: true, isWaypoint: true, edge, waypointIndex });
|
|
359
|
-
handle.style.cursor = 'move';
|
|
360
|
-
handle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
361
|
-
if (edgeHidden) handle.setAttribute('visibility', 'hidden');
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
});
|
|
365
|
-
return path;
|
|
366
|
-
}).filter(Boolean);
|
|
367
|
-
|
|
368
|
-
// Draw edge-crossing bridges (small arcs where edges cross)
|
|
369
|
-
const edgePaths = edgeEls.map(el => el ? el.getAttribute('d') : null).filter(Boolean);
|
|
370
|
-
if (edgePaths.length > 1) {
|
|
371
|
-
// Detect all pairwise crossings
|
|
372
|
-
const allCrossings = [];
|
|
373
|
-
for (let i = 0; i < edgePaths.length; i++) {
|
|
374
|
-
for (let j = i + 1; j < edgePaths.length; j++) {
|
|
375
|
-
const crossings = EdgeGeometry.findEdgeCrossings(edgePaths[i], [edgePaths[j]]);
|
|
376
|
-
allCrossings.push(...crossings);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Draw bridges at each crossing (small semicircle arcs)
|
|
381
|
-
for (const crossing of allCrossings) {
|
|
382
|
-
const bridgeRadius = 6;
|
|
383
|
-
const dx = Math.cos(crossing.angle);
|
|
384
|
-
const dy = Math.sin(crossing.angle);
|
|
385
|
-
const px = crossing.x - dy * bridgeRadius;
|
|
386
|
-
const py = crossing.y + dx * bridgeRadius;
|
|
387
|
-
const qx = crossing.x + dy * bridgeRadius;
|
|
388
|
-
const qy = crossing.y - dx * bridgeRadius;
|
|
389
|
-
|
|
390
|
-
const bridgePath = `M${px},${py} A${bridgeRadius},${bridgeRadius} 0 0,1 ${qx},${qy}`;
|
|
391
|
-
this.renderer.path(bridgePath, 'none', {
|
|
392
|
-
color: '#ffffff',
|
|
393
|
-
width: 8,
|
|
394
|
-
opacity: 0.8
|
|
395
|
-
});
|
|
396
|
-
// Draw a thin line on top for visibility
|
|
397
|
-
this.renderer.path(bridgePath, 'none', {
|
|
398
|
-
color: '#bdc3c7',
|
|
399
|
-
width: 2,
|
|
400
|
-
opacity: 1
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const groups = new Map();
|
|
406
|
-
nodes.forEach(node => {
|
|
407
|
-
if (node.group && !groups.has(node.group)) {
|
|
408
|
-
groups.set(node.group, colors[groups.size % colors.length]);
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// Get nodes hidden by collapsed folders
|
|
413
|
-
const { folders = [] } = this.data;
|
|
414
|
-
const hiddenByCollapsedFolders = this._getHiddenByCollapsedFolders(folders);
|
|
415
|
-
|
|
416
|
-
// Filter out nodes from hidden groups, collapsed subtrees, keyword filter, and collapsed folders
|
|
417
|
-
const visibleNodes = nodes.filter(node =>
|
|
418
|
-
(!node.group || !this._hiddenGraphGroups.has(node.group)) && !this._hiddenGraphNodes.has(node.id)
|
|
419
|
-
&& !hiddenByCollapse.has(node.id) && !hiddenByFilter.has(node.id)
|
|
420
|
-
&& !hiddenByCollapsedFolders.has(node.id)
|
|
421
|
-
);
|
|
422
|
-
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
|
|
423
|
-
const visibleEdges = effectiveEdges.filter(edge => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
|
|
424
|
-
|
|
425
|
-
const edgesTo = svgRoot?.childNodes?.length ?? 0;
|
|
426
|
-
// Render folders (visual containers) before nodes so they appear behind
|
|
427
|
-
this._renderFolders(this.renderer.svg, folders);
|
|
428
|
-
|
|
429
|
-
const nodeEls = nodes.map(node => {
|
|
430
|
-
const isHidden = this._hiddenGraphGroups.has(node.group) || this._hiddenGraphNodes.has(node.id)
|
|
431
|
-
|| hiddenByCollapse.has(node.id) || hiddenByFilter.has(node.id)
|
|
432
|
-
|| hiddenByCollapsedFolders.has(node.id);
|
|
433
|
-
const p = pos.get(node.id);
|
|
434
|
-
|
|
435
|
-
// Table nodes previously got just the bare label as a tooltip —
|
|
436
|
-
// real detail (column/row counts) already exists on the node
|
|
437
|
-
// object at this point but wasn't being surfaced on hover. Either
|
|
438
|
-
// way, any extra caller-supplied field not in the structural set
|
|
439
|
-
// (e.g. {owner:'Alice'}) is appended as its own "key: value" line.
|
|
440
|
-
const resolvedLabel = LabelHelper.resolveNodeLabel(node);
|
|
441
|
-
const tooltip = node.shape === 'table'
|
|
442
|
-
? [
|
|
443
|
-
`${resolvedLabel} — ${node.columns?.length ?? 0} column${node.columns?.length === 1 ? '' : 's'}` +
|
|
444
|
-
(Number.isFinite(node.rowCount) ? ` · ${node.rowCount.toLocaleString()} row${node.rowCount === 1 ? '' : 's'}` : ''),
|
|
445
|
-
...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('table')),
|
|
446
|
-
].join('\n')
|
|
447
|
-
: [`Node ${resolvedLabel}`, ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('node'))].join('\n');
|
|
448
|
-
const g = this.renderer.group({ transform: `translate(${p.x},${p.y})`, draggable: true, nodeId: node.id, tooltip });
|
|
449
|
-
if (node.opacity != null && node.opacity !== 1) {
|
|
450
|
-
g.setAttribute('opacity', String(node.opacity));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (isHidden) {
|
|
454
|
-
g.setAttribute('visibility', 'hidden');
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (node.shape === 'table') {
|
|
458
|
-
NodeGeometry.buildTableNodeContent(this.renderer, g, node);
|
|
459
|
-
} else {
|
|
460
|
-
const nodeColor = node.color || (node.group ? groups.get(node.group) : (options.nodeColor || '#2ecc71'));
|
|
461
|
-
// A 'rect' node's own width/height (like a table node's, see
|
|
462
|
-
// _nodeSize) override the plain square-of-radius box every
|
|
463
|
-
// other shape still uses — needed for ArchiMate-style wider-
|
|
464
|
-
// than-tall boxes (archimateDiagram). nodeR (still used below
|
|
465
|
-
// for icon sizing/badge placement on non-rect shapes) is the
|
|
466
|
-
// radius that SAME square would have had, so nothing changes
|
|
467
|
-
// for a 'rect' node that never sets width/height.
|
|
468
|
-
const nodeSize = NodeGeometry.nodeSize(node, R, sizeDomain, sizeOptions);
|
|
469
|
-
const nodeR = Math.max(nodeSize.w, nodeSize.h) / 2;
|
|
470
|
-
const { el: shapeEl, suppressInner } = NodeGeometry.nodeShapeEl(this.renderer, node.shape, nodeR, nodeColor, nodeSize, node.cornerStyle, node.strokeColor, node.strokeWidth, node.iconUrl, node.icon);
|
|
471
|
-
if (node.strokeDasharray) shapeEl.setAttribute?.('stroke-dasharray', node.strokeDasharray);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
// that
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
});
|
|
781
|
-
return
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
return GraphDiagram.
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
static
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
static
|
|
800
|
-
const hidden = new Set();
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
if (!
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
return
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
})
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
)
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
id
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
this.
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
);
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
);
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
this.
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
if (
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
const
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1
|
+
import { TextStyle } from '../TextStyle.js';
|
|
2
|
+
import { Diagram } from '../Diagram.js';
|
|
3
|
+
import { CommandHistory } from '../CommandHistory.js';
|
|
4
|
+
import { GraphMetrics } from './GraphMetrics.js';
|
|
5
|
+
import { LabelHelper } from './LabelHelper.js';
|
|
6
|
+
import { NodeGeometry } from './NodeGeometry.js';
|
|
7
|
+
import { SvgCoordinate } from './SvgCoordinate.js';
|
|
8
|
+
import { EdgeGeometry } from './EdgeGeometry.js';
|
|
9
|
+
import { ContextMenu } from './ContextMenu.js';
|
|
10
|
+
import { GridOverlay } from './GridOverlay.js';
|
|
11
|
+
import { LINE_STYLE_DASH } from '../edgeStyle/EdgeStyleSchema.js';
|
|
12
|
+
import {
|
|
13
|
+
AddNodeCommand, DeleteNodeCommand,
|
|
14
|
+
AddEdgeCommand, DeleteEdgeCommand, InvertEdgeCommand,
|
|
15
|
+
RenameNodeCommand, RenameEdgeCommand,
|
|
16
|
+
AddWaypointCommand,
|
|
17
|
+
} from './commands/index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GraphDiagram — renders an interactive 2D node/edge graph diagram.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Marks a selected line's end and bend-point handles (removed when the line is deselected). */
|
|
24
|
+
const EDGE_HANDLE_ATTR = 'data-edge-handle';
|
|
25
|
+
|
|
26
|
+
export class GraphDiagram extends Diagram {
|
|
27
|
+
/**
|
|
28
|
+
* Pure. Whether a node without a picture shows centred text: its icon text,
|
|
29
|
+
* or (standing in for a name drawn below the shape) its initials. A node
|
|
30
|
+
* whose name is written inside it needs no initials.
|
|
31
|
+
*/
|
|
32
|
+
static showsInitials(node, defaultLabelPosition = 'below') {
|
|
33
|
+
if (node.icon) return true;
|
|
34
|
+
return (node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition)) !== 'inside';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static diagramType = 'graph';
|
|
38
|
+
|
|
39
|
+
constructor(renderer, nodes, edges, options = {}, engine = null, folders = []) {
|
|
40
|
+
super(renderer, { nodes, edges, folders }, options);
|
|
41
|
+
this.engine = engine;
|
|
42
|
+
this._chartState = { type: 'graph', nodes, edges, folders, options };
|
|
43
|
+
this._graph = null;
|
|
44
|
+
this._graphCollapsedNodes = new Set();
|
|
45
|
+
this._graphFilterKeyword = '';
|
|
46
|
+
this._graphFixedPos = new Set();
|
|
47
|
+
this._hiddenGraphNodes = new Set();
|
|
48
|
+
this._hiddenGraphGroups = new Set();
|
|
49
|
+
this._collapsedFolders = new Set(); // Track which folders are collapsed
|
|
50
|
+
// A host that re-renders the diagram hands in its own history so undo
|
|
51
|
+
// outlives this instance; that host then owns the undo/redo keys too.
|
|
52
|
+
this.hostOwnsHistory = !!options.history;
|
|
53
|
+
this.history = options.history ?? new CommandHistory({
|
|
54
|
+
maxSize: options.historySize ?? 200,
|
|
55
|
+
onChange: (canUndo, canRedo) => {
|
|
56
|
+
options?.onHistoryChange?.(canUndo, canRedo);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fields that are not caller "extra data" for tooltips, search and the
|
|
63
|
+
* properties dialog: the engine's own, plus the host's `internalKeys`
|
|
64
|
+
* (bookkeeping a host adds to the items it hands in).
|
|
65
|
+
* @param {'node'|'table'|'edge'} kind
|
|
66
|
+
* @returns {Set<string>}
|
|
67
|
+
*/
|
|
68
|
+
structuralKeys(kind) {
|
|
69
|
+
const E = this.engine?.constructor ?? {};
|
|
70
|
+
const own = { node: E.NODE_STRUCTURAL_KEYS, table: E.TABLE_NODE_STRUCTURAL_KEYS, edge: E.EDGE_STRUCTURAL_KEYS }[kind];
|
|
71
|
+
return new Set([...(own ?? []), ...(this.options?.internalKeys ?? [])]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_render() {
|
|
75
|
+
const { nodes, edges } = this.data;
|
|
76
|
+
const options = this.options;
|
|
77
|
+
this._chartState = { type: 'graph', nodes, edges, options };
|
|
78
|
+
if (!this.renderer || !this.renderer.supportsSceneGraph) {
|
|
79
|
+
console.warn('Advanced graph diagram is only supported in SVG renderer.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.renderer.clear();
|
|
83
|
+
|
|
84
|
+
const { nodeRadius = 14, onNodeClick, onEdgeClick, legend: showLegend = false, colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452'], edgeColor: defaultEdgeColor = '#bdc3c7', contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable = false } = options;
|
|
85
|
+
const { compactness = 1.0 } = options;
|
|
86
|
+
// Default auto-routing for edges that don't set their own `edge.routing`
|
|
87
|
+
// — 'straight' (default), 'curved', 'orthogonal', or 'polyline' (basic
|
|
88
|
+
// node-avoidance). See _computeEdgePath. Editable diagrams also let
|
|
89
|
+
// the user drag a manual bend point onto any edge (double-click to
|
|
90
|
+
// add/remove, drag to reposition — see _addDragListeners), which
|
|
91
|
+
// always wins over whatever routing style is set.
|
|
92
|
+
const { edgeRouting: defaultEdgeRouting = 'straight' } = options;
|
|
93
|
+
// Resize handle (see _addResizeListeners): off unless `resizable` is
|
|
94
|
+
// true (a node can still opt out/in individually via its own
|
|
95
|
+
// `node.resizable`), corner defaults to 'top-right', overridable per
|
|
96
|
+
// shape (`resizeCornerByShape: {shape: corner}`) or per node
|
|
97
|
+
// (`node.resizeCorner`) — see _nodeResizeCorner.
|
|
98
|
+
const { resizable = false, resizeCorner = 'top-right', resizeCornerByShape } = options;
|
|
99
|
+
// Where a node's icon (icon/iconUrl, see the nodes param doc above)
|
|
100
|
+
// sits: 'center' (default — replaces the initials) or 'corner' (a
|
|
101
|
+
// small badge, positioned like the resize handle via the same
|
|
102
|
+
// node > per-shape > diagram-wide precedence — see _nodeIconCorner).
|
|
103
|
+
// A node's own `node.iconPosition` overrides this diagram default.
|
|
104
|
+
const { iconPosition: defaultIconPosition = 'center', iconCorner = 'top-right', iconCornerByShape } = options;
|
|
105
|
+
// 'below' (default, the original caption-under-the-shape convention)
|
|
106
|
+
// or 'inside' (label left-aligned inside the box — see the render
|
|
107
|
+
// loop below). A node's own `node.labelPosition` overrides this.
|
|
108
|
+
const { labelPosition: defaultLabelPosition = 'below' } = options;
|
|
109
|
+
// Horizontal alignment of the label: 'left' | 'center' | 'right'.
|
|
110
|
+
// Default keeps the existing behaviour: centred for captions below the
|
|
111
|
+
// shape, left-aligned for wrapped labels inside a box. Per-node values
|
|
112
|
+
// override the diagram default.
|
|
113
|
+
const { textAlign: defaultTextAlign = null } = options;
|
|
114
|
+
// Lines over boxes (Archi's connection layer) instead of under them.
|
|
115
|
+
const { edgesOnTop = false } = options;
|
|
116
|
+
// Vertical alignment, only meaningful for `labelPosition: 'inside'`:
|
|
117
|
+
// 'top' | 'middle' | 'bottom'. Default 'top' preserves existing layout.
|
|
118
|
+
const { verticalAlign: defaultVerticalAlign = null } = options;
|
|
119
|
+
// `sizeBy: 'degree'` sizes nodes by connection count instead of the
|
|
120
|
+
// caller pre-populating `node.size` — see _computeNodeDegrees.
|
|
121
|
+
const { minRadius = nodeRadius, maxRadius = nodeRadius * 2.5, sizeScale = 'linear', sizeBy } = options;
|
|
122
|
+
// Same pattern, for edge thickness — `edge.weight` drives stroke width.
|
|
123
|
+
const { minEdgeWidth = 1.4, maxEdgeWidth = minEdgeWidth * 4, edgeWidthScale = 'linear' } = options;
|
|
124
|
+
// Grid: `gridSize` > 0 snaps user-driven positions (drag, add node) to
|
|
125
|
+
// this many SVG units; `showGrid` draws the grid behind the diagram.
|
|
126
|
+
const { gridSize = 0, showGrid = false } = options;
|
|
127
|
+
|
|
128
|
+
// Initialize GridOverlay for responsive grid rendering
|
|
129
|
+
if (showGrid && gridSize > 0) {
|
|
130
|
+
if (!this._gridOverlay) {
|
|
131
|
+
this._gridOverlay = new GridOverlay(this.renderer.container, { gridSize, color: '#e5e5e5', width: 0.5 });
|
|
132
|
+
} else {
|
|
133
|
+
this._gridOverlay.setGridSize(gridSize);
|
|
134
|
+
}
|
|
135
|
+
} else if (this._gridOverlay) {
|
|
136
|
+
this._gridOverlay.destroy();
|
|
137
|
+
this._gridOverlay = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const W = this.renderer.width, H = this.renderer.height;
|
|
141
|
+
const R = nodeRadius;
|
|
142
|
+
const PAD = R + 6;
|
|
143
|
+
// Degree is computed from the caller's own `edges`, not whatever
|
|
144
|
+
// `effectiveEdges` layout inference below may substitute — "number of
|
|
145
|
+
// connections" means the graph the caller described, not an internal
|
|
146
|
+
// tree-layout artifact.
|
|
147
|
+
const degreeMap = sizeBy === 'degree' ? GraphMetrics.computeNodeDegrees(nodes, edges) : null;
|
|
148
|
+
const sizeDomain = GraphMetrics.computeSizeDomain(nodes, { sizeBy, degreeMap });
|
|
149
|
+
const sizeOptions = { minRadius, maxRadius, sizeScale, sizeBy, degreeMap };
|
|
150
|
+
|
|
151
|
+
// A hierarchical-key node set (see _inferHierarchyEdges) replaces the
|
|
152
|
+
// caller's edges with the inferred parent/child structure (plus any
|
|
153
|
+
// "quasi-leaf" edges it chose to keep) and switches layout from
|
|
154
|
+
// force-directed to a top-down tree — the two layouts don't blend.
|
|
155
|
+
const hierarchyEdges = this._inferHierarchyEdges(nodes, edges);
|
|
156
|
+
const useTreeLayout = hierarchyEdges !== null;
|
|
157
|
+
const effectiveEdges = useTreeLayout ? hierarchyEdges : edges;
|
|
158
|
+
const childrenMap = useTreeLayout ? this._hierarchyChildrenMap(effectiveEdges) : null;
|
|
159
|
+
// Over effectiveEdges, not the caller's raw edges — this is the domain
|
|
160
|
+
// for what actually gets drawn below, and in tree-layout mode that's a
|
|
161
|
+
// synthesized edge set the caller's `.weight` values don't apply to.
|
|
162
|
+
const edgeWidthDomain = GraphMetrics.computeEdgeWidthDomain(effectiveEdges);
|
|
163
|
+
const edgeWidthOptions = { minEdgeWidth, maxEdgeWidth, widthScale: edgeWidthScale };
|
|
164
|
+
// Collapsed subtrees (toggled via each node's +/− badge, see nodeEls
|
|
165
|
+
// below) hide their descendants — not the collapsed node itself,
|
|
166
|
+
// whose badge must stay visible/clickable to re-expand it.
|
|
167
|
+
const hiddenByCollapse = useTreeLayout
|
|
168
|
+
? this._collapsedDescendantIds(this._graphCollapsedNodes, childrenMap)
|
|
169
|
+
: new Set();
|
|
170
|
+
// Live keyword filter (see filterGraphNodes) — recomputed every render
|
|
171
|
+
// so typing in a search box just calls filterGraphNodes and re-renders.
|
|
172
|
+
const filterKeyword = (this._graphFilterKeyword || '').trim();
|
|
173
|
+
const hiddenByFilter = this._hiddenByKeywordFilter(nodes, filterKeyword, childrenMap);
|
|
174
|
+
|
|
175
|
+
// Initialize positions, respecting any fixed positions from the input
|
|
176
|
+
this._graphFixedPos = new Set(nodes.filter(n => n.x != null || n.y != null).map(n => n.id));
|
|
177
|
+
let pos;
|
|
178
|
+
if (useTreeLayout) {
|
|
179
|
+
pos = this._computeTreeLayout(nodes, effectiveEdges, W, H, PAD);
|
|
180
|
+
nodes.forEach(n => { // an explicit x/y still overrides the computed tree slot
|
|
181
|
+
if (this._graphFixedPos.has(n.id)) {
|
|
182
|
+
const p = pos.get(n.id);
|
|
183
|
+
if (n.x != null) p.x = n.x;
|
|
184
|
+
if (n.y != null) p.y = n.y;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
pos = new Map(nodes.map(n => [n.id, {
|
|
189
|
+
x: n.x ?? (Math.random() * (W - 2 * PAD) + PAD),
|
|
190
|
+
y: n.y ?? (Math.random() * (H - 2 * PAD) + PAD),
|
|
191
|
+
vx: 0, vy: 0 }]));
|
|
192
|
+
const K_FR = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.9 * compactness;
|
|
193
|
+
const frStep = (temp) => {
|
|
194
|
+
nodes.forEach(n => { if (!this._graphFixedPos.has(n.id)) { const p = pos.get(n.id); p.vx = 0; p.vy = 0; } });
|
|
195
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
196
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
197
|
+
const a = pos.get(nodes[i].id), b = pos.get(nodes[j].id);
|
|
198
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
199
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
200
|
+
const f = K_FR * K_FR / dist;
|
|
201
|
+
a.vx -= dx / dist * f; a.vy -= dy / dist * f;
|
|
202
|
+
b.vx += dx / dist * f; b.vy += dy / dist * f;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
effectiveEdges.forEach(e => {
|
|
206
|
+
const a = pos.get(e.source), b = pos.get(e.target);
|
|
207
|
+
if (!a || !b) return;
|
|
208
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
209
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
210
|
+
const f = (dist * dist) / (K_FR * 1.5);
|
|
211
|
+
a.vx += dx / dist * f; a.vy += dy / dist * f;
|
|
212
|
+
b.vx -= dx / dist * f; b.vy -= dy / dist * f;
|
|
213
|
+
});
|
|
214
|
+
nodes.forEach(n => {
|
|
215
|
+
if (this._graphFixedPos.has(n.id)) return;
|
|
216
|
+
const p = pos.get(n.id);
|
|
217
|
+
p.vx += (W / 2 - p.x) * 0.006;
|
|
218
|
+
p.vy += (H / 2 - p.y) * 0.006;
|
|
219
|
+
const mag = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1;
|
|
220
|
+
const dt = Math.min(mag, temp) / mag;
|
|
221
|
+
p.x = Math.max(PAD, Math.min(W - PAD, p.x + p.vx * dt));
|
|
222
|
+
p.y = Math.max(PAD, Math.min(H - PAD, p.y + p.vy * dt));
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!this._graph) { // Only run full simulation on initial draw
|
|
227
|
+
// Run simulation synchronously before drawing
|
|
228
|
+
for (let iter = 0; iter < 180; iter++) {
|
|
229
|
+
frStep(K_FR * (1 - iter / 180) * 0.25);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Other nodes as circular obstacles for 'polyline' routing (see
|
|
235
|
+
// _polylineEdgePath) — computed once per render, source/target of
|
|
236
|
+
// each edge excluded per-edge below rather than filtered out here.
|
|
237
|
+
const routingObstacles = nodes.map(n => {
|
|
238
|
+
const p = pos.get(n.id);
|
|
239
|
+
if (!p) return null;
|
|
240
|
+
const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
|
|
241
|
+
return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
|
|
242
|
+
}).filter(Boolean);
|
|
243
|
+
|
|
244
|
+
// The selected line's handles are drawn after the nodes: an end handle
|
|
245
|
+
// sits on a node's rim, and a node drawn over it would hide half of it.
|
|
246
|
+
const selectionHandles = [];
|
|
247
|
+
const svgRoot = this.renderer.svg;
|
|
248
|
+
const edgesFrom = svgRoot?.childNodes?.length ?? 0;
|
|
249
|
+
const edgeEls = effectiveEdges.map(edge => {
|
|
250
|
+
const sourceNode = pos.get(edge.source);
|
|
251
|
+
const targetNode = pos.get(edge.target);
|
|
252
|
+
if (!sourceNode || !targetNode) return null;
|
|
253
|
+
const sourceInfo = nodes.find(n => n.id === edge.source);
|
|
254
|
+
const targetInfo = nodes.find(n => n.id === edge.target);
|
|
255
|
+
const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
|
|
256
|
+
const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
|
|
257
|
+
const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
|
|
258
|
+
|
|
259
|
+
const { x1, y1, x2, y2, d } = EdgeGeometry.computeEdgePath(
|
|
260
|
+
sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const edgeColor = edge.color || defaultEdgeColor;
|
|
264
|
+
const arrowhead = edge.arrowhead || 'arrow'; // default to 'arrow'
|
|
265
|
+
const markerId = arrowhead === 'none' ? '' : this.renderer.markerUrl(arrowhead);
|
|
266
|
+
const edgeWidth = GraphMetrics.effectiveEdgeWidth(edge, 1.4, edgeWidthDomain, edgeWidthOptions);
|
|
267
|
+
const path = this.renderer.path(d, 'none', { color: edgeColor, width: edgeWidth }, { isEdge: true, edge, tooltip: this._edgeTooltipText(edge) });
|
|
268
|
+
if (edge.id) path.setAttribute('edgeId', edge.id);
|
|
269
|
+
if (edge.opacity != null && edge.opacity !== 1) {
|
|
270
|
+
path.setAttribute('opacity', String(edge.opacity));
|
|
271
|
+
}
|
|
272
|
+
if (markerId) path.setAttribute('marker-end', markerId);
|
|
273
|
+
// marker-start — a second, independent arrowhead/glyph at the
|
|
274
|
+
// SOURCE end (e.g. ArchiMate's composition/aggregation diamond,
|
|
275
|
+
// or assignment's filled ball, both drawn at the "whole"/source
|
|
276
|
+
// end while marker-end carries a different glyph or none at all).
|
|
277
|
+
// Existing edges that only ever set `arrowhead` are unaffected —
|
|
278
|
+
// this is purely additive.
|
|
279
|
+
if (edge.arrowheadStart && edge.arrowheadStart !== 'none') path.setAttribute('marker-start', this.renderer.markerUrl(edge.arrowheadStart));
|
|
280
|
+
// `lineStyle` ('solid'|'dashed'|'dotted') is the general form of
|
|
281
|
+
// the older boolean `edge.dashed` (kept working, still meaning
|
|
282
|
+
// 'dashed', for callers who never adopted lineStyle) — 'dotted'
|
|
283
|
+
// is the extra case neither `dashed` nor the old fixed pattern
|
|
284
|
+
// could express (ArchiMate's realization/access relationships).
|
|
285
|
+
const dashArray = LINE_STYLE_DASH[edge.lineStyle ?? (edge.dashed ? 'dashed' : 'solid')] ?? null;
|
|
286
|
+
if (dashArray) path.setAttribute('stroke-dasharray', dashArray);
|
|
287
|
+
// onEdgeClick was accepted as an option but never actually wired
|
|
288
|
+
// up to anything — direct DOM listener, same pattern node click
|
|
289
|
+
// already uses (bubbles fine regardless of hitTest).
|
|
290
|
+
if (onEdgeClick) path.style.cursor = 'pointer';
|
|
291
|
+
if (onEdgeClick) path.addEventListener('click', () => onEdgeClick(edge));
|
|
292
|
+
|
|
293
|
+
// Track edge selection for waypoint visibility (only show waypoints on selected edge)
|
|
294
|
+
path.addEventListener('click', (e) => {
|
|
295
|
+
e.stopPropagation(); // Prevent canvas deselection
|
|
296
|
+
this._selectedEdge = edge;
|
|
297
|
+
this._rerenderGraph(); // Redraw to update waypoint visibility
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// A collapsed ancestor or the keyword filter hides some nodes
|
|
301
|
+
// (see nodeEls below) — hide their connecting edges too, or
|
|
302
|
+
// they'd dangle toward an invisible node instead of disappearing
|
|
303
|
+
// with it.
|
|
304
|
+
const edgeHidden = hiddenByCollapse.has(edge.source) || hiddenByCollapse.has(edge.target)
|
|
305
|
+
|| hiddenByFilter.has(edge.source) || hiddenByFilter.has(edge.target);
|
|
306
|
+
if (edgeHidden) path.setAttribute('visibility', 'hidden');
|
|
307
|
+
|
|
308
|
+
if (edge.label) {
|
|
309
|
+
const midX = (x1 + x2) / 2;
|
|
310
|
+
const midY = (y1 + y2) / 2;
|
|
311
|
+
const defaultTooltip = `${sourceInfo.label} → ${targetInfo.label}: ${edge.label}`;
|
|
312
|
+
|
|
313
|
+
const labelEl = this.renderer.text(edge.label, midX, midY, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', tooltip: edge.tooltip || defaultTooltip });
|
|
314
|
+
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Independent end labels — a UML association's multiplicity
|
|
318
|
+
// ('0..1', '1..*', ...) shown near EACH end, distinct from the
|
|
319
|
+
// one midpoint `edge.label` above (e.g. the relationship's own
|
|
320
|
+
// name) — see dataModelDiagram's 'uml' notation, though this is
|
|
321
|
+
// a plain graphDiagram field, not data-model-specific. Placed
|
|
322
|
+
// 18% of the way in from each anchor, not right at it, so the
|
|
323
|
+
// text doesn't sit on top of an arrowhead/cardinality glyph.
|
|
324
|
+
if (edge.sourceLabel) {
|
|
325
|
+
const lx = x1 + (x2 - x1) * 0.18, ly = y1 + (y2 - y1) * 0.18;
|
|
326
|
+
const labelEl = this.renderer.text(edge.sourceLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
|
|
327
|
+
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
328
|
+
}
|
|
329
|
+
if (edge.targetLabel) {
|
|
330
|
+
const lx = x2 + (x1 - x2) * 0.18, ly = y2 + (y1 - y2) * 0.18;
|
|
331
|
+
const labelEl = this.renderer.text(edge.targetLabel, lx, ly, { textAnchor: 'middle', fontSize: '9px', fill: '#636e72', paintOrder: 'stroke', stroke: '#fafbfc', 'stroke-width': '2px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter' });
|
|
332
|
+
if (edgeHidden) labelEl.setAttribute('visibility', 'hidden');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Manual bend points (see _computeEdgePath's waypoints branch and
|
|
336
|
+
// the drag/double-click handlers in _addDragListeners) only need a
|
|
337
|
+
// visible/draggable handle in editable mode — a non-editable
|
|
338
|
+
// diagram still routes through them, it just can't gain new ones.
|
|
339
|
+
// Only show waypoints when this edge is selected (reduces clutter).
|
|
340
|
+
if (editable && this._selectedEdge === edge) selectionHandles.push(() => {
|
|
341
|
+
// Endpoint handles (source and target) — red circles, draggable to reposition on node rim
|
|
342
|
+
const endpointHandleColor = '#ff6b6b';
|
|
343
|
+
{
|
|
344
|
+
const sourceHandle = this.renderer.circle(x1, y1, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'source' });
|
|
345
|
+
sourceHandle.style.cursor = 'grab';
|
|
346
|
+
sourceHandle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
347
|
+
if (edgeHidden) sourceHandle.setAttribute('visibility', 'hidden');
|
|
348
|
+
|
|
349
|
+
const targetHandle = this.renderer.circle(x2, y2, 5, '#ffffff', { color: endpointHandleColor, width: 2 }, { draggable: true, isEndpoint: true, edge, endpointType: 'target' });
|
|
350
|
+
targetHandle.style.cursor = 'grab';
|
|
351
|
+
targetHandle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
352
|
+
if (edgeHidden) targetHandle.setAttribute('visibility', 'hidden');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Waypoint handles (blue circles for manual bends)
|
|
356
|
+
if (edge.waypoints?.length) {
|
|
357
|
+
edge.waypoints.forEach((wp, waypointIndex) => {
|
|
358
|
+
const handle = this.renderer.circle(wp.x, wp.y, 4, '#ffffff', { color: '#4f8ef7', width: 2 }, { draggable: true, isWaypoint: true, edge, waypointIndex });
|
|
359
|
+
handle.style.cursor = 'move';
|
|
360
|
+
handle.setAttribute(EDGE_HANDLE_ATTR, '');
|
|
361
|
+
if (edgeHidden) handle.setAttribute('visibility', 'hidden');
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return path;
|
|
366
|
+
}).filter(Boolean);
|
|
367
|
+
|
|
368
|
+
// Draw edge-crossing bridges (small arcs where edges cross)
|
|
369
|
+
const edgePaths = edgeEls.map(el => el ? el.getAttribute('d') : null).filter(Boolean);
|
|
370
|
+
if (edgePaths.length > 1) {
|
|
371
|
+
// Detect all pairwise crossings
|
|
372
|
+
const allCrossings = [];
|
|
373
|
+
for (let i = 0; i < edgePaths.length; i++) {
|
|
374
|
+
for (let j = i + 1; j < edgePaths.length; j++) {
|
|
375
|
+
const crossings = EdgeGeometry.findEdgeCrossings(edgePaths[i], [edgePaths[j]]);
|
|
376
|
+
allCrossings.push(...crossings);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Draw bridges at each crossing (small semicircle arcs)
|
|
381
|
+
for (const crossing of allCrossings) {
|
|
382
|
+
const bridgeRadius = 6;
|
|
383
|
+
const dx = Math.cos(crossing.angle);
|
|
384
|
+
const dy = Math.sin(crossing.angle);
|
|
385
|
+
const px = crossing.x - dy * bridgeRadius;
|
|
386
|
+
const py = crossing.y + dx * bridgeRadius;
|
|
387
|
+
const qx = crossing.x + dy * bridgeRadius;
|
|
388
|
+
const qy = crossing.y - dx * bridgeRadius;
|
|
389
|
+
|
|
390
|
+
const bridgePath = `M${px},${py} A${bridgeRadius},${bridgeRadius} 0 0,1 ${qx},${qy}`;
|
|
391
|
+
this.renderer.path(bridgePath, 'none', {
|
|
392
|
+
color: '#ffffff',
|
|
393
|
+
width: 8,
|
|
394
|
+
opacity: 0.8
|
|
395
|
+
});
|
|
396
|
+
// Draw a thin line on top for visibility
|
|
397
|
+
this.renderer.path(bridgePath, 'none', {
|
|
398
|
+
color: '#bdc3c7',
|
|
399
|
+
width: 2,
|
|
400
|
+
opacity: 1
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const groups = new Map();
|
|
406
|
+
nodes.forEach(node => {
|
|
407
|
+
if (node.group && !groups.has(node.group)) {
|
|
408
|
+
groups.set(node.group, colors[groups.size % colors.length]);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Get nodes hidden by collapsed folders
|
|
413
|
+
const { folders = [] } = this.data;
|
|
414
|
+
const hiddenByCollapsedFolders = this._getHiddenByCollapsedFolders(folders);
|
|
415
|
+
|
|
416
|
+
// Filter out nodes from hidden groups, collapsed subtrees, keyword filter, and collapsed folders
|
|
417
|
+
const visibleNodes = nodes.filter(node =>
|
|
418
|
+
(!node.group || !this._hiddenGraphGroups.has(node.group)) && !this._hiddenGraphNodes.has(node.id)
|
|
419
|
+
&& !hiddenByCollapse.has(node.id) && !hiddenByFilter.has(node.id)
|
|
420
|
+
&& !hiddenByCollapsedFolders.has(node.id)
|
|
421
|
+
);
|
|
422
|
+
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
|
|
423
|
+
const visibleEdges = effectiveEdges.filter(edge => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
|
|
424
|
+
|
|
425
|
+
const edgesTo = svgRoot?.childNodes?.length ?? 0;
|
|
426
|
+
// Render folders (visual containers) before nodes so they appear behind
|
|
427
|
+
this._renderFolders(this.renderer.svg, folders);
|
|
428
|
+
|
|
429
|
+
const nodeEls = nodes.map(node => {
|
|
430
|
+
const isHidden = this._hiddenGraphGroups.has(node.group) || this._hiddenGraphNodes.has(node.id)
|
|
431
|
+
|| hiddenByCollapse.has(node.id) || hiddenByFilter.has(node.id)
|
|
432
|
+
|| hiddenByCollapsedFolders.has(node.id);
|
|
433
|
+
const p = pos.get(node.id);
|
|
434
|
+
|
|
435
|
+
// Table nodes previously got just the bare label as a tooltip —
|
|
436
|
+
// real detail (column/row counts) already exists on the node
|
|
437
|
+
// object at this point but wasn't being surfaced on hover. Either
|
|
438
|
+
// way, any extra caller-supplied field not in the structural set
|
|
439
|
+
// (e.g. {owner:'Alice'}) is appended as its own "key: value" line.
|
|
440
|
+
const resolvedLabel = LabelHelper.resolveNodeLabel(node);
|
|
441
|
+
const tooltip = node.shape === 'table'
|
|
442
|
+
? [
|
|
443
|
+
`${resolvedLabel} — ${node.columns?.length ?? 0} column${node.columns?.length === 1 ? '' : 's'}` +
|
|
444
|
+
(Number.isFinite(node.rowCount) ? ` · ${node.rowCount.toLocaleString()} row${node.rowCount === 1 ? '' : 's'}` : ''),
|
|
445
|
+
...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('table')),
|
|
446
|
+
].join('\n')
|
|
447
|
+
: [`Node ${resolvedLabel}`, ...LabelHelper.nodeExtraDataLines(node, this.structuralKeys('node'))].join('\n');
|
|
448
|
+
const g = this.renderer.group({ transform: `translate(${p.x},${p.y})`, draggable: true, nodeId: node.id, tooltip });
|
|
449
|
+
if (node.opacity != null && node.opacity !== 1) {
|
|
450
|
+
g.setAttribute('opacity', String(node.opacity));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (isHidden) {
|
|
454
|
+
g.setAttribute('visibility', 'hidden');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (node.shape === 'table') {
|
|
458
|
+
NodeGeometry.buildTableNodeContent(this.renderer, g, node);
|
|
459
|
+
} else {
|
|
460
|
+
const nodeColor = node.color || (node.group ? groups.get(node.group) : (options.nodeColor || '#2ecc71'));
|
|
461
|
+
// A 'rect' node's own width/height (like a table node's, see
|
|
462
|
+
// _nodeSize) override the plain square-of-radius box every
|
|
463
|
+
// other shape still uses — needed for ArchiMate-style wider-
|
|
464
|
+
// than-tall boxes (archimateDiagram). nodeR (still used below
|
|
465
|
+
// for icon sizing/badge placement on non-rect shapes) is the
|
|
466
|
+
// radius that SAME square would have had, so nothing changes
|
|
467
|
+
// for a 'rect' node that never sets width/height.
|
|
468
|
+
const nodeSize = NodeGeometry.nodeSize(node, R, sizeDomain, sizeOptions);
|
|
469
|
+
const nodeR = Math.max(nodeSize.w, nodeSize.h) / 2;
|
|
470
|
+
const { el: shapeEl, suppressInner } = NodeGeometry.nodeShapeEl(this.renderer, node.shape, nodeR, nodeColor, nodeSize, node.cornerStyle, node.strokeColor, node.strokeWidth, node.iconUrl, node.icon);
|
|
471
|
+
if (node.strokeDasharray) shapeEl.setAttribute?.('stroke-dasharray', node.strokeDasharray);
|
|
472
|
+
const fillOp = node.fillOpacity ?? (node.alpha !== undefined && node.alpha !== null && node.alpha !== '' ? Number(node.alpha) / 255 : undefined);
|
|
473
|
+
if (fillOp !== undefined && fillOp !== 1 && shapeEl.setAttribute) shapeEl.setAttribute('fill-opacity', String(fillOp));
|
|
474
|
+
const strokeOp = node.strokeOpacity ?? (node.lineAlpha !== undefined && node.lineAlpha !== null && node.lineAlpha !== '' ? Number(node.lineAlpha) / 255 : undefined);
|
|
475
|
+
if (strokeOp !== undefined && strokeOp !== 1 && shapeEl.setAttribute) shapeEl.setAttribute('stroke-opacity', String(strokeOp));
|
|
476
|
+
if (node.gradient && node.gradient !== '0' && node.gradient !== 0 && nodeColor !== 'none' && typeof this.renderer.linearGradient === 'function') {
|
|
477
|
+
const dir = String(node.gradient);
|
|
478
|
+
const coords = dir === '2' ? { x1: '100%', y1: '0%', x2: '0%', y2: '0%' }
|
|
479
|
+
: dir === '3' ? { x1: '0%', y1: '100%', x2: '0%', y2: '0%' }
|
|
480
|
+
: dir === '4' ? { x1: '0%', y1: '0%', x2: '100%', y2: '0%' }
|
|
481
|
+
: { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
|
|
482
|
+
const gradId = `grad-${node.id || Math.random().toString(36).slice(2)}`;
|
|
483
|
+
const stops = [
|
|
484
|
+
{ offset: '0%', color: '#ffffff', opacity: 0.45 },
|
|
485
|
+
{ offset: '100%', color: nodeColor, opacity: fillOp ?? 1 },
|
|
486
|
+
];
|
|
487
|
+
const fillUrl = this.renderer.linearGradient(gradId, stops, coords);
|
|
488
|
+
if (fillUrl && shapeEl.setAttribute) shapeEl.setAttribute('fill', fillUrl);
|
|
489
|
+
}
|
|
490
|
+
g.appendChild(shapeEl);
|
|
491
|
+
|
|
492
|
+
// `iconShape` — a shape NAME (any _nodeShapeEl one) drawn
|
|
493
|
+
// small in the corner as line art, instead of an emoji/image
|
|
494
|
+
// badge. This is how the real ArchiMate notation works: the
|
|
495
|
+
// corner badge is the element's own standalone glyph at badge
|
|
496
|
+
// size (see archimateDiagram/_archimateBuildNode), so one
|
|
497
|
+
// vector glyph serves both. Drawn unfilled with a dark
|
|
498
|
+
// outline so it reads on top of the node's own fill.
|
|
499
|
+
// The icon is drawn in the outline colour; with no outline ('none') it stays black, as in Archi.
|
|
500
|
+
const markColor = node.strokeColor === 'none' ? '#000000' : node.strokeColor;
|
|
501
|
+
if (node.iconShape) {
|
|
502
|
+
const badge = Math.max(10, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
|
|
503
|
+
const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
|
|
504
|
+
const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
|
|
505
|
+
const inset = badge / 2 + 3;
|
|
506
|
+
const bx = cp.x + (corner.includes('right') ? -inset : inset);
|
|
507
|
+
const by = cp.y + (corner.includes('bottom') ? -inset : inset);
|
|
508
|
+
const { el: badgeEl } = NodeGeometry.nodeShapeEl(
|
|
509
|
+
this.renderer, node.iconShape, badge / 2, 'none', { w: badge, h: badge * 0.78 }, undefined,
|
|
510
|
+
markColor || '#000000', 1,
|
|
511
|
+
);
|
|
512
|
+
badgeEl.setAttribute('transform', `translate(${bx},${by})`);
|
|
513
|
+
badgeEl.setAttribute('pointer-events', 'none');
|
|
514
|
+
g.appendChild(badgeEl);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const iconPosition = node.iconPosition || defaultIconPosition;
|
|
518
|
+
// A custom icon (an emoji/glyph string, or an image/SVG file via
|
|
519
|
+
// iconUrl) — centred on the shape (replacing the plain initials,
|
|
520
|
+
// 'center', the default) or a small corner badge ('corner', see
|
|
521
|
+
// _nodeIconCorner) alongside whatever else is in the box.
|
|
522
|
+
// suppressInner still gates 'center' mode only — a 'person'
|
|
523
|
+
// shape (which draws its own head/body glyph) stays uncluttered
|
|
524
|
+
// there, but a corner badge sits well clear of that glyph.
|
|
525
|
+
if (iconPosition === 'corner' && (node.icon || node.iconUrl)) {
|
|
526
|
+
// Sized off the box's SMALLER dimension, not nodeR (which
|
|
527
|
+
// is max(w,h)/2 — for a wide-but-short ArchiMate-style
|
|
528
|
+
// box that would badly oversize the corner badge).
|
|
529
|
+
const badgeSize = Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3));
|
|
530
|
+
const corner = NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner);
|
|
531
|
+
const cp = NodeGeometry.cornerPoint({ x: 0, y: 0 }, nodeSize, corner);
|
|
532
|
+
const inset = badgeSize / 2 + 2; // keep the badge inside the box, not straddling its edge
|
|
533
|
+
const bx = cp.x + (corner.includes('right') ? -inset : inset);
|
|
534
|
+
const by = cp.y + (corner.includes('bottom') ? -inset : inset);
|
|
535
|
+
if (node.iconUrl) {
|
|
536
|
+
g.appendChild(this.renderer.image(node.iconUrl, bx - badgeSize / 2, by - badgeSize / 2, badgeSize, badgeSize, { 'pointer-events': 'none' }));
|
|
537
|
+
} else {
|
|
538
|
+
// Badge colour follows the node's own outline colour —
|
|
539
|
+
// white reads on plain graphDiagram's saturated node
|
|
540
|
+
// fills, but vanishes on an ArchiMate box's pastel one
|
|
541
|
+
// (which sets strokeColor: '#000').
|
|
542
|
+
g.appendChild(this.renderer.text(node.icon, bx, by, { fill: markColor || '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${badgeSize}px`, 'pointer-events': 'none' }));
|
|
543
|
+
}
|
|
544
|
+
// A node with a real glyph badge (iconShape, drawn above)
|
|
545
|
+
// must not ALSO get the centred initials fallback below —
|
|
546
|
+
// that's what put a stray "RE"/"CA" in the middle of every
|
|
547
|
+
// ArchiMate box once emoji badges gave way to glyphs.
|
|
548
|
+
} else if (!suppressInner && !node.iconShape && (!node.viewObject || node.iconUrl)) {
|
|
549
|
+
// (A view-only box shows its own text: no initials, but its picture if it has one.)
|
|
550
|
+
if (node.iconUrl) {
|
|
551
|
+
const iconSize = nodeR * 1.3;
|
|
552
|
+
g.appendChild(this.renderer.image(node.iconUrl, -iconSize / 2, -iconSize / 2, iconSize, iconSize, { 'pointer-events': 'none' }));
|
|
553
|
+
} else if (GraphDiagram.showsInitials(node, defaultLabelPosition)) {
|
|
554
|
+
const centerText = node.icon || resolvedLabel.slice(0, 2).toUpperCase() || (node.id ?? '').slice(0, 2).toUpperCase();
|
|
555
|
+
const fontSize = node.icon ? Math.max(12, Math.round(nodeR * 1.1)) : 10;
|
|
556
|
+
g.appendChild(this.renderer.text(centerText, 0, 0, { fill: '#fff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: `${fontSize}px`, fontWeight: node.icon ? 'normal' : 'bold', 'pointer-events': 'none' }));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 'below' (default) keeps the original single-line caption-
|
|
561
|
+
// under-the-shape convention; 'inside' (ArchiMate-style
|
|
562
|
+
// boxes) left-aligns the label near the box's own top-left
|
|
563
|
+
// instead, WORD-WRAPPED (_wrapLabelLines) to as many lines as
|
|
564
|
+
// fit inside the real box (nodeSize, not the icon-sizing
|
|
565
|
+
// nodeR above — a wide-but-short rect must not push it far
|
|
566
|
+
// below) rather than a single line overflowing the edges.
|
|
567
|
+
// Image objects draw their own visual, so they skip labels.
|
|
568
|
+
if (node.shape !== 'image') {
|
|
569
|
+
const labelPosition = node.labelPosition || (node.shape === 'note' ? 'inside' : defaultLabelPosition);
|
|
570
|
+
const inside = labelPosition === 'inside';
|
|
571
|
+
const textAlign = node.textAlign || defaultTextAlign || (inside ? 'left' : 'center');
|
|
572
|
+
const verticalAlign = node.verticalAlign || defaultVerticalAlign || 'top';
|
|
573
|
+
const anchorByAlign = { left: 'start', center: 'middle', right: 'end' };
|
|
574
|
+
const textAnchor = anchorByAlign[textAlign] || 'start';
|
|
575
|
+
if (inside) {
|
|
576
|
+
// The node's own font when it has one (an Archi import: points, as Archi sizes them).
|
|
577
|
+
const FONT_SIZE = GraphDiagram.fontPixels(node.fontSize) ?? 10;
|
|
578
|
+
const PAD = 6, LINE_HEIGHT = Math.round(FONT_SIZE * 1.2);
|
|
579
|
+
const font = {
|
|
580
|
+
fill: node.textColor || '#222', fontSize: `${FONT_SIZE}px`,
|
|
581
|
+
...(node.fontFamily && { fontFamily: node.fontFamily }),
|
|
582
|
+
...(node.fontWeight && { fontWeight: node.fontWeight }),
|
|
583
|
+
...(node.fontStyle && { fontStyle: node.fontStyle }),
|
|
584
|
+
};
|
|
585
|
+
// A badge in a top corner takes its width off that side of the text, as in Archi.
|
|
586
|
+
const hasBadge = node.iconShape || (iconPosition === 'corner' && (node.icon || node.iconUrl));
|
|
587
|
+
const badgeCorner = hasBadge ? NodeGeometry.nodeIconCorner(node, iconCornerByShape, iconCorner) : '';
|
|
588
|
+
const badgeWidth = hasBadge && badgeCorner.includes('top') ? Math.max(12, Math.round(Math.min(nodeSize.w, nodeSize.h) * 0.3)) + 4 : 0;
|
|
589
|
+
const insetLeft = badgeCorner.includes('left') ? badgeWidth : 0;
|
|
590
|
+
const insetRight = badgeCorner.includes('right') ? badgeWidth : 0;
|
|
591
|
+
const lines = LabelHelper.wrapLabelLines(resolvedLabel, nodeSize.w - PAD * 2 - insetLeft - insetRight, FONT_SIZE, nodeSize.h - PAD * 2, LINE_HEIGHT,
|
|
592
|
+
this.renderer.measureText ? (s) => this.renderer.measureText(s, font) : null);
|
|
593
|
+
const { x, baseline: y0 } = TextStyle.block({ textAlign, textPosition: verticalAlign },
|
|
594
|
+
{ left: -nodeSize.w / 2 + insetLeft, right: nodeSize.w / 2 - insetRight, top: -nodeSize.h / 2, bottom: nodeSize.h / 2 },
|
|
595
|
+
lines.length, { pixels: FONT_SIZE, lineHeight: LINE_HEIGHT, pad: PAD });
|
|
596
|
+
lines.forEach((line, i) => {
|
|
597
|
+
g.appendChild(this.renderer.text(line, x, y0 + i * LINE_HEIGHT, { ...font, textAnchor, 'pointer-events': 'none' }));
|
|
598
|
+
});
|
|
599
|
+
} else {
|
|
600
|
+
// nodeSize.h/2 (the shape's own real bottom edge), not
|
|
601
|
+
// nodeR (max(w,h)/2 — for a wide-but-short shape, e.g. any
|
|
602
|
+
// archimateDiagram distinct notation shape at its usual
|
|
603
|
+
// 120x55-ish box footprint, nodeR badly overshoots the
|
|
604
|
+
// shape's actual visual height, sitting the label so far
|
|
605
|
+
// down it lands on top of the NEXT row in a dense grid
|
|
606
|
+
// instead of just under this node). For a shape that
|
|
607
|
+
// ignores non-square `size` entirely (person/diamond/the
|
|
608
|
+
// plain default circle — always rendered from R alone),
|
|
609
|
+
// nodeSize.h still equals 2*R when no explicit
|
|
610
|
+
// width/height override is set, so this is a no-op there.
|
|
611
|
+
let x;
|
|
612
|
+
if (textAlign === 'left') x = -nodeSize.w / 2;
|
|
613
|
+
else if (textAlign === 'right') x = nodeSize.w / 2;
|
|
614
|
+
else x = 0;
|
|
615
|
+
g.appendChild(this.renderer.text(resolvedLabel, x, nodeSize.h / 2 + 9, { fill: '#333', textAnchor, fontSize: '9px', 'pointer-events': 'none' }));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Hierarchical-key mode only: a +/− badge on any node with
|
|
620
|
+
// children toggles its subtree's visibility (_graphCollapsedNodes,
|
|
621
|
+
// read back into hiddenByCollapse above on the next render).
|
|
622
|
+
// stopPropagation keeps this click from also bubbling to the
|
|
623
|
+
// node's own onNodeClick listener below.
|
|
624
|
+
if (childrenMap?.get(node.id)?.length) {
|
|
625
|
+
const collapsed = this._graphCollapsedNodes.has(node.id);
|
|
626
|
+
const badgeY = nodeR + 20; // below the label (at nodeR+9) so it doesn't overlap the node circle or its text
|
|
627
|
+
const badge = this.renderer.circle(0, badgeY, 6, '#ffffff', { color: '#888', width: 1.2 });
|
|
628
|
+
const badgeGlyph = this.renderer.text(collapsed ? '+' : '−', 0, badgeY, {
|
|
629
|
+
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: '#555', 'pointer-events': 'none',
|
|
630
|
+
});
|
|
631
|
+
badge.style.cursor = 'pointer';
|
|
632
|
+
badge.addEventListener('click', (event) => {
|
|
633
|
+
event.stopPropagation();
|
|
634
|
+
if (collapsed) this._graphCollapsedNodes.delete(node.id);
|
|
635
|
+
else this._graphCollapsedNodes.add(node.id);
|
|
636
|
+
this._rerenderGraph();
|
|
637
|
+
});
|
|
638
|
+
g.appendChild(badge);
|
|
639
|
+
g.appendChild(badgeGlyph);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (onNodeClick) {
|
|
644
|
+
g.addEventListener('click', () => onNodeClick(node));
|
|
645
|
+
}
|
|
646
|
+
return g;
|
|
647
|
+
});
|
|
648
|
+
if (edgesOnTop && svgRoot?.childNodes) {
|
|
649
|
+
// Re-append the lines (and their labels and bridges), in order, above every box.
|
|
650
|
+
for (const el of Array.from(svgRoot.childNodes).slice(edgesFrom, edgesTo)) svgRoot.appendChild(el);
|
|
651
|
+
}
|
|
652
|
+
for (const draw of selectionHandles) draw();
|
|
653
|
+
|
|
654
|
+
if (showLegend && groups.size > 0) {
|
|
655
|
+
const legendX = W - 120;
|
|
656
|
+
let legendY = 40;
|
|
657
|
+
const legendItemHeight = 20;
|
|
658
|
+
const legendMarkerRadius = 6;
|
|
659
|
+
|
|
660
|
+
const legendGroup = this.renderer.group({ class: 'legend' });
|
|
661
|
+
|
|
662
|
+
groups.forEach((color, groupName) => {
|
|
663
|
+
const isHidden = this._hiddenGraphGroups.has(groupName);
|
|
664
|
+
const legendItemAttrs = { legendItem: true, graphLegend: true, groupName };
|
|
665
|
+
const itemColor = isHidden ? '#ccc' : color;
|
|
666
|
+
const textColor = isHidden ? '#ccc' : '#333';
|
|
667
|
+
|
|
668
|
+
const marker = this.renderer.circle(legendX, legendY, legendMarkerRadius, itemColor, { color: 'white', width: 1.5 }, legendItemAttrs);
|
|
669
|
+
const label = this.renderer.text(groupName, legendX + legendMarkerRadius + 8, legendY, {
|
|
670
|
+
textAnchor: 'start',
|
|
671
|
+
verticalAlign: 'middle',
|
|
672
|
+
fontSize: '12px',
|
|
673
|
+
fill: textColor,
|
|
674
|
+
...legendItemAttrs
|
|
675
|
+
});
|
|
676
|
+
marker.addEventListener('click', () => this._onLegendClick(groupName));
|
|
677
|
+
label.addEventListener('click', () => this._onLegendClick(groupName));
|
|
678
|
+
legendY += legendItemHeight;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// A click anywhere but on the selected line's handles deselects it: empty canvas, a shape, the
|
|
683
|
+
// grid, a group. Listened in the capture phase (a shape stops its own click from bubbling); a
|
|
684
|
+
// click on a line selects that one (its own listener). The handles are removed in place rather
|
|
685
|
+
// than by redrawing, which would replace a shape under a double-click.
|
|
686
|
+
if (this.renderer.svg) {
|
|
687
|
+
this.renderer.svg.removeEventListener('click', this._handleCanvasClick, true);
|
|
688
|
+
this._handleCanvasClick = (e) => this.deselectEdgeOnClick(e.target);
|
|
689
|
+
this.renderer.svg.addEventListener('click', this._handleCanvasClick, true);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// The engine keeps the selection (several nodes), so it outlives a redraw.
|
|
693
|
+
this._showNodeSelection(nodeEls);
|
|
694
|
+
this._wireGraphHover(nodes, effectiveEdges, nodeEls, edgeEls);
|
|
695
|
+
|
|
696
|
+
this._graph = {
|
|
697
|
+
nodes, edges: visibleEdges, pos, nodeEls, edgeEls, nodeRadius: R, groups,
|
|
698
|
+
contextMenuItems, edgeContextMenuItems, canvasContextMenuItems, editable,
|
|
699
|
+
sizeDomain, sizeOptions,
|
|
700
|
+
resizableDefault: resizable, resizeCorner, resizeCornerByShape,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Wire interactive hover elevation and 1-hop neighborhood glow on graph/ArchiMate/data-model
|
|
706
|
+
* nodes and edges without touching node position transforms or EmphasisRule opacity attributes.
|
|
707
|
+
*/
|
|
708
|
+
_wireGraphHover(nodes, effectiveEdges, nodeEls, edgeEls) {
|
|
709
|
+
if (this.renderer?.supportsSceneGraph !== true || this.options?.hoverHighlight === false) return;
|
|
710
|
+
const nodeElById = new Map();
|
|
711
|
+
nodes.forEach((n, i) => {
|
|
712
|
+
if (nodeEls[i]) nodeElById.set(n.id, nodeEls[i]);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const clearHover = () => {
|
|
716
|
+
for (const g of nodeEls) {
|
|
717
|
+
if (g?.style) g.style.filter = '';
|
|
718
|
+
}
|
|
719
|
+
for (const p of edgeEls) {
|
|
720
|
+
if (p?.style) p.style.filter = '';
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
nodes.forEach((node, idx) => {
|
|
725
|
+
const g = nodeEls[idx];
|
|
726
|
+
if (typeof g?.addEventListener !== 'function') return;
|
|
727
|
+
if (g.style) g.style.transition = 'filter 0.18s ease';
|
|
728
|
+
g.addEventListener('mouseenter', () => {
|
|
729
|
+
if (g.style) g.style.filter = 'drop-shadow(0 5px 10px rgba(43, 95, 217, 0.34))';
|
|
730
|
+
effectiveEdges.forEach((edge, eIdx) => {
|
|
731
|
+
if (edge.source !== node.id && edge.target !== node.id) return;
|
|
732
|
+
const p = edgeEls[eIdx];
|
|
733
|
+
if (p?.style) p.style.filter = 'drop-shadow(0 0 4px rgba(43, 95, 217, 0.5))';
|
|
734
|
+
const neighborId = edge.source === node.id ? edge.target : edge.source;
|
|
735
|
+
const neighborEl = nodeElById.get(neighborId);
|
|
736
|
+
if (neighborEl?.style && neighborEl !== g) {
|
|
737
|
+
neighborEl.style.filter = 'drop-shadow(0 3px 7px rgba(43, 95, 217, 0.24))';
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
g.addEventListener('mouseleave', clearHover);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
effectiveEdges.forEach((edge, eIdx) => {
|
|
745
|
+
const p = edgeEls[eIdx];
|
|
746
|
+
if (typeof p?.addEventListener !== 'function') return;
|
|
747
|
+
if (p.style) p.style.transition = 'filter 0.18s ease';
|
|
748
|
+
p.addEventListener('mouseenter', () => {
|
|
749
|
+
if (p.style) p.style.filter = 'drop-shadow(0 0 5px rgba(43, 95, 217, 0.55))';
|
|
750
|
+
const sEl = nodeElById.get(edge.source);
|
|
751
|
+
const tEl = nodeElById.get(edge.target);
|
|
752
|
+
if (sEl?.style) sEl.style.filter = 'drop-shadow(0 4px 8px rgba(43, 95, 217, 0.28))';
|
|
753
|
+
if (tEl?.style) tEl.style.filter = 'drop-shadow(0 4px 8px rgba(43, 95, 217, 0.28))';
|
|
754
|
+
});
|
|
755
|
+
p.addEventListener('mouseleave', clearHover);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
static inferHierarchyEdges(nodes, explicitEdges = []) {
|
|
760
|
+
const HIER_KEY_RE = /^\d+(?:[./-]\d+)+$/;
|
|
761
|
+
if (!nodes.some(n => HIER_KEY_RE.test(String(n.id)))) return null;
|
|
762
|
+
const isHierId = (id) => /^\d+(?:[./-]\d+)*$/.test(String(id));
|
|
763
|
+
const nodeIds = new Set(nodes.map(n => n.id));
|
|
764
|
+
const edges = [];
|
|
765
|
+
const seen = new Set();
|
|
766
|
+
nodes.forEach(n => {
|
|
767
|
+
const m = String(n.id).match(/^(.+)[./-]\d+$/);
|
|
768
|
+
const parentId = m ? m[1] : null;
|
|
769
|
+
if (parentId && nodeIds.has(parentId)) {
|
|
770
|
+
edges.push({ source: parentId, target: n.id });
|
|
771
|
+
seen.add(`${parentId}\u0000${n.id}`);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
explicitEdges.forEach(e => {
|
|
775
|
+
if (isHierId(e.source) && isHierId(e.target)) return; // superseded by the inferred parent/child above
|
|
776
|
+
const key = `${e.source}\u0000${e.target}`;
|
|
777
|
+
if (seen.has(key)) return;
|
|
778
|
+
seen.add(key);
|
|
779
|
+
edges.push(e);
|
|
780
|
+
});
|
|
781
|
+
return edges;
|
|
782
|
+
}
|
|
783
|
+
_inferHierarchyEdges(nodes, explicitEdges = []) {
|
|
784
|
+
return GraphDiagram.inferHierarchyEdges(nodes, explicitEdges);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
static hierarchyChildrenMap(edges) {
|
|
788
|
+
const map = new Map();
|
|
789
|
+
edges.forEach(e => {
|
|
790
|
+
if (!map.has(e.source)) map.set(e.source, []);
|
|
791
|
+
map.get(e.source).push(e.target);
|
|
792
|
+
});
|
|
793
|
+
return map;
|
|
794
|
+
}
|
|
795
|
+
_hierarchyChildrenMap(edges) {
|
|
796
|
+
return GraphDiagram.hierarchyChildrenMap(edges);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
static collapsedDescendantIds(collapsedIds, childrenMap) {
|
|
800
|
+
const hidden = new Set();
|
|
801
|
+
const visit = (id) => {
|
|
802
|
+
for (const child of (childrenMap.get(id) || [])) {
|
|
803
|
+
if (!hidden.has(child)) { hidden.add(child); visit(child); }
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
collapsedIds.forEach(visit);
|
|
807
|
+
return hidden;
|
|
808
|
+
}
|
|
809
|
+
_collapsedDescendantIds(collapsedIds, childrenMap) {
|
|
810
|
+
return GraphDiagram.collapsedDescendantIds(collapsedIds, childrenMap);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
static computeTreeLayout(nodes, edges, W, H, PAD) {
|
|
814
|
+
const childrenMap = GraphDiagram.hierarchyChildrenMap(edges);
|
|
815
|
+
const hasParent = new Set(edges.map(e => e.target));
|
|
816
|
+
const roots = nodes.filter(n => !hasParent.has(n.id)).map(n => n.id);
|
|
817
|
+
|
|
818
|
+
const depth = new Map();
|
|
819
|
+
const leafOrder = [];
|
|
820
|
+
const visited = new Set();
|
|
821
|
+
const visit = (id, d) => {
|
|
822
|
+
if (visited.has(id)) return;
|
|
823
|
+
visited.add(id);
|
|
824
|
+
depth.set(id, d);
|
|
825
|
+
const kids = childrenMap.get(id) || [];
|
|
826
|
+
if (kids.length === 0) leafOrder.push(id);
|
|
827
|
+
else kids.forEach(k => visit(k, d + 1));
|
|
828
|
+
};
|
|
829
|
+
roots.forEach(id => visit(id, 0));
|
|
830
|
+
nodes.forEach(n => { if (!visited.has(n.id)) visit(n.id, 0); }); // any node an edge cycle left unreached becomes its own root
|
|
831
|
+
|
|
832
|
+
const slot = new Map();
|
|
833
|
+
leafOrder.forEach((id, i) => slot.set(id, i));
|
|
834
|
+
const computeSlot = (id, seen) => {
|
|
835
|
+
if (slot.has(id)) return slot.get(id);
|
|
836
|
+
if (seen.has(id)) return 0; // guards a malformed/cyclic edge set
|
|
837
|
+
seen.add(id);
|
|
838
|
+
const kids = childrenMap.get(id) || [];
|
|
839
|
+
const s = kids.reduce((sum, k) => sum + computeSlot(k, seen), 0) / kids.length;
|
|
840
|
+
slot.set(id, s);
|
|
841
|
+
return s;
|
|
842
|
+
};
|
|
843
|
+
nodes.forEach(n => computeSlot(n.id, new Set()));
|
|
844
|
+
|
|
845
|
+
const maxDepth = Math.max(0, ...Array.from(depth.values()));
|
|
846
|
+
const numLeaves = Math.max(1, leafOrder.length);
|
|
847
|
+
const pos = new Map();
|
|
848
|
+
nodes.forEach(n => {
|
|
849
|
+
const d = depth.get(n.id) ?? 0;
|
|
850
|
+
const s = slot.get(n.id) ?? 0;
|
|
851
|
+
const x = numLeaves === 1 ? W / 2 : PAD + (s / (numLeaves - 1)) * (W - 2 * PAD);
|
|
852
|
+
const y = maxDepth === 0 ? H / 2 : PAD + (d / maxDepth) * (H - 2 * PAD);
|
|
853
|
+
pos.set(n.id, { x, y, vx: 0, vy: 0 });
|
|
854
|
+
});
|
|
855
|
+
return pos;
|
|
856
|
+
}
|
|
857
|
+
_computeTreeLayout(nodes, edges, W, H, PAD) {
|
|
858
|
+
return GraphDiagram.computeTreeLayout(nodes, edges, W, H, PAD);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
static nodeMatchesKeyword(node, keyword, { nodeStructuralKeys, tableNodeStructuralKeys }) {
|
|
862
|
+
const structuralKeys = node.shape === 'table' ? tableNodeStructuralKeys : nodeStructuralKeys;
|
|
863
|
+
const haystack = [LabelHelper.resolveNodeLabel(node), ...LabelHelper.nodeExtraDataLines(node, structuralKeys)].join(' ').toLowerCase();
|
|
864
|
+
return haystack.includes(String(keyword).toLowerCase());
|
|
865
|
+
}
|
|
866
|
+
_nodeMatchesKeyword(node, keyword) {
|
|
867
|
+
return GraphDiagram.nodeMatchesKeyword(node, keyword, {
|
|
868
|
+
nodeStructuralKeys: this.structuralKeys('node'),
|
|
869
|
+
tableNodeStructuralKeys: this.structuralKeys('table'),
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
static hiddenByKeywordFilter(nodes, keyword, childrenMap = null, { nodeStructuralKeys, tableNodeStructuralKeys }) {
|
|
874
|
+
const hidden = new Set();
|
|
875
|
+
if (!keyword) return hidden;
|
|
876
|
+
const matchedIds = nodes.filter(n => GraphDiagram.nodeMatchesKeyword(n, keyword, { nodeStructuralKeys, tableNodeStructuralKeys })).map(n => n.id);
|
|
877
|
+
let keepVisible;
|
|
878
|
+
if (childrenMap) {
|
|
879
|
+
const parentOf = new Map();
|
|
880
|
+
childrenMap.forEach((kids, parent) => kids.forEach(kid => parentOf.set(kid, parent)));
|
|
881
|
+
keepVisible = new Set();
|
|
882
|
+
matchedIds.forEach(id => {
|
|
883
|
+
let cur = id;
|
|
884
|
+
while (cur != null && !keepVisible.has(cur)) {
|
|
885
|
+
keepVisible.add(cur);
|
|
886
|
+
cur = parentOf.get(cur);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
} else {
|
|
890
|
+
keepVisible = new Set(matchedIds);
|
|
891
|
+
}
|
|
892
|
+
nodes.forEach(n => { if (!keepVisible.has(n.id)) hidden.add(n.id); });
|
|
893
|
+
return hidden;
|
|
894
|
+
}
|
|
895
|
+
_hiddenByKeywordFilter(nodes, keyword, childrenMap = null) {
|
|
896
|
+
return GraphDiagram.hiddenByKeywordFilter(nodes, keyword, childrenMap, {
|
|
897
|
+
nodeStructuralKeys: this.structuralKeys('node'),
|
|
898
|
+
tableNodeStructuralKeys: this.structuralKeys('table'),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
static edgeTooltipText(edge, { edgeStructuralKeys }) {
|
|
903
|
+
const lines = LabelHelper.nodeExtraDataLines(edge, edgeStructuralKeys);
|
|
904
|
+
if (!lines.length) return undefined;
|
|
905
|
+
return [`Edge ${edge.source} → ${edge.target}`, ...lines].join('\n');
|
|
906
|
+
}
|
|
907
|
+
_edgeTooltipText(edge) {
|
|
908
|
+
return GraphDiagram.edgeTooltipText(edge, {
|
|
909
|
+
edgeStructuralKeys: this.structuralKeys('edge'),
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
static generateNodeId(label, existingIds = []) {
|
|
914
|
+
const base = (label || 'node').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '') || 'node';
|
|
915
|
+
const existing = new Set(existingIds);
|
|
916
|
+
if (!existing.has(base)) return base;
|
|
917
|
+
let i = 2;
|
|
918
|
+
while (existing.has(`${base}-${i}`)) i++;
|
|
919
|
+
return `${base}-${i}`;
|
|
920
|
+
}
|
|
921
|
+
_generateNodeId(label) {
|
|
922
|
+
const existingIds = (this._chartState?.nodes ?? []).map(n => n.id);
|
|
923
|
+
return GraphDiagram.generateNodeId(label, existingIds);
|
|
924
|
+
}
|
|
925
|
+
_insertEdgeWaypoint(edge, point) {
|
|
926
|
+
const sourcePos = this._graph?.pos?.get(edge.source), targetPos = this._graph?.pos?.get(edge.target);
|
|
927
|
+
if (!sourcePos || !targetPos) return;
|
|
928
|
+
const oldWaypoints = edge.waypoints ? edge.waypoints.slice() : [];
|
|
929
|
+
const waypoints = EdgeGeometry.edgeWaypointsWithInsertion(edge, sourcePos, targetPos, point);
|
|
930
|
+
// Find where the new point landed in the recomputed array.
|
|
931
|
+
let insertedIndex = -1;
|
|
932
|
+
for (let i = 0; i < waypoints.length; i++) {
|
|
933
|
+
if (i >= oldWaypoints.length || waypoints[i] !== oldWaypoints[i]) {
|
|
934
|
+
insertedIndex = i;
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (insertedIndex < 0) insertedIndex = waypoints.length - 1;
|
|
939
|
+
// Keep edge selected after adding waypoint so endpoints remain visible
|
|
940
|
+
this._selectedEdge = edge;
|
|
941
|
+
this.history.execute(new AddWaypointCommand(edge, waypoints[insertedIndex], { diagram: this, index: insertedIndex }));
|
|
942
|
+
}
|
|
943
|
+
/** The nodes selected together (a selection box, Ctrl-click): their ids. */
|
|
944
|
+
get selectedNodeIds() {
|
|
945
|
+
return this.engine?._selectedNodeIds ?? new Set();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Select these nodes (or add them, with `extend`), and show it. */
|
|
949
|
+
selectNodes(ids, { extend = false } = {}) {
|
|
950
|
+
if (!this.engine) return;
|
|
951
|
+
const next = extend ? new Set([...this.selectedNodeIds, ...ids]) : new Set(ids);
|
|
952
|
+
this.engine._selectedNodeIds = next;
|
|
953
|
+
this._showNodeSelection(this._graph?.nodeEls ?? []);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Mark the selected nodes' elements (an outline glow), and unmark the rest. @private */
|
|
957
|
+
_showNodeSelection(nodeEls) {
|
|
958
|
+
const selected = this.selectedNodeIds;
|
|
959
|
+
if (!selected.size && !this._showingSelection) return; // nothing to mark or unmark
|
|
960
|
+
this._showingSelection = selected.size > 0;
|
|
961
|
+
for (const el of nodeEls) {
|
|
962
|
+
if (typeof el?.getAttribute !== 'function') continue;
|
|
963
|
+
const on = selected.has(el.getAttribute('nodeId'));
|
|
964
|
+
el.classList?.toggle('multi-selected', on);
|
|
965
|
+
el.style.filter = on ? 'drop-shadow(0 0 3px #0066cc) drop-shadow(0 0 1px #0066cc)' : '';
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* After a click on `target`: unless it is one of the selected line's
|
|
971
|
+
* handles, the line is no longer selected and its handles go.
|
|
972
|
+
* @returns {boolean} whether a line was deselected
|
|
973
|
+
*/
|
|
974
|
+
deselectEdgeOnClick(target) {
|
|
975
|
+
if (!this._selectedEdge || target?.closest?.(`[${EDGE_HANDLE_ATTR}]`)) return false;
|
|
976
|
+
this._selectedEdge = null;
|
|
977
|
+
this.renderer.svg?.querySelectorAll(`[${EDGE_HANDLE_ATTR}]`).forEach(n => n.remove());
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
_updateGraphLayout() {
|
|
982
|
+
if (!this._graph) return;
|
|
983
|
+
|
|
984
|
+
const { nodes, edges, pos, nodeEls, edgeEls, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
|
|
985
|
+
const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
|
|
986
|
+
|
|
987
|
+
// Update node positions
|
|
988
|
+
nodeEls.forEach(g => {
|
|
989
|
+
const nodeId = g.getAttribute('nodeId');
|
|
990
|
+
const p = pos.get(nodeId);
|
|
991
|
+
g.setAttribute('transform', `translate(${p.x},${p.y})`);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
const routingObstacles = nodes.map(n => {
|
|
995
|
+
const p = pos.get(n.id);
|
|
996
|
+
if (!p) return null;
|
|
997
|
+
const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
|
|
998
|
+
return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
|
|
999
|
+
}).filter(Boolean);
|
|
1000
|
+
|
|
1001
|
+
// Update edge positions — routes through the same _computeEdgePath the
|
|
1002
|
+
// initial draw in graphDiagram() uses, so a live drag frame doesn't
|
|
1003
|
+
// visibly snap between two different geometry calculations.
|
|
1004
|
+
edges.forEach((edge, i) => {
|
|
1005
|
+
const edgeEl = edgeEls[i];
|
|
1006
|
+
if (!edgeEl) return;
|
|
1007
|
+
const sourceNode = pos.get(edge.source);
|
|
1008
|
+
const targetNode = pos.get(edge.target);
|
|
1009
|
+
if (!sourceNode || !targetNode) return;
|
|
1010
|
+
const sourceInfo = nodes.find(n => n.id === edge.source);
|
|
1011
|
+
const targetInfo = nodes.find(n => n.id === edge.target);
|
|
1012
|
+
const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
|
|
1013
|
+
const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
|
|
1014
|
+
const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
|
|
1015
|
+
const { d } = EdgeGeometry.computeEdgePath(
|
|
1016
|
+
sourceInfo, sourceNode, sourceSize, targetInfo, targetNode, targetSize, edge, defaultEdgeRouting, edgeObstacles
|
|
1017
|
+
);
|
|
1018
|
+
edgeEl.setAttribute('d', d);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Find the edge whose rendered path passes closest to (x,y), within
|
|
1023
|
+
* `tolerance` SVG units. Returns the edge object or null. Used to make
|
|
1024
|
+
* thin or angled lines easier to select/hover when the renderer's own
|
|
1025
|
+
* pixel-exact hitTest misses the actual stroke. */
|
|
1026
|
+
findNearestEdge(x, y, tolerance = 14) {
|
|
1027
|
+
if (!this._graph) return null;
|
|
1028
|
+
const { nodes, edges, pos, nodeRadius: R, sizeDomain, sizeOptions } = this._graph;
|
|
1029
|
+
const defaultEdgeRouting = this._chartState?.options?.edgeRouting || 'straight';
|
|
1030
|
+
const tol2 = tolerance * tolerance;
|
|
1031
|
+
|
|
1032
|
+
const routingObstacles = nodes.map(n => {
|
|
1033
|
+
const p = pos.get(n.id);
|
|
1034
|
+
if (!p) return null;
|
|
1035
|
+
const size = NodeGeometry.nodeSize(n, R, sizeDomain, sizeOptions);
|
|
1036
|
+
return { id: n.id, x: p.x, y: p.y, r: Math.max(size.w, size.h) / 2 };
|
|
1037
|
+
}).filter(Boolean);
|
|
1038
|
+
|
|
1039
|
+
let bestEdge = null;
|
|
1040
|
+
let bestDist2 = Infinity;
|
|
1041
|
+
|
|
1042
|
+
for (const edge of edges) {
|
|
1043
|
+
const sourcePos = pos.get(edge.source);
|
|
1044
|
+
const targetPos = pos.get(edge.target);
|
|
1045
|
+
if (!sourcePos || !targetPos) continue;
|
|
1046
|
+
const sourceInfo = nodes.find(n => n.id === edge.source);
|
|
1047
|
+
const targetInfo = nodes.find(n => n.id === edge.target);
|
|
1048
|
+
const sourceSize = this.engine._nodeSize(sourceInfo, R, sizeDomain, sizeOptions);
|
|
1049
|
+
const targetSize = this.engine._nodeSize(targetInfo, R, sizeDomain, sizeOptions);
|
|
1050
|
+
const edgeObstacles = routingObstacles.filter(o => o.id !== edge.source && o.id !== edge.target);
|
|
1051
|
+
const { d } = EdgeGeometry.computeEdgePath(
|
|
1052
|
+
sourceInfo, sourcePos, sourceSize, targetInfo, targetPos, targetSize, edge, defaultEdgeRouting, edgeObstacles
|
|
1053
|
+
);
|
|
1054
|
+
const dist2 = EdgeGeometry.pathDistance2(d, x, y);
|
|
1055
|
+
if (dist2 < tol2 && dist2 < bestDist2) {
|
|
1056
|
+
bestDist2 = dist2;
|
|
1057
|
+
bestEdge = edge;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return bestEdge;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
_getContextMenu() {
|
|
1064
|
+
if (!this._contextMenu && this.engine?.container) {
|
|
1065
|
+
this._contextMenu = new ContextMenu(this.engine.container);
|
|
1066
|
+
}
|
|
1067
|
+
return this._contextMenu;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
_showNodeContextMenu(event, nodeId) {
|
|
1071
|
+
const node = this._graph.nodes.find(n => n.id === nodeId);
|
|
1072
|
+
if (!node) return;
|
|
1073
|
+
|
|
1074
|
+
const userItems = this._graph.contextMenuItems;
|
|
1075
|
+
let menuItems;
|
|
1076
|
+
if (userItems) {
|
|
1077
|
+
menuItems = userItems;
|
|
1078
|
+
} else {
|
|
1079
|
+
menuItems = [
|
|
1080
|
+
{
|
|
1081
|
+
label: (node) => this._graphFixedPos.has(node.id) ? 'Unpin Node' : 'Pin Node',
|
|
1082
|
+
action: (node) => {
|
|
1083
|
+
this._graphFixedPos.has(node.id)
|
|
1084
|
+
? this._graphFixedPos.delete(node.id)
|
|
1085
|
+
: this._graphFixedPos.add(node.id);
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
...(this._graph.editable ? [{
|
|
1089
|
+
label: 'Rename Node',
|
|
1090
|
+
action: (node) => {
|
|
1091
|
+
const next = window.prompt('Rename node:', node.label ?? '');
|
|
1092
|
+
if (next == null) return;
|
|
1093
|
+
const trimmed = next.trim();
|
|
1094
|
+
if (trimmed === (node.label ?? '')) return;
|
|
1095
|
+
this.history.execute(new RenameNodeCommand(
|
|
1096
|
+
node.id, node.label ?? '', trimmed,
|
|
1097
|
+
{ diagram: this, onRename: this._chartState?.options?.onNodeRename }
|
|
1098
|
+
));
|
|
1099
|
+
}
|
|
1100
|
+
}] : []),
|
|
1101
|
+
{
|
|
1102
|
+
label: 'Show Details in Console',
|
|
1103
|
+
action: (node) => console.log('Node Details:', node)
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
label: 'Hide Node',
|
|
1107
|
+
action: (node) => {
|
|
1108
|
+
this._hiddenGraphNodes.add(node.id);
|
|
1109
|
+
this._rerenderGraph();
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
label: 'Delete Node',
|
|
1114
|
+
action: (node) => {
|
|
1115
|
+
const dangling = this._chartState.edges.filter(e => e.source === node.id || e.target === node.id);
|
|
1116
|
+
this.history.execute(new DeleteNodeCommand(node, dangling, { diagram: this }));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
];
|
|
1120
|
+
|
|
1121
|
+
if (this._hiddenGraphNodes.size > 0) {
|
|
1122
|
+
menuItems.push({ separator: true });
|
|
1123
|
+
menuItems.push({
|
|
1124
|
+
label: 'Show All Hidden Nodes',
|
|
1125
|
+
action: () => {
|
|
1126
|
+
this._hiddenGraphNodes.clear();
|
|
1127
|
+
this._rerenderGraph();
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
this._getContextMenu()?.show(event, menuItems, node, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
|
|
1134
|
+
}
|
|
1135
|
+
_showEdgeContextMenu(event, edge) {
|
|
1136
|
+
const menuItems = this._graph.edgeContextMenuItems || [
|
|
1137
|
+
{
|
|
1138
|
+
label: 'Rename Edge',
|
|
1139
|
+
action: (edge) => {
|
|
1140
|
+
const next = window.prompt('Rename edge:', edge.label ?? '');
|
|
1141
|
+
if (next == null) return;
|
|
1142
|
+
const trimmed = next.trim();
|
|
1143
|
+
if (trimmed === (edge.label ?? '')) return;
|
|
1144
|
+
this.history.execute(new RenameEdgeCommand(
|
|
1145
|
+
edge, edge.label ?? '', trimmed,
|
|
1146
|
+
{ diagram: this, onRename: this._chartState?.options?.onEdgeRename }
|
|
1147
|
+
));
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
label: 'Invert Direction',
|
|
1152
|
+
action: (edge) => {
|
|
1153
|
+
this.history.execute(new InvertEdgeCommand(
|
|
1154
|
+
edge,
|
|
1155
|
+
{ diagram: this, onInvert: this._chartState?.options?.onEdgeInvert }
|
|
1156
|
+
));
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
// Where adding a bend point moved to once double-clicking the
|
|
1160
|
+
// line itself was repurposed for onEdgeDoubleClick (see
|
|
1161
|
+
// graphDiagram's own doc comment) — `point` is the right-click
|
|
1162
|
+
// location _buildAndShowContextMenu now passes to every action.
|
|
1163
|
+
{ label: 'Add Waypoint Here', action: (edge, engine, point) => this._insertEdgeWaypoint(edge, point) },
|
|
1164
|
+
{
|
|
1165
|
+
label: 'Delete Edge',
|
|
1166
|
+
action: (edge) => {
|
|
1167
|
+
this.history.execute(new DeleteEdgeCommand(
|
|
1168
|
+
edge,
|
|
1169
|
+
{ diagram: this, onDelete: this._chartState?.options?.onEdgeDelete }
|
|
1170
|
+
));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
];
|
|
1174
|
+
this._getContextMenu()?.show(event, menuItems, edge, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
|
|
1175
|
+
}
|
|
1176
|
+
_showCanvasContextMenu(event) {
|
|
1177
|
+
const point = SvgCoordinate.clientToSvgPoint(this.renderer?.svg, event.clientX, event.clientY);
|
|
1178
|
+
const menuItems = this._graph.canvasContextMenuItems || [
|
|
1179
|
+
{
|
|
1180
|
+
label: 'Add Node',
|
|
1181
|
+
action: (point) => {
|
|
1182
|
+
const label = window.prompt('New node label:', '');
|
|
1183
|
+
if (label == null) return;
|
|
1184
|
+
const trimmed = label.trim();
|
|
1185
|
+
if (!trimmed) return;
|
|
1186
|
+
const gridSize = this._chartState?.options?.gridSize ?? 0;
|
|
1187
|
+
const id = this._generateNodeId(trimmed);
|
|
1188
|
+
const newNode = {
|
|
1189
|
+
id,
|
|
1190
|
+
label: trimmed,
|
|
1191
|
+
x: GraphDiagram.snapToGrid(point.x, gridSize),
|
|
1192
|
+
y: GraphDiagram.snapToGrid(point.y, gridSize),
|
|
1193
|
+
};
|
|
1194
|
+
this.history.execute(new AddNodeCommand(
|
|
1195
|
+
newNode,
|
|
1196
|
+
{ diagram: this, onAdd: this._chartState?.options?.onNodeAdd }
|
|
1197
|
+
));
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
label: 'Add Folder',
|
|
1202
|
+
action: (point) => {
|
|
1203
|
+
const name = window.prompt('New folder name:', 'New Folder');
|
|
1204
|
+
if (name == null) return;
|
|
1205
|
+
const trimmed = name.trim();
|
|
1206
|
+
if (!trimmed) return;
|
|
1207
|
+
const gridSize = this._chartState?.options?.gridSize ?? 0;
|
|
1208
|
+
const folderId = `folder-${Math.random().toString(36).slice(2, 9)}`;
|
|
1209
|
+
const newFolder = {
|
|
1210
|
+
id: folderId,
|
|
1211
|
+
name: trimmed,
|
|
1212
|
+
x: GraphDiagram.snapToGrid(point.x, gridSize),
|
|
1213
|
+
y: GraphDiagram.snapToGrid(point.y, gridSize),
|
|
1214
|
+
width: 200,
|
|
1215
|
+
height: 150,
|
|
1216
|
+
collapsed: false,
|
|
1217
|
+
};
|
|
1218
|
+
// Add to folders in data
|
|
1219
|
+
if (!this.data.folders) this.data.folders = [];
|
|
1220
|
+
this.data.folders.push(newFolder);
|
|
1221
|
+
this._rerenderGraph();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
];
|
|
1225
|
+
this._getContextMenu()?.show(event, menuItems, point, (clientX, clientY) => SvgCoordinate.clientToSvgPoint(this.renderer?.svg, clientX, clientY), this.engine);
|
|
1226
|
+
}
|
|
1227
|
+
_onLegendClick(groupName) {
|
|
1228
|
+
if (this._hiddenGraphGroups.has(groupName)) {
|
|
1229
|
+
this._hiddenGraphGroups.delete(groupName);
|
|
1230
|
+
} else {
|
|
1231
|
+
this._hiddenGraphGroups.add(groupName);
|
|
1232
|
+
}
|
|
1233
|
+
this._rerenderGraph();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Handle clicks on diagram chrome (e.g. the group legend).
|
|
1238
|
+
* @param {MouseEvent} event
|
|
1239
|
+
* @param {object} hitShape
|
|
1240
|
+
*/
|
|
1241
|
+
onClick(event, hitShape) {
|
|
1242
|
+
if (hitShape && hitShape.graphLegend) {
|
|
1243
|
+
this._onLegendClick(hitShape.groupName);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
filterGraphNodes(keyword) {
|
|
1247
|
+
this._graphFilterKeyword = keyword ? String(keyword).trim() : '';
|
|
1248
|
+
this._rerenderGraph();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Snap a coordinate to the nearest grid multiple. `gridSize <= 0` means no
|
|
1252
|
+
* snapping. Exposed as a static helper so drag/add-node code outside this
|
|
1253
|
+
* class can reuse the same rounding rule. */
|
|
1254
|
+
static snapToGrid(value, gridSize) {
|
|
1255
|
+
if (!gridSize || gridSize <= 0) return value;
|
|
1256
|
+
return Math.round(value / gridSize) * gridSize;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** A single path string covering a W×H area with a grid of `gridSize`
|
|
1260
|
+
* spacing — rendered as one cheap background path. */
|
|
1261
|
+
static _gridPath(W, H, gridSize) {
|
|
1262
|
+
const parts = [];
|
|
1263
|
+
for (let x = gridSize; x < W; x += gridSize) parts.push(`M${x},0 L${x},${H}`);
|
|
1264
|
+
for (let y = gridSize; y < H; y += gridSize) parts.push(`M0,${y} L${W},${y}`);
|
|
1265
|
+
return parts.join(' ');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
_rerenderGraph() {
|
|
1269
|
+
// graphDiagram() reseeds every node's position from `n.x ?? Math.random()`
|
|
1270
|
+
// on each call — nothing else ever writes the live/simulated/dragged
|
|
1271
|
+
// position back onto the node object itself. Without carrying it
|
|
1272
|
+
// forward here, a node being actively dragged would snap back to its
|
|
1273
|
+
// pre-drag spot on the very next re-render (this is called on every
|
|
1274
|
+
// drag mousemove), and any node without an explicit x/y would jump to
|
|
1275
|
+
// a brand-new random position on every hide/show, delete, or legend
|
|
1276
|
+
// toggle. Copying this._graph.pos back onto the node objects before
|
|
1277
|
+
// re-invoking graphDiagram makes it a true "redraw with current state"
|
|
1278
|
+
// instead of "reroll positions and redraw".
|
|
1279
|
+
if (this._graph?.pos) {
|
|
1280
|
+
for (const node of this._chartState.nodes) {
|
|
1281
|
+
const p = this._graph.pos.get(node.id);
|
|
1282
|
+
if (p) { node.x = p.x; node.y = p.y; }
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
this.data.nodes = this._chartState.nodes;
|
|
1286
|
+
this.data.edges = this._chartState.edges;
|
|
1287
|
+
this.options = this._chartState.options;
|
|
1288
|
+
this.render();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Render folder containers (before nodes so they appear behind) */
|
|
1292
|
+
_renderFolders(svg, folders = []) {
|
|
1293
|
+
if (!folders.length || !svg) return;
|
|
1294
|
+
for (const folder of folders) {
|
|
1295
|
+
this._renderFolderContainer(svg, folder);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Render a single folder container with border, header, and collapse toggle */
|
|
1300
|
+
_renderFolderContainer(svg, folder) {
|
|
1301
|
+
// A group needs a place; 0 is one.
|
|
1302
|
+
if (!Number.isFinite(folder.x) || !Number.isFinite(folder.y)) return;
|
|
1303
|
+
const HEADER_HEIGHT = 24;
|
|
1304
|
+
const BORDER_COLOR = this._dragOverFolderId === folder.id ? '#44aa44' : '#999';
|
|
1305
|
+
const FILL_COLOR = this._dragOverFolderId === folder.id ? 'rgba(68, 170, 68, 0.15)' : 'rgba(150, 150, 200, 0.08)';
|
|
1306
|
+
const isCollapsed = this._collapsedFolders.has(folder.id);
|
|
1307
|
+
|
|
1308
|
+
// Folder background rectangle
|
|
1309
|
+
const bgRect = this.renderer.rect(
|
|
1310
|
+
folder.x, folder.y, folder.width || 150, folder.height || 100,
|
|
1311
|
+
FILL_COLOR, { color: BORDER_COLOR, width: 1.5 }
|
|
1312
|
+
);
|
|
1313
|
+
svg.appendChild(bgRect);
|
|
1314
|
+
|
|
1315
|
+
// Folder header bar
|
|
1316
|
+
const headerRect = this.renderer.rect(
|
|
1317
|
+
folder.x, folder.y, folder.width || 150, HEADER_HEIGHT,
|
|
1318
|
+
'rgba(150, 150, 200, 0.3)', { color: BORDER_COLOR, width: 1.5 }
|
|
1319
|
+
);
|
|
1320
|
+
headerRect.setAttribute('folderId', folder.id);
|
|
1321
|
+
headerRect.setAttribute('class', 'folder-header');
|
|
1322
|
+
headerRect.style.cursor = 'pointer';
|
|
1323
|
+
svg.appendChild(headerRect);
|
|
1324
|
+
|
|
1325
|
+
// Collapse/expand indicator
|
|
1326
|
+
const indicator = isCollapsed ? '▶' : '▼';
|
|
1327
|
+
const indicatorText = this.renderer.text(indicator, folder.x + 4, folder.y + 16,
|
|
1328
|
+
{ fontSize: '12px', fill: '#333', cursor: 'pointer' }
|
|
1329
|
+
);
|
|
1330
|
+
indicatorText.setAttribute('folderId', folder.id);
|
|
1331
|
+
indicatorText.setAttribute('class', 'folder-toggle');
|
|
1332
|
+
svg.appendChild(indicatorText);
|
|
1333
|
+
|
|
1334
|
+
// Folder name label
|
|
1335
|
+
const label = this.renderer.text(
|
|
1336
|
+
LabelHelper.resolveNodeLabel({ label: folder.name }) || 'Folder',
|
|
1337
|
+
folder.x + 22, folder.y + 16,
|
|
1338
|
+
{ fontSize: '12px', fontWeight: 'bold', fill: '#333', cursor: 'pointer' }
|
|
1339
|
+
);
|
|
1340
|
+
label.setAttribute('folderId', folder.id);
|
|
1341
|
+
label.setAttribute('class', 'folder-label');
|
|
1342
|
+
svg.appendChild(label);
|
|
1343
|
+
|
|
1344
|
+
// Add click handlers
|
|
1345
|
+
headerRect.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
|
|
1346
|
+
headerRect.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
|
|
1347
|
+
indicatorText.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
|
|
1348
|
+
label.addEventListener('click', (e) => this._onFolderHeaderClick(e, folder.id));
|
|
1349
|
+
label.addEventListener('dblclick', (e) => this._onFolderRename(e, folder));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Handle folder header click — toggle collapse state */
|
|
1353
|
+
_onFolderHeaderClick(event, folderId) {
|
|
1354
|
+
event.stopPropagation();
|
|
1355
|
+
if (this._collapsedFolders.has(folderId)) {
|
|
1356
|
+
this._collapsedFolders.delete(folderId);
|
|
1357
|
+
} else {
|
|
1358
|
+
this._collapsedFolders.add(folderId);
|
|
1359
|
+
}
|
|
1360
|
+
this._rerenderGraph();
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Handle folder rename (double-click) */
|
|
1364
|
+
_onFolderRename(event, folder) {
|
|
1365
|
+
event.stopPropagation();
|
|
1366
|
+
const newName = window.prompt('Rename folder:', folder.name || 'Folder');
|
|
1367
|
+
if (newName == null) return;
|
|
1368
|
+
const trimmed = newName.trim();
|
|
1369
|
+
if (!trimmed || trimmed === folder.name) return;
|
|
1370
|
+
|
|
1371
|
+
// Update folder name
|
|
1372
|
+
folder.name = trimmed;
|
|
1373
|
+
this._rerenderGraph();
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** Return IDs of all nodes within a given folder (recursively including nested folders) */
|
|
1377
|
+
_getNodesInFolder(folderId) {
|
|
1378
|
+
const nodeIds = new Set();
|
|
1379
|
+
const elementFolderMap = this._chartState?.options?.elementFolderMap;
|
|
1380
|
+
if (!elementFolderMap) return nodeIds;
|
|
1381
|
+
|
|
1382
|
+
const folders = this._chartState?.folders || [];
|
|
1383
|
+
|
|
1384
|
+
// Find all elements in this folder directly
|
|
1385
|
+
for (const [elementId, folderIdForElement] of elementFolderMap.entries()) {
|
|
1386
|
+
if (folderIdForElement === folderId) {
|
|
1387
|
+
nodeIds.add(elementId);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Recursively add elements in nested folders
|
|
1392
|
+
for (const folder of folders) {
|
|
1393
|
+
if (folder.parentId === folderId) {
|
|
1394
|
+
for (const nodeId of this._getNodesInFolder(folder.id)) {
|
|
1395
|
+
nodeIds.add(nodeId);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
return nodeIds;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Return IDs of all nodes hidden due to collapsed folders */
|
|
1404
|
+
_getHiddenByCollapsedFolders(folders) {
|
|
1405
|
+
if (!folders.length && !this._collapsedFolders.size) return new Set();
|
|
1406
|
+
const hidden = new Set();
|
|
1407
|
+
for (const folderId of this._collapsedFolders) {
|
|
1408
|
+
const nodesInFolder = this._getNodesInFolder(folderId);
|
|
1409
|
+
for (const nodeId of nodesInFolder) {
|
|
1410
|
+
hidden.add(nodeId);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return hidden;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* A font size in pixels: a number, "12px" or "9pt" (1pt = 4/3 px). Undefined when there is none.
|
|
1420
|
+
* @param {number|string} [size]
|
|
1421
|
+
* @returns {number|undefined}
|
|
1422
|
+
*/
|
|
1423
|
+
/** A font size in pixels (TextStyle.pixels: points are 4/3 of a pixel). */
|
|
1424
|
+
GraphDiagram.fontPixels = (size) => TextStyle.pixels(size);
|