@peter.prib/renderengine 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49641 -39747
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +206 -0
  6. package/examples/decision-workbench.html +26 -0
  7. package/package.json +7 -1
  8. package/src/CanvasRenderer.js +225 -200
  9. package/src/SVGRenderer.js +476 -454
  10. package/src/archimate.js +22 -2
  11. package/src/archimateArchiFormat.js +1735 -1701
  12. package/src/archimateArchive.js +36 -7
  13. package/src/archimateCSV.js +93 -37
  14. package/src/archimateDrawio.js +2 -0
  15. package/src/archimateExchangeFormat.js +3 -1
  16. package/src/chartCatalog.js +219 -0
  17. package/src/charting/Annotations.js +169 -0
  18. package/src/charting/ArchimateDiagramEditor.js +54 -10
  19. package/src/charting/Axis.js +217 -5
  20. package/src/charting/Chart.js +99 -9
  21. package/src/charting/Chart3D.js +3 -0
  22. package/src/charting/ChartBox.js +8 -1
  23. package/src/charting/ColorPalette.js +42 -4
  24. package/src/charting/DiagramEditor.js +174 -39
  25. package/src/charting/DiagramEditorCommands.js +28 -0
  26. package/src/charting/Edge.js +7 -0
  27. package/src/charting/Hierarchy.js +114 -0
  28. package/src/charting/InteractionManager.js +4 -0
  29. package/src/charting/ModelFileFormat.js +5 -1
  30. package/src/charting/SettingsManager.js +35 -0
  31. package/src/charting/ShapePaths.js +82 -10
  32. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  33. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  34. package/src/charting/charts/AlluvialDiagram.js +311 -0
  35. package/src/charting/charts/AreaChart.js +52 -15
  36. package/src/charting/charts/BarChart.js +38 -3
  37. package/src/charting/charts/BarChart3D.js +5 -3
  38. package/src/charting/charts/BarChartBase.js +26 -6
  39. package/src/charting/charts/BeeswarmChart.js +164 -0
  40. package/src/charting/charts/BoxPlotChart.js +23 -1
  41. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  42. package/src/charting/charts/CandlestickChart.js +172 -4
  43. package/src/charting/charts/ChordDiagram.js +105 -2
  44. package/src/charting/charts/CirclePackChart.js +81 -0
  45. package/src/charting/charts/ComboChart.js +233 -0
  46. package/src/charting/charts/ContourChart.js +310 -0
  47. package/src/charting/charts/DonutChart3D.js +13 -0
  48. package/src/charting/charts/DumbbellChart.js +217 -0
  49. package/src/charting/charts/EcdfChart.js +150 -0
  50. package/src/charting/charts/FunnelChart.js +4 -1
  51. package/src/charting/charts/GeoMapChart.js +132 -11
  52. package/src/charting/charts/GroupedBarChart.js +44 -3
  53. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  54. package/src/charting/charts/HeatmapChart.js +2 -2
  55. package/src/charting/charts/HexbinChart.js +137 -0
  56. package/src/charting/charts/HierarchyChart.js +77 -3
  57. package/src/charting/charts/HorizonChart.js +105 -0
  58. package/src/charting/charts/KagiChart.js +167 -0
  59. package/src/charting/charts/LineChart.js +74 -18
  60. package/src/charting/charts/OhlcChart.js +79 -0
  61. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  62. package/src/charting/charts/PieChart.js +195 -17
  63. package/src/charting/charts/PieChart3D.js +137 -0
  64. package/src/charting/charts/PointAndFigureChart.js +198 -0
  65. package/src/charting/charts/PolarAreaChart.js +189 -0
  66. package/src/charting/charts/RadarChart.js +7 -1
  67. package/src/charting/charts/RadialBarChart.js +5 -2
  68. package/src/charting/charts/RenkoChart.js +178 -0
  69. package/src/charting/charts/RidgelineChart.js +113 -0
  70. package/src/charting/charts/SankeyDiagram.js +127 -4
  71. package/src/charting/charts/ScatterChart.js +13 -0
  72. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  73. package/src/charting/charts/SlopegraphChart.js +205 -0
  74. package/src/charting/charts/SparklineTableChart.js +166 -0
  75. package/src/charting/charts/StackedBarChart.js +48 -5
  76. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  77. package/src/charting/charts/StreamGraphChart.js +10 -3
  78. package/src/charting/charts/SunburstChart.js +27 -7
  79. package/src/charting/charts/SurfaceChart3D.js +157 -0
  80. package/src/charting/charts/TreemapChart.js +28 -8
  81. package/src/charting/charts/ViolinChart.js +18 -0
  82. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  83. package/src/charting/datamodel/DataLineage.js +150 -0
  84. package/src/charting/datamodel/DataModel.js +83 -1
  85. package/src/charting/datamodel/ModelMapping.js +87 -0
  86. package/src/charting/datamodel/SqlDdl.js +110 -85
  87. package/src/charting/datamodel/index.js +3 -0
  88. package/src/charting/decision/DecisionWorkbench.js +295 -0
  89. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  90. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  91. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  92. package/src/charting/diagrams/SketchView.js +121 -0
  93. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  94. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  95. package/src/charting/modelTree/NavigatorModel.js +410 -0
  96. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  97. package/src/charting/pivot/PivotChart.js +109 -4
  98. package/src/charting/pivot/PivotGridChart.js +321 -33
  99. package/src/charting/pivot/PivotView.js +82 -0
  100. package/src/charting/report/TemplatedReport.js +547 -0
  101. package/src/charting/templates/BoxTemplate.js +28 -27
  102. package/src/charting/templates/PackageTemplate.js +26 -25
  103. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  104. package/src/charting/templates/Template.js +219 -212
  105. package/src/cli.js +146 -0
  106. package/src/index.js +3370 -2995
  107. package/src/workspace/Workspace.js +126 -7
  108. package/src/workspace/archimateServices.js +4 -0
  109. package/src/workspace/index.js +3 -0
  110. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  111. package/src/workspace/parts/DecisionWorkbenchPart.js +58 -0
  112. package/src/workspace/parts/ViewsPart.js +196 -0
  113. package/src/workspace/styles.js +36 -1
@@ -1,455 +1,477 @@
1
- import { BaseRenderer } from './BaseRenderer.js';
2
-
3
- /**
4
- * @class SVGRenderer
5
- * Renders shapes by creating and appending SVG elements to an <svg> tag.
6
- */
7
- export class SVGRenderer extends BaseRenderer {
8
- get type() { return 'svg'; }
9
- get supportsSceneGraph() { return true; }
10
-
11
- static #instances = 0;
12
-
13
- /**
14
- * The id of a line-end marker in this renderer's <defs>. Each renderer has
15
- * its own prefix: ids are page-wide, and `url(#arrow)` would otherwise find
16
- * the first SVG's marker, which may be hidden (another tab, another editor).
17
- * @param {string} name - marker name, e.g. 'arrow', 'diamond-hollow'
18
- */
19
- markerId(name) { return `${this.markerPrefix}${name}`; }
20
-
21
- /** The `marker-start`/`marker-end` value for a marker name. */
22
- markerUrl(name) { return `url(#${this.markerId(name)})`; }
23
-
24
- _createElement(tag,attributes = {},stroke = null) {
25
- const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
26
- this._setAttributes(el, attributes);
27
- if (stroke) {
28
- this._setAttributes(el, { stroke: stroke.color || 'none', 'stroke-width': stroke.width || 1 });
29
- // Used by the gauge arcs' rounded end-caps ({cap: 'round'}) — was
30
- // silently dropped, so gauges always got default (butt) caps.
31
- if (stroke.cap) this._setAttributes(el, { 'stroke-linecap': stroke.cap });
32
- }
33
- // Do not append here; let the caller decide.
34
- // if (this.svg) this.svg.appendChild(el);
35
- return el;
36
- }
37
- _elMap = new Map();
38
- constructor(container, width, height) {
39
- super(container, width, height);
40
- this.markerPrefix = `re${SVGRenderer.#instances++}-`;
41
- this.svg = this._createElement("svg",{ width, height, viewBox: `0 0 ${width} ${height}` });
42
- // Inline elements (the default for <svg>) leave a few px of baseline
43
- // whitespace below them inside a block container, making the
44
- // container's scrollHeight exceed its clientHeight for every chart.
45
- this.svg.style.display = 'block';
46
-
47
- // Add a style tag for hover effects
48
- const defs = this._createElement('defs');
49
-
50
- // Default arrow marker
51
- const arrowMarker = this._createElement('marker', { id: this.markerId('arrow'), viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
52
- arrowMarker.appendChild(this._createElement('path', { d: 'M 0 0 L 10 5 L 0 10 z', class: 'arrowhead-path' }));
53
- defs.appendChild(arrowMarker);
54
-
55
- // Circle marker
56
- const circleMarker = this._createElement('marker', { id: this.markerId('circle'), viewBox: '0 0 10 10', refX: 5, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
57
- circleMarker.appendChild(this._createElement('circle', { cx: 5, cy: 5, r: 4, class: 'arrowhead-path' }));
58
- defs.appendChild(circleMarker);
59
-
60
- // Tee marker
61
- const teeMarker = this._createElement('marker', { id: this.markerId('tee'), viewBox: '0 0 10 10', refX: 1, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
62
- teeMarker.appendChild(this._createElement('path', { d: 'M 0 0 L 0 10', 'stroke-width': 2, class: 'arrowhead-path' }));
63
- defs.appendChild(teeMarker);
64
-
65
- // The four markers below exist for graphDiagram's relationship-style
66
- // notation (composition/aggregation/realization/specialization/
67
- // serving/access/influence — see _archimateRelationshipStyle) but
68
- // aren't ArchiMate-specific themselves: any edge can use them via
69
- // `arrowhead`/`arrowheadStart`, same as 'arrow'/'circle'/'tee' above.
70
- // Filled diamond (composition's "whole"-end glyph).
71
- const diamondMarker = this._createElement('marker', { id: this.markerId('diamond'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
72
- diamondMarker.appendChild(this._createElement('path', { d: 'M0,5 L5,0 L10,5 L5,10 Z', class: 'arrowhead-path' }));
73
- defs.appendChild(diamondMarker);
74
-
75
- // Hollow diamond (aggregation) — filled white, not transparent, so
76
- // the line underneath doesn't show through the shape.
77
- const diamondHollowMarker = this._createElement('marker', { id: this.markerId('diamond-hollow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
78
- diamondHollowMarker.appendChild(this._createElement('path', { d: 'M0,5 L5,0 L10,5 L5,10 Z', class: 'arrowhead-path-hollow' }));
79
- defs.appendChild(diamondHollowMarker);
80
-
81
- // Hollow triangle (realization/specialization) — same white-filled
82
- // convention as the hollow diamond above.
83
- const triangleHollowMarker = this._createElement('marker', { id: this.markerId('triangle-hollow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
84
- triangleHollowMarker.appendChild(this._createElement('path', { d: 'M0,0 L10,5 L0,10 Z', class: 'arrowhead-path-hollow' }));
85
- defs.appendChild(triangleHollowMarker);
86
-
87
- // Open/thin arrow (serving, access, influence) — an unfilled chevron,
88
- // distinct from 'arrow's solid filled triangle.
89
- const openArrowMarker = this._createElement('marker', { id: this.markerId('open-arrow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
90
- openArrowMarker.appendChild(this._createElement('path', { d: 'M0,0 L9,5 L0,10', class: 'arrowhead-path-open' }));
91
- defs.appendChild(openArrowMarker);
92
-
93
- // Half/single-barb open arrow — ArchiMate's DIRECTED association (the
94
- // spec's own relationships figure shows association twice: a plain
95
- // line, and this one-barb variant for the directed form).
96
- const halfArrowMarker = this._createElement('marker', { id: this.markerId('open-arrow-half'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
97
- halfArrowMarker.appendChild(this._createElement('path', { d: 'M0,0 L9,5', class: 'arrowhead-path-open' }));
98
- defs.appendChild(halfArrowMarker);
99
-
100
- // Crow's-foot ERD cardinality glyphs (dataModelDiagram's 'crowsfoot'
101
- // notation — see src/datamodel.js) — a representative rendition, not
102
- // every real-notation optionality combination. All four share a
103
- // 16-wide viewBox with refX:15 (the point that sits ON the entity's
104
- // edge) so the drawn glyph sits BETWEEN the entity and the line,
105
- // extending away from it toward x:0.
106
- // "Exactly one" (mandatory, single cardinality) — one perpendicular tick.
107
- const crowsfootOneMarker = this._createElement('marker', { id: this.markerId('crowsfoot-one'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
108
- crowsfootOneMarker.appendChild(this._createElement('path', { d: 'M10,1 L10,9', class: 'arrowhead-path-open' }));
109
- defs.appendChild(crowsfootOneMarker);
110
- // "Zero or one" (optional, single cardinality) — hollow circle (the
111
- // "zero"/optional part) plus one tick (the "one" part) further out.
112
- const crowsfootOneOptionalMarker = this._createElement('marker', { id: this.markerId('crowsfoot-one-optional'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
113
- crowsfootOneOptionalMarker.appendChild(this._createElement('circle', { cx: 12, cy: 5, r: 3, class: 'arrowhead-path-hollow' }));
114
- crowsfootOneOptionalMarker.appendChild(this._createElement('path', { d: 'M5,1 L5,9', class: 'arrowhead-path-open' }));
115
- defs.appendChild(crowsfootOneOptionalMarker);
116
- // "Zero or many" (optional, many cardinality) — the crow's-foot fork
117
- // alone, its three prongs touching the entity edge.
118
- const crowsfootManyMarker = this._createElement('marker', { id: this.markerId('crowsfoot-many'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
119
- crowsfootManyMarker.appendChild(this._createElement('path', { d: 'M5,5 L15,0 M5,5 L15,5 M5,5 L15,10', class: 'arrowhead-path-open' }));
120
- defs.appendChild(crowsfootManyMarker);
121
- // "One or many" (mandatory, many cardinality) — the fork plus one
122
- // tick further out (the "at least one" part).
123
- const crowsfootManyMandatoryMarker = this._createElement('marker', { id: this.markerId('crowsfoot-many-mandatory'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
124
- crowsfootManyMandatoryMarker.appendChild(this._createElement('path', { d: 'M5,5 L15,0 M5,5 L15,5 M5,5 L15,10 M2,2 L2,8', class: 'arrowhead-path-open' }));
125
- defs.appendChild(crowsfootManyMandatoryMarker);
126
-
127
- this.svg.appendChild(defs);
128
- const style =this._createElement("style");
129
- style.textContent = `
130
- .hover {
131
- fill: orange;
132
- stroke: #333;
133
- stroke-width: 2px;
134
- }
135
- .selected {
136
- filter: drop-shadow(0 0 3px var(--ws-accent, #91cc75));
137
- }
138
- .selected > path,
139
- .selected > rect,
140
- .selected > circle,
141
- .selected > ellipse,
142
- .selected > polygon {
143
- stroke: var(--ws-accent, #91cc75);
144
- stroke-width: 3px;
145
- }
146
- .selected-edge {
147
- stroke: var(--ws-accent, #91cc75);
148
- stroke-width: 3px;
149
- }
150
- .arrowhead-path {
151
- fill: inherit;
152
- stroke: inherit;
153
- }
154
- .arrowhead-path-hollow {
155
- fill: white;
156
- stroke: black;
157
- stroke-width: 1px;
158
- }
159
- .arrowhead-path-open {
160
- fill: none;
161
- stroke: black;
162
- stroke-width: 1.5px;
163
- }
164
- `;
165
- this.svg.appendChild(style);
166
- // The main SVG is now the default parent for new elements.
167
- this.container.appendChild(this.svg);
168
- }
169
-
170
- /**
171
- * Resize the <svg>. Its viewBox follows only while it is still the size it
172
- * was created with: a diagram sets its own viewBox to its content, and that
173
- * must survive, so the diagram then scales to the new box instead of
174
- * being cropped to it.
175
- */
176
- resize(width, height) {
177
- const defaultViewBox = `0 0 ${this.width} ${this.height}`;
178
- super.resize(width, height);
179
- this.svg.setAttribute('width', width);
180
- this.svg.setAttribute('height', height);
181
- if (this.svg.getAttribute('viewBox') === defaultViewBox) {
182
- this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
183
- }
184
- }
185
-
186
- clear() {
187
- this._elMap.clear();
188
- // Keep <defs> and <style> elements; remove everything else.
189
- for (const child of [...this.svg.children]) {
190
- if (child.tagName !== 'style' && child.tagName !== 'defs') child.remove();
191
- }
192
- }
193
- _defineShape(shape,attributes, stroke = null) {
194
- shape.id=`shape_${this._elMap.size}`;
195
- const parent = shape.parent || this.svg;
196
- const el = this._createElement(shape.type, attributes, stroke);
197
- parent.appendChild(el);
198
- this._elMap.set(shape.id, { el, shape });
199
- return shape.id;
200
- }
201
- rect(x, y, width, height, fill, stroke, extra) {
202
- const shape = { type: 'rect', x, y, width, height, fill, stroke, ...extra };
203
- this._defineShape(shape, { x, y, width, height, fill: fill || 'none', ...extra }, stroke);
204
- return this._elMap.get(shape.id).el;
205
- }
206
-
207
- circle(cx, cy, r, fill, stroke, extra) {
208
- const shape = { type: 'circle', cx, cy, r, fill, stroke, ...extra};
209
- this._defineShape(shape, { cx, cy, r, fill: fill || 'none' }, stroke);
210
- return this._elMap.get(shape.id).el;
211
- }
212
-
213
- path(d, fill, stroke, extra = {}) {
214
- // `extra` (e.g. {tooltip}) merges into the shape descriptor only, not
215
- // into DOM attributes — hitTest()/tooltip resolution reads it straight
216
- // off `shape` (see _addTooltipListeners), it was never rendered as a
217
- // real SVG attribute for rect/circle either. Previously missing here
218
- // (CanvasRenderer.path already accepted it) — pie/doughnut wedges,
219
- // candlestick bodies, and box-plot boxes all need path-level tooltips.
220
- const shape = { type: 'path', d, fill, stroke, ...extra, id: `shape_${this._elMap.size}` };
221
- // _createElement already applies stroke.color/width as real SVG attributes
222
- // (see the `stroke` param handling above) — do NOT also set them as inline
223
- // styles here. Setting style.fill = stroke.color previously clobbered any
224
- // closed shape's real fill (e.g. graphDiagram's diamond node, filled with
225
- // the group color but stroked white) with the stroke color instead, since
226
- // inline styles win over attributes. Open paths (edge lines, fill:'none')
227
- // never showed the bug since there's no enclosed area to paint either way.
228
- const pathEl = this._createElement("path", { d, fill: fill || 'none' }, stroke);
229
- this.svg.appendChild(pathEl);
230
- this._elMap.set(shape.id, { el: pathEl, shape });
231
- return pathEl;
232
- }
233
-
234
- /** Text width measured with a (shared, off-page) 2D canvas context; the estimate without a document. */
235
- measureText(text, font = {}) {
236
- if (typeof document === 'undefined' || typeof document.createElement !== 'function') return super.measureText(text, font);
237
- const ctx = (SVGRenderer._measureContext ??= document.createElement('canvas').getContext?.('2d'));
238
- if (!ctx) return super.measureText(text, font);
239
- ctx.font = BaseRenderer.cssFont(font);
240
- return ctx.measureText(String(text)).width;
241
- }
242
-
243
- text(text, x, y, options = {}) {
244
- const shape = { type: 'text', text, x, y, ...options, id: `shape_${this._elMap.size}` };
245
- const attrs = { x, y, fill: options.fill || 'black', 'font-family': options.fontFamily || 'sans-serif', 'font-size': options.fontSize || '12px', 'text-anchor': options.textAnchor || 'start' };
246
- // Several call sites (graph-node initials/labels, table-node rows) pass
247
- // verticalAlign:'middle' expecting the text to be vertically centred on
248
- // its y-coordinate — this was silently ignored (SVG's default baseline
249
- // is alphabetic, not middle), so multi-row content sat visibly low.
250
- if (options.verticalAlign === 'middle') attrs['dominant-baseline'] = 'middle';
251
- if (options.fontWeight) attrs['font-weight'] = options.fontWeight;
252
- if (options.fontStyle) attrs['font-style'] = options.fontStyle;
253
- // Every graphDiagram call site passes 'pointer-events':'none' on node
254
- // initials/labels expecting clicks to fall through to the shape/badge
255
- // underneath — silently dropped since it wasn't in this whitelist, so
256
- // filled text always intercepted the click regardless of what callers
257
- // asked for (surfaced by the new collapse-badge glyph sitting on top
258
- // of its own circle and eating every click meant for it).
259
- if (options['pointer-events']) attrs['pointer-events'] = options['pointer-events'];
260
- // `rotation` is in radians (matches Canvas's ctx.rotate() convention) —
261
- // convert to degrees for SVG's transform=rotate(deg, cx, cy). Every
262
- // yAxisLabel call across the chart methods relies on this to render
263
- // vertically instead of overlapping/clipping past the chart's left edge.
264
- if (options.rotation) attrs.transform = `rotate(${options.rotation * 180 / Math.PI} ${x} ${y})`;
265
- const el = this._createElement('text', attrs);
266
- el.textContent = text;
267
- this.svg.appendChild(el);
268
- this._elMap.set(shape.id, { el, shape });
269
- return el;
270
- }
271
-
272
- /** An SVG `<image>` — used for graphDiagram's `node.iconUrl` (a custom
273
- * icon/image file/data URI centred on a node, in place of the plain
274
- * initials text). `href` is set as the plain SVG2 attribute; every
275
- * renderer target this library ships against (real browsers, jsdom in
276
- * tests) resolves that without also needing the legacy `xlink:href`. */
277
- image(href, x, y, width, height, options = {}) {
278
- const shape = { type: 'image', href, x, y, width, height, ...options, id: `shape_${this._elMap.size}` };
279
- const attrs = { href, x, y, width, height, preserveAspectRatio: options.preserveAspectRatio || 'xMidYMid meet' };
280
- if (options['pointer-events']) attrs['pointer-events'] = options['pointer-events'];
281
- const el = this._createElement('image', attrs);
282
- this.svg.appendChild(el);
283
- this._elMap.set(shape.id, { el, shape });
284
- return el;
285
- }
286
-
287
- group(attributes = {}) {
288
- const shape = { type: 'g', ...attributes, id: `shape_${this._elMap.size}` };
289
- const el = this._createElement('g', attributes);
290
- this.svg.appendChild(el);
291
- this._elMap.set(shape.id, { el, shape });
292
- return el;
293
- }
294
-
295
- hitTest(x, y) {
296
- // This is a bit of a hack. We find the element and then iterate our map to find the shape.
297
- // A more robust solution might use a WeakMap from element to shape.
298
- //
299
- // `x`/`y` are viewport coordinates (every caller in index.js passes
300
- // event.clientX/clientY straight through) — document.elementFromPoint
301
- // already takes coordinates in that same space. Adding this.svg's own
302
- // getBoundingClientRect().left/top on top double-offset the query
303
- // point by the canvas's on-page position, so hitTest silently queried
304
- // the wrong spot on the page for any layout where the SVG isn't flush
305
- // against the browser's top-left corner (i.e. basically always, once
306
- // there's a toolbar/sidebar around it) — drag-start, the context
307
- // menu, and tooltips all read this result directly, so all three
308
- // misfired by exactly that offset. Zoom (the SVG's internal viewBox
309
- // scale) doesn't factor in here at all: elementFromPoint operates on
310
- // real rendered screen pixels, which already reflect whatever zoom is
311
- // applied, so no CTM/zoom math belongs in this function.
312
- const hitEl = this.svg.ownerDocument.elementFromPoint(x, y);
313
- if (!hitEl) return null;
314
- return SVGRenderer.resolveHit(this._elMap, hitEl);
315
- }
316
-
317
- /**
318
- * Given the element under the cursor and the el→{el,shape} registry, picks
319
- * which registered shape a hit resolves to. Split out from hitTest() so
320
- * this candidate-selection logic is unit-testable without a real DOM
321
- * (`hitEl`/registered `el`s just need a `.contains()` method).
322
- *
323
- * A graph node is drawn as an outer <g draggable nodeId tooltip> wrapping
324
- * an inner shape primitive (circle/rect/path) plus text labels — each
325
- * registered in _elMap separately, with the inner primitive inserted
326
- * *before* the wrapping group. Since _elMap is iterated in insertion order
327
- * and this used to return the *first* match, a click on the node's own
328
- * visible body resolved to the inner primitive's bare shape (no
329
- * draggable/tooltip/nodeId) instead of the wrapping group — silently
330
- * breaking drag-start, the context menu, and hover tooltips (all of which
331
- * read hitTest()'s result directly, unlike click-to-select which bubbles
332
- * via a real DOM listener). Prefer a draggable node wrapper among the
333
- * candidates when one is present; otherwise keep the original first-match
334
- * behavior (legend markers etc. resolve via their own direct listeners
335
- * already, not this method).
336
- */
337
- static resolveHit(elMap, hitEl) {
338
- let first = null;
339
- for (const { el, shape } of elMap.values()) {
340
- if (el === hitEl || el.contains(hitEl)) {
341
- if (!first) first = shape;
342
- if (shape.draggable && shape.nodeId != null) return shape;
343
- }
344
- }
345
- return first;
346
- }
347
-
348
- enterState(shape, stateName) {
349
- this._elMap.get(shape.id)?.el.classList.add(stateName);
350
- }
351
-
352
- leaveState(shape, stateName) {
353
- this._elMap.get(shape.id)?.el.classList.remove(stateName);
354
- }
355
-
356
- renderDiagram(diagram) {
357
- // 20,20 padding matches this file's own DiagramNode.prototype.drawOn
358
- // (railroadDiagram.js, the Canvas rendering path) — without a real
359
- // starting x/y, every node's format() computes its own position as
360
- // NaN (this used to be called with zero args, so x/y were undefined
361
- // throughout the whole tree).
362
- diagram.format(20, 20);
363
-
364
- const group = this._createElement('g', {
365
- 'font-family': 'monospace',
366
- 'font-size': '12px'
367
- });
368
-
369
- // Collect every leaf first, then draw in a fixed path -> rect -> text
370
- // paint order, rather than the tree's own traversal order. Connector
371
- // lines (Sequence's backbone, ZeroOrMore's top line, Choice's arcs)
372
- // legitimately run behind boxes they connect through — but walk()
373
- // visits nodes in whatever order each FORMATTERS.* function happened
374
- // to build its .item/.items — e.g. a Sequence's own backbone Path is
375
- // appended (via addTo()) AFTER its child items are formatted, so it
376
- // painted on top of and struck through every terminal/nonterminal
377
- // box's text. A fixed paint order guarantees lines stay underneath
378
- // opaque boxes and text stays readable on top, regardless of which
379
- // order the diagram tree happens to store its children in.
380
- const leaves = [];
381
- diagram.walk(item => {
382
- // The `if (item.type === 'rect'/'path'/'text')` chain below
383
- // already correctly picks out only real leaf shapes — every
384
- // intermediate DiagramNode-tree node (Sequence/Choice/Terminal/
385
- // NonTerminal/...) has a .type that never matches any of those,
386
- // so it's harmless to reach the chain and fall through. A prior
387
- // `if (!item.width) return;` guard here was presumably meant to
388
- // skip those same intermediate nodes, but 'text' and 'path'
389
- // FakeSVG leaves never carry a .width at all (only 'rect' does —
390
- // same as real SVG <text>/<path> elements), so it silently
391
- // discarded every label and every connector line too.
392
- if (item.type === 'rect' || item.type === 'path' || item.type === 'text') leaves.push(item);
393
- });
394
- const paintOrder = { path: 0, rect: 1, text: 2 };
395
- leaves.sort((a, b) => paintOrder[a.type] - paintOrder[b.type]);
396
- leaves.forEach(item => {
397
- if (item.type === 'rect') {
398
- // NonTerminal rects have no rx/ry (sharp corners, by design —
399
- // see railroadDiagram.js's FORMATTERS.NonTerminal) — only
400
- // pass them through when real, rather than setting a literal
401
- // "undefined" string attribute value.
402
- const extra = { parent: group };
403
- if (item.rx != null) extra.rx = item.rx;
404
- if (item.ry != null) extra.ry = item.ry;
405
- this.rect(item.x, item.y, item.width, item.height, 'hsl(30,20%,95%)', { color: 'black', width: 2 }, extra);
406
- } else if (item.type === 'path') {
407
- const pathEl = this._createElement('path', { d: item.attrs.d }, { color: 'black', width: 3 });
408
- pathEl.setAttribute('fill', 'none');
409
- group.appendChild(pathEl);
410
- } else if (item.type === 'text') {
411
- const textEl = this._createElement('text', {
412
- x: item.x,
413
- y: item.y,
414
- 'text-anchor': 'middle',
415
- fill: 'black'
416
- });
417
- if (item.attrs.class === 'comment') {
418
- textEl.setAttribute('font-style', 'italic');
419
- } else if (item.type === 'NonTerminal') {
420
- textEl.setAttribute('font-weight', 'bold');
421
- }
422
- textEl.textContent = item.text;
423
- group.appendChild(textEl);
424
- }
425
- });
426
-
427
- // Every other SVGRenderer primitive (group()/rect()/circle()/...)
428
- // appends itself to this.svg (or a specified parent) within its own
429
- // method — this one built `group` and populated it via the walk
430
- // callback's this.rect(...) calls (which do append into `group`,
431
- // via the `parent: group` extra above), but never attached `group`
432
- // to the actual <svg> itself, so nothing was ever visible.
433
- this.svg.appendChild(group);
434
-
435
- // A diagram's natural width is data-driven (however many rule
436
- // elements it has) and routinely wider than a chart's fixed
437
- // container box. Only the viewBox is updated to the content's real
438
- // size — width/height stay at whatever they were set to at
439
- // construction (the container's own clientWidth/clientHeight, same
440
- // as every other chart type) — so the SVG's default
441
- // preserveAspectRatio="xMidYMid meet" scales the whole diagram down
442
- // to fit that fixed box, exactly like an <img>: no scrollbar, no
443
- // clipping, just a smaller rendering for a wide diagram. (An
444
- // earlier version of this also resized width/height to match the
445
- // content, which fit the diagram at full native size but broke
446
- // the page's CSS grid layout — a grid item's automatic minimum
447
- // size is based on its content's intrinsic size unless something
448
- // caps it — and even with that capped, still needed a scrollbar to
449
- // reach the full content. Not resizing width/height avoids both.)
450
- const w = diagram.width + 40, h = diagram.height + 40;
451
- this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
452
-
453
- return group;
454
- }
1
+ import { BaseRenderer } from './BaseRenderer.js';
2
+
3
+ /**
4
+ * @class SVGRenderer
5
+ * Renders shapes by creating and appending SVG elements to an <svg> tag.
6
+ */
7
+ export class SVGRenderer extends BaseRenderer {
8
+ get type() { return 'svg'; }
9
+ get supportsSceneGraph() { return true; }
10
+
11
+ static #instances = 0;
12
+
13
+ /**
14
+ * The id of a line-end marker in this renderer's <defs>. Each renderer has
15
+ * its own prefix: ids are page-wide, and `url(#arrow)` would otherwise find
16
+ * the first SVG's marker, which may be hidden (another tab, another editor).
17
+ * @param {string} name - marker name, e.g. 'arrow', 'diamond-hollow'
18
+ */
19
+ markerId(name) { return `${this.markerPrefix}${name}`; }
20
+
21
+ /** The `marker-start`/`marker-end` value for a marker name. */
22
+ markerUrl(name) { return `url(#${this.markerId(name)})`; }
23
+
24
+ _createElement(tag,attributes = {},stroke = null) {
25
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
26
+ this._setAttributes(el, attributes);
27
+ if (stroke) {
28
+ this._setAttributes(el, { stroke: stroke.color || 'none', 'stroke-width': stroke.width || 1 });
29
+ // Used by the gauge arcs' rounded end-caps ({cap: 'round'}) — was
30
+ // silently dropped, so gauges always got default (butt) caps.
31
+ if (stroke.cap) this._setAttributes(el, { 'stroke-linecap': stroke.cap });
32
+ }
33
+ // Do not append here; let the caller decide.
34
+ // if (this.svg) this.svg.appendChild(el);
35
+ return el;
36
+ }
37
+ _elMap = new Map();
38
+ constructor(container, width, height) {
39
+ super(container, width, height);
40
+ this.markerPrefix = `re${SVGRenderer.#instances++}-`;
41
+ this.svg = this._createElement("svg",{ width, height, viewBox: `0 0 ${width} ${height}` });
42
+ // Inline elements (the default for <svg>) leave a few px of baseline
43
+ // whitespace below them inside a block container, making the
44
+ // container's scrollHeight exceed its clientHeight for every chart.
45
+ this.svg.style.display = 'block';
46
+
47
+ // Add a style tag for hover effects
48
+ const defs = this._createElement('defs');
49
+ this.defs = defs;
50
+
51
+ // Default arrow marker
52
+ const arrowMarker = this._createElement('marker', { id: this.markerId('arrow'), viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
53
+ arrowMarker.appendChild(this._createElement('path', { d: 'M 0 0 L 10 5 L 0 10 z', class: 'arrowhead-path' }));
54
+ defs.appendChild(arrowMarker);
55
+
56
+ // Circle marker
57
+ const circleMarker = this._createElement('marker', { id: this.markerId('circle'), viewBox: '0 0 10 10', refX: 5, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
58
+ circleMarker.appendChild(this._createElement('circle', { cx: 5, cy: 5, r: 4, class: 'arrowhead-path' }));
59
+ defs.appendChild(circleMarker);
60
+
61
+ // Tee marker
62
+ const teeMarker = this._createElement('marker', { id: this.markerId('tee'), viewBox: '0 0 10 10', refX: 1, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' });
63
+ teeMarker.appendChild(this._createElement('path', { d: 'M 0 0 L 0 10', 'stroke-width': 2, class: 'arrowhead-path' }));
64
+ defs.appendChild(teeMarker);
65
+
66
+ // The four markers below exist for graphDiagram's relationship-style
67
+ // notation (composition/aggregation/realization/specialization/
68
+ // serving/access/influence — see _archimateRelationshipStyle) but
69
+ // aren't ArchiMate-specific themselves: any edge can use them via
70
+ // `arrowhead`/`arrowheadStart`, same as 'arrow'/'circle'/'tee' above.
71
+ // Filled diamond (composition's "whole"-end glyph).
72
+ const diamondMarker = this._createElement('marker', { id: this.markerId('diamond'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
73
+ diamondMarker.appendChild(this._createElement('path', { d: 'M0,5 L5,0 L10,5 L5,10 Z', class: 'arrowhead-path' }));
74
+ defs.appendChild(diamondMarker);
75
+
76
+ // Hollow diamond (aggregation) — filled white, not transparent, so
77
+ // the line underneath doesn't show through the shape.
78
+ const diamondHollowMarker = this._createElement('marker', { id: this.markerId('diamond-hollow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
79
+ diamondHollowMarker.appendChild(this._createElement('path', { d: 'M0,5 L5,0 L10,5 L5,10 Z', class: 'arrowhead-path-hollow' }));
80
+ defs.appendChild(diamondHollowMarker);
81
+
82
+ // Hollow triangle (realization/specialization) — same white-filled
83
+ // convention as the hollow diamond above.
84
+ const triangleHollowMarker = this._createElement('marker', { id: this.markerId('triangle-hollow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
85
+ triangleHollowMarker.appendChild(this._createElement('path', { d: 'M0,0 L10,5 L0,10 Z', class: 'arrowhead-path-hollow' }));
86
+ defs.appendChild(triangleHollowMarker);
87
+
88
+ // Open/thin arrow (serving, access, influence) — an unfilled chevron,
89
+ // distinct from 'arrow's solid filled triangle.
90
+ const openArrowMarker = this._createElement('marker', { id: this.markerId('open-arrow'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
91
+ openArrowMarker.appendChild(this._createElement('path', { d: 'M0,0 L9,5 L0,10', class: 'arrowhead-path-open' }));
92
+ defs.appendChild(openArrowMarker);
93
+
94
+ // Half/single-barb open arrow — ArchiMate's DIRECTED association (the
95
+ // spec's own relationships figure shows association twice: a plain
96
+ // line, and this one-barb variant for the directed form).
97
+ const halfArrowMarker = this._createElement('marker', { id: this.markerId('open-arrow-half'), viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: 'auto-start-reverse' });
98
+ halfArrowMarker.appendChild(this._createElement('path', { d: 'M0,0 L9,5', class: 'arrowhead-path-open' }));
99
+ defs.appendChild(halfArrowMarker);
100
+
101
+ // Crow's-foot ERD cardinality glyphs (dataModelDiagram's 'crowsfoot'
102
+ // notation — see src/datamodel.js) — a representative rendition, not
103
+ // every real-notation optionality combination. All four share a
104
+ // 16-wide viewBox with refX:15 (the point that sits ON the entity's
105
+ // edge) so the drawn glyph sits BETWEEN the entity and the line,
106
+ // extending away from it toward x:0.
107
+ // "Exactly one" (mandatory, single cardinality) — one perpendicular tick.
108
+ const crowsfootOneMarker = this._createElement('marker', { id: this.markerId('crowsfoot-one'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
109
+ crowsfootOneMarker.appendChild(this._createElement('path', { d: 'M10,1 L10,9', class: 'arrowhead-path-open' }));
110
+ defs.appendChild(crowsfootOneMarker);
111
+ // "Zero or one" (optional, single cardinality) — hollow circle (the
112
+ // "zero"/optional part) plus one tick (the "one" part) further out.
113
+ const crowsfootOneOptionalMarker = this._createElement('marker', { id: this.markerId('crowsfoot-one-optional'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
114
+ crowsfootOneOptionalMarker.appendChild(this._createElement('circle', { cx: 12, cy: 5, r: 3, class: 'arrowhead-path-hollow' }));
115
+ crowsfootOneOptionalMarker.appendChild(this._createElement('path', { d: 'M5,1 L5,9', class: 'arrowhead-path-open' }));
116
+ defs.appendChild(crowsfootOneOptionalMarker);
117
+ // "Zero or many" (optional, many cardinality) — the crow's-foot fork
118
+ // alone, its three prongs touching the entity edge.
119
+ const crowsfootManyMarker = this._createElement('marker', { id: this.markerId('crowsfoot-many'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
120
+ crowsfootManyMarker.appendChild(this._createElement('path', { d: 'M5,5 L15,0 M5,5 L15,5 M5,5 L15,10', class: 'arrowhead-path-open' }));
121
+ defs.appendChild(crowsfootManyMarker);
122
+ // "One or many" (mandatory, many cardinality) — the fork plus one
123
+ // tick further out (the "at least one" part).
124
+ const crowsfootManyMandatoryMarker = this._createElement('marker', { id: this.markerId('crowsfoot-many-mandatory'), viewBox: '0 0 16 10', refX: 15, refY: 5, markerWidth: 12, markerHeight: 8, orient: 'auto-start-reverse' });
125
+ crowsfootManyMandatoryMarker.appendChild(this._createElement('path', { d: 'M5,5 L15,0 M5,5 L15,5 M5,5 L15,10 M2,2 L2,8', class: 'arrowhead-path-open' }));
126
+ defs.appendChild(crowsfootManyMandatoryMarker);
127
+
128
+ this.svg.appendChild(defs);
129
+ const style =this._createElement("style");
130
+ style.textContent = `
131
+ .hover {
132
+ fill: orange;
133
+ stroke: #333;
134
+ stroke-width: 2px;
135
+ }
136
+ .selected {
137
+ filter: drop-shadow(0 0 3px var(--ws-accent, #91cc75));
138
+ }
139
+ .selected > path,
140
+ .selected > rect,
141
+ .selected > circle,
142
+ .selected > ellipse,
143
+ .selected > polygon {
144
+ stroke: var(--ws-accent, #91cc75);
145
+ stroke-width: 3px;
146
+ }
147
+ .selected-edge {
148
+ stroke: var(--ws-accent, #91cc75);
149
+ stroke-width: 3px;
150
+ }
151
+ .arrowhead-path {
152
+ fill: inherit;
153
+ stroke: inherit;
154
+ }
155
+ .arrowhead-path-hollow {
156
+ fill: white;
157
+ stroke: black;
158
+ stroke-width: 1px;
159
+ }
160
+ .arrowhead-path-open {
161
+ fill: none;
162
+ stroke: black;
163
+ stroke-width: 1.5px;
164
+ }
165
+ `;
166
+ this.svg.appendChild(style);
167
+ // The main SVG is now the default parent for new elements.
168
+ this.container.appendChild(this.svg);
169
+ }
170
+
171
+ /**
172
+ * Resize the <svg>. Its viewBox follows only while it is still the size it
173
+ * was created with: a diagram sets its own viewBox to its content, and that
174
+ * must survive, so the diagram then scales to the new box instead of
175
+ * being cropped to it.
176
+ */
177
+ resize(width, height) {
178
+ const defaultViewBox = `0 0 ${this.width} ${this.height}`;
179
+ super.resize(width, height);
180
+ this.svg.setAttribute('width', width);
181
+ this.svg.setAttribute('height', height);
182
+ if (this.svg.getAttribute('viewBox') === defaultViewBox) {
183
+ this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
184
+ }
185
+ }
186
+
187
+ clear() {
188
+ this._elMap.clear();
189
+ // Keep <defs> and <style> elements; remove everything else.
190
+ for (const child of [...this.svg.children]) {
191
+ if (child.tagName !== 'style' && child.tagName !== 'defs') child.remove();
192
+ }
193
+ }
194
+ _defineShape(shape,attributes, stroke = null) {
195
+ shape.id=`shape_${this._elMap.size}`;
196
+ const parent = shape.parent || this.svg;
197
+ const el = this._createElement(shape.type, attributes, stroke);
198
+ parent.appendChild(el);
199
+ this._elMap.set(shape.id, { el, shape });
200
+ return shape.id;
201
+ }
202
+ rect(x, y, width, height, fill, stroke, extra) {
203
+ const shape = { type: 'rect', x, y, width, height, fill, stroke, ...extra };
204
+ this._defineShape(shape, { x, y, width, height, fill: fill || 'none', ...extra }, stroke);
205
+ return this._elMap.get(shape.id).el;
206
+ }
207
+
208
+ circle(cx, cy, r, fill, stroke, extra) {
209
+ const shape = { type: 'circle', cx, cy, r, fill, stroke, ...extra};
210
+ this._defineShape(shape, { cx, cy, r, fill: fill || 'none' }, stroke);
211
+ return this._elMap.get(shape.id).el;
212
+ }
213
+
214
+ path(d, fill, stroke, extra = {}) {
215
+ // `extra` (e.g. {tooltip}) merges into the shape descriptor only, not
216
+ // into DOM attributes — hitTest()/tooltip resolution reads it straight
217
+ // off `shape` (see _addTooltipListeners), it was never rendered as a
218
+ // real SVG attribute for rect/circle either. Previously missing here
219
+ // (CanvasRenderer.path already accepted it) — pie/doughnut wedges,
220
+ // candlestick bodies, and box-plot boxes all need path-level tooltips.
221
+ const shape = { type: 'path', d, fill, stroke, ...extra, id: `shape_${this._elMap.size}` };
222
+ // _createElement already applies stroke.color/width as real SVG attributes
223
+ // (see the `stroke` param handling above) — do NOT also set them as inline
224
+ // styles here. Setting style.fill = stroke.color previously clobbered any
225
+ // closed shape's real fill (e.g. graphDiagram's diamond node, filled with
226
+ // the group color but stroked white) with the stroke color instead, since
227
+ // inline styles win over attributes. Open paths (edge lines, fill:'none')
228
+ // never showed the bug since there's no enclosed area to paint either way.
229
+ const pathEl = this._createElement("path", { d, fill: fill || 'none' }, stroke);
230
+ this.svg.appendChild(pathEl);
231
+ this._elMap.set(shape.id, { el: pathEl, shape });
232
+ return pathEl;
233
+ }
234
+
235
+ /** Text width measured with a (shared, off-page) 2D canvas context; the estimate without a document. */
236
+ measureText(text, font = {}) {
237
+ if (typeof document === 'undefined' || typeof document.createElement !== 'function') return super.measureText(text, font);
238
+ const ctx = (SVGRenderer._measureContext ??= document.createElement('canvas').getContext?.('2d'));
239
+ if (!ctx) return super.measureText(text, font);
240
+ ctx.font = BaseRenderer.cssFont(font);
241
+ return ctx.measureText(String(text)).width;
242
+ }
243
+
244
+ text(text, x, y, options = {}) {
245
+ const shape = { type: 'text', text, x, y, ...options, id: `shape_${this._elMap.size}` };
246
+ const attrs = { x, y, fill: options.fill || 'black', 'font-family': options.fontFamily || 'sans-serif', 'font-size': options.fontSize || '12px', 'text-anchor': options.textAnchor || 'start' };
247
+ // Several call sites (graph-node initials/labels, table-node rows) pass
248
+ // verticalAlign:'middle' expecting the text to be vertically centred on
249
+ // its y-coordinate — this was silently ignored (SVG's default baseline
250
+ // is alphabetic, not middle), so multi-row content sat visibly low.
251
+ if (options.verticalAlign === 'middle') attrs['dominant-baseline'] = 'middle';
252
+ if (options.fontWeight) attrs['font-weight'] = options.fontWeight;
253
+ if (options.fontStyle) attrs['font-style'] = options.fontStyle;
254
+ // Every graphDiagram call site passes 'pointer-events':'none' on node
255
+ // initials/labels expecting clicks to fall through to the shape/badge
256
+ // underneath — silently dropped since it wasn't in this whitelist, so
257
+ // filled text always intercepted the click regardless of what callers
258
+ // asked for (surfaced by the new collapse-badge glyph sitting on top
259
+ // of its own circle and eating every click meant for it).
260
+ if (options['pointer-events']) attrs['pointer-events'] = options['pointer-events'];
261
+ // `rotation` is in radians (matches Canvas's ctx.rotate() convention) —
262
+ // convert to degrees for SVG's transform=rotate(deg, cx, cy). Every
263
+ // yAxisLabel call across the chart methods relies on this to render
264
+ // vertically instead of overlapping/clipping past the chart's left edge.
265
+ if (options.rotation) attrs.transform = `rotate(${options.rotation * 180 / Math.PI} ${x} ${y})`;
266
+ const el = this._createElement('text', attrs);
267
+ el.textContent = text;
268
+ this.svg.appendChild(el);
269
+ this._elMap.set(shape.id, { el, shape });
270
+ return el;
271
+ }
272
+
273
+ /** An SVG `<image>` — used for graphDiagram's `node.iconUrl` (a custom
274
+ * icon/image file/data URI centred on a node, in place of the plain
275
+ * initials text). `href` is set as the plain SVG2 attribute; every
276
+ * renderer target this library ships against (real browsers, jsdom in
277
+ * tests) resolves that without also needing the legacy `xlink:href`. */
278
+ image(href, x, y, width, height, options = {}) {
279
+ const shape = { type: 'image', href, x, y, width, height, ...options, id: `shape_${this._elMap.size}` };
280
+ const attrs = { href, x, y, width, height, preserveAspectRatio: options.preserveAspectRatio || 'xMidYMid meet' };
281
+ if (options['pointer-events']) attrs['pointer-events'] = options['pointer-events'];
282
+ const el = this._createElement('image', attrs);
283
+ this.svg.appendChild(el);
284
+ this._elMap.set(shape.id, { el, shape });
285
+ return el;
286
+ }
287
+
288
+ group(attributes = {}) {
289
+ const shape = { type: 'g', ...attributes, id: `shape_${this._elMap.size}` };
290
+ const el = this._createElement('g', attributes);
291
+ this.svg.appendChild(el);
292
+ this._elMap.set(shape.id, { el, shape });
293
+ return el;
294
+ }
295
+
296
+ hitTest(x, y) {
297
+ // This is a bit of a hack. We find the element and then iterate our map to find the shape.
298
+ // A more robust solution might use a WeakMap from element to shape.
299
+ //
300
+ // `x`/`y` are viewport coordinates (every caller in index.js passes
301
+ // event.clientX/clientY straight through) — document.elementFromPoint
302
+ // already takes coordinates in that same space. Adding this.svg's own
303
+ // getBoundingClientRect().left/top on top double-offset the query
304
+ // point by the canvas's on-page position, so hitTest silently queried
305
+ // the wrong spot on the page for any layout where the SVG isn't flush
306
+ // against the browser's top-left corner (i.e. basically always, once
307
+ // there's a toolbar/sidebar around it) — drag-start, the context
308
+ // menu, and tooltips all read this result directly, so all three
309
+ // misfired by exactly that offset. Zoom (the SVG's internal viewBox
310
+ // scale) doesn't factor in here at all: elementFromPoint operates on
311
+ // real rendered screen pixels, which already reflect whatever zoom is
312
+ // applied, so no CTM/zoom math belongs in this function.
313
+ const hitEl = this.svg.ownerDocument.elementFromPoint(x, y);
314
+ if (!hitEl) return null;
315
+ return SVGRenderer.resolveHit(this._elMap, hitEl);
316
+ }
317
+
318
+ /**
319
+ * Given the element under the cursor and the el→{el,shape} registry, picks
320
+ * which registered shape a hit resolves to. Split out from hitTest() so
321
+ * this candidate-selection logic is unit-testable without a real DOM
322
+ * (`hitEl`/registered `el`s just need a `.contains()` method).
323
+ *
324
+ * A graph node is drawn as an outer <g draggable nodeId tooltip> wrapping
325
+ * an inner shape primitive (circle/rect/path) plus text labels — each
326
+ * registered in _elMap separately, with the inner primitive inserted
327
+ * *before* the wrapping group. Since _elMap is iterated in insertion order
328
+ * and this used to return the *first* match, a click on the node's own
329
+ * visible body resolved to the inner primitive's bare shape (no
330
+ * draggable/tooltip/nodeId) instead of the wrapping group — silently
331
+ * breaking drag-start, the context menu, and hover tooltips (all of which
332
+ * read hitTest()'s result directly, unlike click-to-select which bubbles
333
+ * via a real DOM listener). Prefer a draggable node wrapper among the
334
+ * candidates when one is present; otherwise keep the original first-match
335
+ * behavior (legend markers etc. resolve via their own direct listeners
336
+ * already, not this method).
337
+ */
338
+ static resolveHit(elMap, hitEl) {
339
+ let first = null;
340
+ for (const { el, shape } of elMap.values()) {
341
+ if (el === hitEl || el.contains(hitEl)) {
342
+ if (!first) first = shape;
343
+ if (shape.draggable && shape.nodeId != null) return shape;
344
+ }
345
+ }
346
+ return first;
347
+ }
348
+
349
+ enterState(shape, stateName) {
350
+ this._elMap.get(shape.id)?.el.classList.add(stateName);
351
+ }
352
+
353
+ leaveState(shape, stateName) {
354
+ this._elMap.get(shape.id)?.el.classList.remove(stateName);
355
+ }
356
+
357
+ renderDiagram(diagram) {
358
+ // 20,20 padding matches this file's own DiagramNode.prototype.drawOn
359
+ // (railroadDiagram.js, the Canvas rendering path) — without a real
360
+ // starting x/y, every node's format() computes its own position as
361
+ // NaN (this used to be called with zero args, so x/y were undefined
362
+ // throughout the whole tree).
363
+ diagram.format(20, 20);
364
+
365
+ const group = this._createElement('g', {
366
+ 'font-family': 'monospace',
367
+ 'font-size': '12px'
368
+ });
369
+
370
+ // Collect every leaf first, then draw in a fixed path -> rect -> text
371
+ // paint order, rather than the tree's own traversal order. Connector
372
+ // lines (Sequence's backbone, ZeroOrMore's top line, Choice's arcs)
373
+ // legitimately run behind boxes they connect through — but walk()
374
+ // visits nodes in whatever order each FORMATTERS.* function happened
375
+ // to build its .item/.items — e.g. a Sequence's own backbone Path is
376
+ // appended (via addTo()) AFTER its child items are formatted, so it
377
+ // painted on top of and struck through every terminal/nonterminal
378
+ // box's text. A fixed paint order guarantees lines stay underneath
379
+ // opaque boxes and text stays readable on top, regardless of which
380
+ // order the diagram tree happens to store its children in.
381
+ const leaves = [];
382
+ diagram.walk(item => {
383
+ // The `if (item.type === 'rect'/'path'/'text')` chain below
384
+ // already correctly picks out only real leaf shapes — every
385
+ // intermediate DiagramNode-tree node (Sequence/Choice/Terminal/
386
+ // NonTerminal/...) has a .type that never matches any of those,
387
+ // so it's harmless to reach the chain and fall through. A prior
388
+ // `if (!item.width) return;` guard here was presumably meant to
389
+ // skip those same intermediate nodes, but 'text' and 'path'
390
+ // FakeSVG leaves never carry a .width at all (only 'rect' does —
391
+ // same as real SVG <text>/<path> elements), so it silently
392
+ // discarded every label and every connector line too.
393
+ if (item.type === 'rect' || item.type === 'path' || item.type === 'text') leaves.push(item);
394
+ });
395
+ const paintOrder = { path: 0, rect: 1, text: 2 };
396
+ leaves.sort((a, b) => paintOrder[a.type] - paintOrder[b.type]);
397
+ leaves.forEach(item => {
398
+ if (item.type === 'rect') {
399
+ // NonTerminal rects have no rx/ry (sharp corners, by design —
400
+ // see railroadDiagram.js's FORMATTERS.NonTerminal) — only
401
+ // pass them through when real, rather than setting a literal
402
+ // "undefined" string attribute value.
403
+ const extra = { parent: group };
404
+ if (item.rx != null) extra.rx = item.rx;
405
+ if (item.ry != null) extra.ry = item.ry;
406
+ this.rect(item.x, item.y, item.width, item.height, 'hsl(30,20%,95%)', { color: 'black', width: 2 }, extra);
407
+ } else if (item.type === 'path') {
408
+ const pathEl = this._createElement('path', { d: item.attrs.d }, { color: 'black', width: 3 });
409
+ pathEl.setAttribute('fill', 'none');
410
+ group.appendChild(pathEl);
411
+ } else if (item.type === 'text') {
412
+ const textEl = this._createElement('text', {
413
+ x: item.x,
414
+ y: item.y,
415
+ 'text-anchor': 'middle',
416
+ fill: 'black'
417
+ });
418
+ if (item.attrs.class === 'comment') {
419
+ textEl.setAttribute('font-style', 'italic');
420
+ } else if (item.type === 'NonTerminal') {
421
+ textEl.setAttribute('font-weight', 'bold');
422
+ }
423
+ textEl.textContent = item.text;
424
+ group.appendChild(textEl);
425
+ }
426
+ });
427
+
428
+ // Every other SVGRenderer primitive (group()/rect()/circle()/...)
429
+ // appends itself to this.svg (or a specified parent) within its own
430
+ // method — this one built `group` and populated it via the walk
431
+ // callback's this.rect(...) calls (which do append into `group`,
432
+ // via the `parent: group` extra above), but never attached `group`
433
+ // to the actual <svg> itself, so nothing was ever visible.
434
+ this.svg.appendChild(group);
435
+
436
+ // A diagram's natural width is data-driven (however many rule
437
+ // elements it has) and routinely wider than a chart's fixed
438
+ // container box. Only the viewBox is updated to the content's real
439
+ // size — width/height stay at whatever they were set to at
440
+ // construction (the container's own clientWidth/clientHeight, same
441
+ // as every other chart type) — so the SVG's default
442
+ // preserveAspectRatio="xMidYMid meet" scales the whole diagram down
443
+ // to fit that fixed box, exactly like an <img>: no scrollbar, no
444
+ // clipping, just a smaller rendering for a wide diagram. (An
445
+ // earlier version of this also resized width/height to match the
446
+ // content, which fit the diagram at full native size but broke
447
+ // the page's CSS grid layout — a grid item's automatic minimum
448
+ // size is based on its content's intrinsic size unless something
449
+ // caps it — and even with that capped, still needed a scrollbar to
450
+ // reach the full content. Not resizing width/height avoids both.)
451
+ const w = diagram.width + 40, h = diagram.height + 40;
452
+ this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
453
+
454
+ return group;
455
+ }
456
+
457
+ /**
458
+ * Add a <linearGradient> to <defs> and return its url(#id) reference.
459
+ * @param {string} id
460
+ * @param {Array<{offset: string|number, color: string, opacity?: number}>} stops
461
+ * @param {{x1?: string, y1?: string, x2?: string, y2?: string}} [options]
462
+ * @returns {string} url(#id)
463
+ */
464
+ linearGradient(id, stops = [], { x1 = '0%', y1 = '0%', x2 = '0%', y2 = '100%' } = {}) {
465
+ const existing = this.svg?.querySelector ? this.svg.querySelector(`#${id}`) : null;
466
+ if (existing) return `url(#${id})`;
467
+ const grad = this._createElement('linearGradient', { id, x1, y1, x2, y2 });
468
+ for (const stop of stops) {
469
+ const offset = typeof stop.offset === 'number' ? `${stop.offset * 100}%` : stop.offset;
470
+ const attrs = { offset, 'stop-color': stop.color };
471
+ if (stop.opacity !== undefined) attrs['stop-opacity'] = String(stop.opacity);
472
+ grad.appendChild(this._createElement('stop', attrs));
473
+ }
474
+ this.defs?.appendChild(grad);
475
+ return `url(#${id})`;
476
+ }
455
477
  }