@peter.prib/renderengine 0.14.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.
Files changed (109) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49391 -39748
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +38 -0
  6. package/package.json +6 -1
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +219 -0
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +3 -0
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +114 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/charts/AlluvialDiagram.js +311 -0
  34. package/src/charting/charts/AreaChart.js +52 -15
  35. package/src/charting/charts/BarChart.js +38 -3
  36. package/src/charting/charts/BarChart3D.js +5 -3
  37. package/src/charting/charts/BarChartBase.js +26 -6
  38. package/src/charting/charts/BeeswarmChart.js +164 -0
  39. package/src/charting/charts/BoxPlotChart.js +23 -1
  40. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  41. package/src/charting/charts/CandlestickChart.js +172 -4
  42. package/src/charting/charts/ChordDiagram.js +105 -2
  43. package/src/charting/charts/CirclePackChart.js +81 -0
  44. package/src/charting/charts/ComboChart.js +233 -0
  45. package/src/charting/charts/ContourChart.js +310 -0
  46. package/src/charting/charts/DonutChart3D.js +13 -0
  47. package/src/charting/charts/DumbbellChart.js +217 -0
  48. package/src/charting/charts/EcdfChart.js +150 -0
  49. package/src/charting/charts/FunnelChart.js +4 -1
  50. package/src/charting/charts/GeoMapChart.js +132 -11
  51. package/src/charting/charts/GroupedBarChart.js +44 -3
  52. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  53. package/src/charting/charts/HeatmapChart.js +2 -2
  54. package/src/charting/charts/HexbinChart.js +137 -0
  55. package/src/charting/charts/HierarchyChart.js +77 -3
  56. package/src/charting/charts/HorizonChart.js +105 -0
  57. package/src/charting/charts/KagiChart.js +167 -0
  58. package/src/charting/charts/LineChart.js +74 -18
  59. package/src/charting/charts/OhlcChart.js +79 -0
  60. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  61. package/src/charting/charts/PieChart.js +195 -17
  62. package/src/charting/charts/PieChart3D.js +137 -0
  63. package/src/charting/charts/PointAndFigureChart.js +198 -0
  64. package/src/charting/charts/PolarAreaChart.js +189 -0
  65. package/src/charting/charts/RadarChart.js +7 -1
  66. package/src/charting/charts/RadialBarChart.js +5 -2
  67. package/src/charting/charts/RenkoChart.js +178 -0
  68. package/src/charting/charts/RidgelineChart.js +113 -0
  69. package/src/charting/charts/SankeyDiagram.js +127 -4
  70. package/src/charting/charts/ScatterChart.js +13 -0
  71. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  72. package/src/charting/charts/SlopegraphChart.js +205 -0
  73. package/src/charting/charts/SparklineTableChart.js +166 -0
  74. package/src/charting/charts/StackedBarChart.js +48 -5
  75. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  76. package/src/charting/charts/StreamGraphChart.js +10 -3
  77. package/src/charting/charts/SunburstChart.js +27 -7
  78. package/src/charting/charts/SurfaceChart3D.js +157 -0
  79. package/src/charting/charts/TreemapChart.js +28 -8
  80. package/src/charting/charts/ViolinChart.js +18 -0
  81. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  82. package/src/charting/datamodel/DataLineage.js +150 -0
  83. package/src/charting/datamodel/DataModel.js +83 -1
  84. package/src/charting/datamodel/ModelMapping.js +87 -0
  85. package/src/charting/datamodel/SqlDdl.js +110 -85
  86. package/src/charting/datamodel/index.js +3 -0
  87. package/src/charting/decision/DecisionWorkbench.js +295 -0
  88. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  89. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  90. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  91. package/src/charting/diagrams/SketchView.js +121 -0
  92. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  93. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  94. package/src/charting/modelTree/NavigatorModel.js +410 -0
  95. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  96. package/src/charting/pivot/PivotChart.js +109 -4
  97. package/src/charting/pivot/PivotGridChart.js +321 -33
  98. package/src/charting/pivot/PivotView.js +82 -0
  99. package/src/charting/report/TemplatedReport.js +547 -0
  100. package/src/charting/templates/BoxTemplate.js +28 -27
  101. package/src/charting/templates/PackageTemplate.js +26 -25
  102. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  103. package/src/charting/templates/Template.js +219 -212
  104. package/src/cli.js +146 -0
  105. package/src/index.js +3370 -2995
  106. package/src/workspace/Workspace.js +126 -7
  107. package/src/workspace/archimateServices.js +4 -0
  108. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  109. package/src/workspace/styles.js +36 -1
@@ -3200,6 +3200,44 @@ to it) / `deleteEdgeCommand(id)`. A subclass whose shapes and lines stand for so
3200
3200
  overrides those factories (ArchimateCanvasEditor does). A template class says whether the editor
3201
3201
  offers it a rotate handle (`static rotatable`, true by default; ArchiMate's templates are not). `pickFile(accept)` resolves with the chosen `File` (or `null`).
3202
3202
 
3203
+ ## Decision Workbench (`DecisionWorkbench`)
3204
+
3205
+ The views of a SADIS recommendation made by decisionengine (`gitlab.com/peter.prib/decisionengine`),
3206
+ drawn with this library's charts. It reads decisionengine's JSON, not decisionengine: a workbench
3207
+ document
3208
+
3209
+ ```js
3210
+ { kind: 'sadis-workbench', report, plan?, before? } // report.toJSON(), plan.toJSON(), an earlier report.toJSON()
3211
+ ```
3212
+
3213
+ which decisionengine's SADIS Explorer downloads ("Download for the Decision Workbench").
3214
+
3215
+ ```js
3216
+ import { DecisionWorkbench } from '@peter.prib/renderengine';
3217
+ const workbench = DecisionWorkbench.from(doc, { start: '2026-11-02', periodDays: 14 });
3218
+ for (const view of workbench.views()) {
3219
+ view.id, view.title; // 'executive', 'Executive', …
3220
+ view.charts(); // [{ title, chartType, data, options }] — chartType is a CHART_CATALOG key
3221
+ view.notes(); // plain lines for what the charts cannot show
3222
+ }
3223
+ ```
3224
+
3225
+ | View | Needs | Charts |
3226
+ |---|---|---|
3227
+ | Executive | a report | `kpi-tile` (what to build next; half the value delivered by), `stackedBar` (score part by part), `line` (value delivered by period) |
3228
+ | Architecture | a report | `scatter` (visible rating against basin position: foundations sit high and left), `lollipop` (share each frees) |
3229
+ | Delivery | a plan | `gantt` (the plan on a calendar), `stackedBar` (capacity used per period, per team when planned by team) |
3230
+ | Portfolio | a report with perspectives | `heatmap` (rank by perspective), `dumbbell` (best and worst rank, most contested first) |
3231
+ | Replay | an earlier report | `slopegraph` (rank then → now); notes say what entered, left (and why) and moved |
3232
+
3233
+ A view the document cannot fill is not offered. `from()` refuses what is not a workbench document,
3234
+ saying why. The delivery plan's periods are put on a calendar: period 1 starts at `start` (default:
3235
+ the report's as-of business time, else today), each `periodDays` long (default 14). Each view is a
3236
+ `WorkbenchView` subclass (`WORKBENCH_VIEWS`); a new view is a new subclass.
3237
+
3238
+ `examples/decision-workbench.html` is the page: open a workbench file and each view is a workspace
3239
+ perspective, its charts in `ChartPart`s and its notes in a list.
3240
+
3203
3241
  ## Navigation menus
3204
3242
 
3205
3243
  `NavMenu` is a vertical navigation menu (a sidebar's), written as data: no markup. Each entry is
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "ArchiMate modeller, data modeller, diagram editor, OLAP cube & pivot chart studio, and 50+ type chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
8
8
  "browser": "dist/renderengine.umd.js",
9
+ "bin": {
10
+ "renderengine": "./bin/renderengine.mjs"
11
+ },
9
12
  "exports": {
10
13
  ".": "./src/index.js",
11
14
  "./workspace": "./src/workspace/index.js",
@@ -14,6 +17,7 @@
14
17
  "./package.json": "./package.json"
15
18
  },
16
19
  "files": [
20
+ "bin/*.mjs",
17
21
  "src/",
18
22
  "dist/*.umd.js",
19
23
  "examples/archimate.html",
@@ -24,6 +28,7 @@
24
28
  "LICENSE"
25
29
  ],
26
30
  "scripts": {
31
+ "cli": "node bin/renderengine.mjs",
27
32
  "start": "node scripts/open.mjs examples/archimate.html",
28
33
  "archimate": "node scripts/open.mjs examples/archimate.html",
29
34
  "diagram": "node scripts/open.mjs examples/diagram-editor.html",
@@ -1,201 +1,226 @@
1
- import { BaseRenderer } from './BaseRenderer.js';
2
-
3
- /**
4
- * @class CanvasRenderer
5
- * Renders shapes by drawing on a 2D canvas context.
6
- */
7
- export class CanvasRenderer extends BaseRenderer {
8
- get type() { return 'canvas'; }
9
- get centerTextAnchor() { return 'center'; }
10
-
11
- constructor(container, width, height) {
12
- super(container, width, height);
13
- this.canvas = document.createElement('canvas');
14
- this.canvas.width = width;
15
- this.canvas.height = height;
16
- // Inline elements (the default for <canvas>) leave a few px of baseline
17
- // whitespace below them inside a block container, making the
18
- // container's scrollHeight exceed its clientHeight for every chart.
19
- this.canvas.style.display = 'block';
20
- this._shapes = [];
21
- this.container.appendChild(this.canvas);
22
- this.ctx = this.canvas.getContext('2d');
23
- }
24
-
25
- /** Resize the canvas. Setting a canvas's size clears it, so redraw after. */
26
- resize(width, height) {
27
- super.resize(width, height);
28
- this.canvas.width = width;
29
- this.canvas.height = height;
30
- }
31
-
32
- clear() {
33
- this.ctx.clearRect(0, 0, this.width, this.height);
34
- this._shapes = [];
35
- }
36
-
37
- // Always returns an isolated Path2D — used by hitTest() to test one
38
- // shape's geometry at a time. Previously rect/circle called this.ctx.rect()/
39
- // .arc() directly onto the canvas's own current path without resetting it
40
- // first, so each hitTest() loop iteration kept accumulating geometry from
41
- // every shape checked before it, corrupting hit results for anything but
42
- // the very first shape tested (tooltips/clicks/hover on Canvas charts
43
- // could resolve to the wrong shape).
44
- _drawPath(shape) {
45
- const p = new Path2D();
46
- switch (shape.type) {
47
- case 'rect':
48
- p.rect(shape.x, shape.y, shape.width, shape.height);
49
- return p;
50
- case 'circle':
51
- p.arc(shape.cx, shape.cy, shape.r, 0, 2 * Math.PI);
52
- return p;
53
- case 'path':
54
- return new Path2D(shape.d);
55
- default:
56
- return p; // empty — no geometry to hit-test
57
- }
58
- }
59
- _setShapeType={
60
- rect:(shape)=>this.ctx.rect(shape.x, shape.y, shape.width, shape.height),
61
- circle:(shape)=>this.ctx.arc(shape.cx, shape.cy, shape.r, 0, 2 * Math.PI),
62
- path:(shape,fill,stroke)=>{
63
- const p = new Path2D(shape.d);
64
- if (fill && fill !== 'none') { this.ctx.fillStyle = fill; this.ctx.fill(p); }
65
- if (stroke && stroke.color !== 'none') {
66
- this.ctx.strokeStyle = stroke.color;
67
- this.ctx.lineWidth = stroke.width || 1;
68
- // Used by the gauge arcs' rounded end-caps ({cap: 'round'}).
69
- this.ctx.lineCap = stroke.cap || 'butt';
70
- this.ctx.stroke(p);
71
- }
72
- }
73
- }
74
- // Shared by text() (initial draw) and _draw()'s 'text' case (redraw on
75
- // hover enter/leave) so rotation/alignment logic isn't duplicated.
76
- _paintText(shape) {
77
- const hoverStyle = shape._hoverStyle;
78
- const fill = hoverStyle?.fill ?? shape.fill;
79
- this.ctx.fillStyle = fill || 'black';
80
- this.ctx.font = `${shape.fontStyle || ''} ${shape.fontWeight || ''} ${shape.fontSize || '12px'} ${shape.fontFamily || 'sans-serif'}`.trim().replace(/\s+/g, ' ');
81
- let textAlign = shape.textAnchor || 'start';
82
- if (textAlign === 'middle') textAlign = 'center';
83
- this.ctx.textAlign = textAlign;
84
- this.ctx.textBaseline = 'middle';
85
- if (shape.rotation) {
86
- this.ctx.save();
87
- this.ctx.translate(shape.x, shape.y);
88
- this.ctx.rotate(shape.rotation);
89
- this.ctx.fillText(shape.text, 0, 0);
90
- this.ctx.restore();
91
- } else {
92
- this.ctx.fillText(shape.text, shape.x, shape.y);
93
- }
94
- }
95
- _draw(shape) {
96
- if (shape.type === 'text') { this._paintText(shape); return; }
97
-
98
- const hoverStyle = shape._hoverStyle;
99
- const fill = hoverStyle?.fill ?? shape.fill;
100
- const stroke = hoverStyle?.stroke ?? shape.stroke;
101
-
102
- // For path, we need to handle fill and stroke separately inside the case.
103
- const isPath = shape.type === 'path';
104
-
105
- this.ctx.beginPath();
106
- this._setShapeType[shape.type](shape, fill, stroke);
107
- if (isPath) return
108
- if (fill && fill !== 'none') {
109
- this.ctx.fillStyle = fill;
110
- this.ctx.fill();
111
- }
112
- if (stroke) {
113
- this.ctx.strokeStyle = stroke.color || 'none';
114
- this.ctx.lineWidth = stroke.width || 1;
115
- this.ctx.stroke();
116
- }
117
- }
118
-
119
- _redraw() {
120
- this.ctx.clearRect(0, 0, this.width, this.height);
121
- this._shapes.forEach(shape => this._draw(shape));
122
- }
123
-
124
- rect(x, y, width, height, fill, stroke, extra = {}) {
125
- const shape = { type: 'rect', x, y, width, height, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
126
- this._shapes.push(shape);
127
- this._draw(shape);
128
- }
129
-
130
- circle(cx, cy, r, fill, stroke, extra = {}) {
131
- const shape = { type: 'circle', cx, cy, r, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
132
- this._shapes.push(shape);
133
- this._draw(shape);
134
- }
135
-
136
- path(d, fill, stroke, extra = {}) {
137
- const shape = { type: 'path', d, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
138
- this._shapes.push(shape);
139
- this._draw(shape);
140
- }
141
-
142
- /** Text width measured by this renderer's own 2D context. */
143
- measureText(text, font = {}) {
144
- if (!this.ctx?.measureText) return super.measureText(text, font);
145
- this.ctx.save();
146
- this.ctx.font = BaseRenderer.cssFont(font);
147
- const width = this.ctx.measureText(String(text)).width;
148
- this.ctx.restore();
149
- return width;
150
- }
151
-
152
- text(text, x, y, options = {}) {
153
- const shape = { type: 'text', text, x, y, ...options, id: `shape_${this._shapes.length}` };
154
- this._shapes.push(shape);
155
- this._paintText(shape);
156
- }
157
-
158
- hitTest(x, y) {
159
- for (let i = this._shapes.length - 1; i >= 0; i--) {
160
- const shape = this._shapes[i];
161
- const path2d = this._drawPath(shape);
162
-
163
- // 1. Check for a fill hit first.
164
- if (shape.fill && shape.fill !== 'none') {
165
- if (path2d ? this.ctx.isPointInPath(path2d, x, y) : this.ctx.isPointInPath(x, y)) {
166
- return shape;
167
- }
168
- }
169
-
170
- // 2. If no fill hit, check for a stroke hit.
171
- if (shape.stroke && shape.stroke.color !== 'none') {
172
- this.ctx.lineWidth = shape.stroke.width || 1;
173
- if (path2d ? this.ctx.isPointInStroke(path2d, x, y) : this.ctx.isPointInStroke(x, y)) {
174
- return shape;
175
- }
176
- }
177
- }
178
- return null;
179
- }
180
-
181
- enterState(shape, stateName) {
182
- const targetShape = this._shapes.find(s => s.id === shape.id);
183
- if (targetShape) {
184
- targetShape._hoverStyle = { fill: 'orange', stroke: { color: '#333', width: 2 } };
185
- this._redraw();
186
- }
187
- }
188
-
189
- leaveState(shape, stateName) {
190
- const targetShape = this._shapes.find(s => s.id === shape.id);
191
- if (targetShape) {
192
- delete targetShape._hoverStyle;
193
- this._redraw();
194
- }
195
- }
196
-
197
- renderDiagram(diagram) {
198
- // Draw the diagram directly onto the canvas context.
199
- diagram.drawOn(this.ctx);
200
- }
1
+ import { BaseRenderer } from './BaseRenderer.js';
2
+
3
+ /**
4
+ * @class CanvasRenderer
5
+ * Renders shapes by drawing on a 2D canvas context.
6
+ */
7
+ export class CanvasRenderer extends BaseRenderer {
8
+ get type() { return 'canvas'; }
9
+ get centerTextAnchor() { return 'center'; }
10
+
11
+ constructor(container, width, height) {
12
+ super(container, width, height);
13
+ const doc = typeof document !== 'undefined' ? document : container?.ownerDocument;
14
+ this.canvas = doc?.createElement ? doc.createElement('canvas') : (container?.canvas || {
15
+ style: {}, width, height,
16
+ getContext: () => ({ fillRect: () => {}, measureText: () => ({ width: 10 }), clearRect: () => {}, beginPath: () => {}, arc: () => {}, fill: () => {}, stroke: () => {} }),
17
+ });
18
+ this.canvas.width = width;
19
+ this.canvas.height = height;
20
+ // Inline elements (the default for <canvas>) leave a few px of baseline
21
+ // whitespace below them inside a block container, making the
22
+ // container's scrollHeight exceed its clientHeight for every chart.
23
+ if (this.canvas?.style) this.canvas.style.display = 'block';
24
+ this._shapes = [];
25
+ this.container?.appendChild?.(this.canvas);
26
+ this.ctx = this.canvas?.getContext?.('2d') || null;
27
+ }
28
+
29
+ /** Resize the canvas. Setting a canvas's size clears it, so redraw after. */
30
+ resize(width, height) {
31
+ super.resize(width, height);
32
+ this.canvas.width = width;
33
+ this.canvas.height = height;
34
+ }
35
+
36
+ clear() {
37
+ this.ctx.clearRect(0, 0, this.width, this.height);
38
+ this._shapes = [];
39
+ }
40
+
41
+ // Always returns an isolated Path2D — used by hitTest() to test one
42
+ // shape's geometry at a time. Previously rect/circle called this.ctx.rect()/
43
+ // .arc() directly onto the canvas's own current path without resetting it
44
+ // first, so each hitTest() loop iteration kept accumulating geometry from
45
+ // every shape checked before it, corrupting hit results for anything but
46
+ // the very first shape tested (tooltips/clicks/hover on Canvas charts
47
+ // could resolve to the wrong shape).
48
+ _drawPath(shape) {
49
+ const p = new Path2D();
50
+ switch (shape.type) {
51
+ case 'rect':
52
+ p.rect(shape.x, shape.y, shape.width, shape.height);
53
+ return p;
54
+ case 'circle':
55
+ p.arc(shape.cx, shape.cy, shape.r, 0, 2 * Math.PI);
56
+ return p;
57
+ case 'path':
58
+ return new Path2D(shape.d);
59
+ default:
60
+ return p; // empty — no geometry to hit-test
61
+ }
62
+ }
63
+ _setShapeType={
64
+ rect:(shape)=>this.ctx.rect(shape.x, shape.y, shape.width, shape.height),
65
+ circle:(shape)=>this.ctx.arc(shape.cx, shape.cy, shape.r, 0, 2 * Math.PI),
66
+ path:(shape,fill,stroke)=>{
67
+ const p = new Path2D(shape.d);
68
+ if (fill && fill !== 'none') { this.ctx.fillStyle = fill; this.ctx.fill(p); }
69
+ if (stroke && stroke.color !== 'none') {
70
+ this.ctx.strokeStyle = stroke.color;
71
+ this.ctx.lineWidth = stroke.width || 1;
72
+ // Used by the gauge arcs' rounded end-caps ({cap: 'round'}).
73
+ this.ctx.lineCap = stroke.cap || 'butt';
74
+ this.ctx.stroke(p);
75
+ }
76
+ }
77
+ }
78
+ // Shared by text() (initial draw) and _draw()'s 'text' case (redraw on
79
+ // hover enter/leave) so rotation/alignment logic isn't duplicated.
80
+ _paintText(shape) {
81
+ const hoverStyle = shape._hoverStyle;
82
+ const fill = hoverStyle?.fill ?? shape.fill;
83
+ this.ctx.fillStyle = fill || 'black';
84
+ this.ctx.font = `${shape.fontStyle || ''} ${shape.fontWeight || ''} ${shape.fontSize || '12px'} ${shape.fontFamily || 'sans-serif'}`.trim().replace(/\s+/g, ' ');
85
+ let textAlign = shape.textAnchor || 'start';
86
+ if (textAlign === 'middle') textAlign = 'center';
87
+ this.ctx.textAlign = textAlign;
88
+ this.ctx.textBaseline = 'middle';
89
+ if (shape.rotation) {
90
+ this.ctx.save();
91
+ this.ctx.translate(shape.x, shape.y);
92
+ this.ctx.rotate(shape.rotation);
93
+ this.ctx.fillText(shape.text, 0, 0);
94
+ this.ctx.restore();
95
+ } else {
96
+ this.ctx.fillText(shape.text, shape.x, shape.y);
97
+ }
98
+ }
99
+ _draw(shape) {
100
+ if (shape.type === 'text') { this._paintText(shape); return; }
101
+
102
+ const hoverStyle = shape._hoverStyle;
103
+ const fill = hoverStyle?.fill ?? shape.fill;
104
+ const stroke = hoverStyle?.stroke ?? shape.stroke;
105
+
106
+ // For path, we need to handle fill and stroke separately inside the case.
107
+ const isPath = shape.type === 'path';
108
+
109
+ this.ctx.beginPath();
110
+ this._setShapeType[shape.type](shape, fill, stroke);
111
+ if (isPath) return
112
+ if (fill && fill !== 'none') {
113
+ this.ctx.fillStyle = fill;
114
+ this.ctx.fill();
115
+ }
116
+ if (stroke) {
117
+ this.ctx.strokeStyle = stroke.color || 'none';
118
+ this.ctx.lineWidth = stroke.width || 1;
119
+ this.ctx.stroke();
120
+ }
121
+ }
122
+
123
+ _redraw() {
124
+ this.ctx.clearRect(0, 0, this.width, this.height);
125
+ this._shapes.forEach(shape => this._draw(shape));
126
+ }
127
+
128
+ rect(x, y, width, height, fill, stroke, extra = {}) {
129
+ const shape = { type: 'rect', x, y, width, height, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
130
+ this._shapes.push(shape);
131
+ this._draw(shape);
132
+ return shape;
133
+ }
134
+
135
+ circle(cx, cy, r, fill, stroke, extra = {}) {
136
+ const shape = { type: 'circle', cx, cy, r, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
137
+ this._shapes.push(shape);
138
+ this._draw(shape);
139
+ return shape;
140
+ }
141
+
142
+ path(d, fill, stroke, extra = {}) {
143
+ const shape = { type: 'path', d, fill, stroke, id: `shape_${this._shapes.length}`, ...extra };
144
+ this._shapes.push(shape);
145
+ this._draw(shape);
146
+ return shape;
147
+ }
148
+
149
+ /** Text width measured by this renderer's own 2D context. */
150
+ measureText(text, font = {}) {
151
+ if (!this.ctx?.measureText) return super.measureText(text, font);
152
+ this.ctx.save();
153
+ this.ctx.font = BaseRenderer.cssFont(font);
154
+ const width = this.ctx.measureText(String(text)).width;
155
+ this.ctx.restore();
156
+ return width;
157
+ }
158
+
159
+ text(text, x, y, options = {}) {
160
+ const shape = { type: 'text', text, x, y, ...options, id: `shape_${this._shapes.length}` };
161
+ this._shapes.push(shape);
162
+ this._paintText(shape);
163
+ return shape;
164
+ }
165
+
166
+ hitTest(x, y) {
167
+ for (let i = this._shapes.length - 1; i >= 0; i--) {
168
+ const shape = this._shapes[i];
169
+ const path2d = this._drawPath(shape);
170
+
171
+ // 1. Check for a fill hit first.
172
+ if (shape.fill && shape.fill !== 'none') {
173
+ if (path2d ? this.ctx.isPointInPath(path2d, x, y) : this.ctx.isPointInPath(x, y)) {
174
+ return shape;
175
+ }
176
+ }
177
+
178
+ // 2. If no fill hit, check for a stroke hit.
179
+ if (shape.stroke && shape.stroke.color !== 'none') {
180
+ this.ctx.lineWidth = shape.stroke.width || 1;
181
+ if (path2d ? this.ctx.isPointInStroke(path2d, x, y) : this.ctx.isPointInStroke(x, y)) {
182
+ return shape;
183
+ }
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ enterState(shape, stateName) {
190
+ const targetShape = this._shapes.find(s => s.id === shape.id);
191
+ if (targetShape) {
192
+ targetShape._hoverStyle = { fill: 'orange', stroke: { color: '#333', width: 2 } };
193
+ this._redraw();
194
+ }
195
+ }
196
+
197
+ leaveState(shape, stateName) {
198
+ const targetShape = this._shapes.find(s => s.id === shape.id);
199
+ if (targetShape) {
200
+ delete targetShape._hoverStyle;
201
+ this._redraw();
202
+ }
203
+ }
204
+
205
+ renderDiagram(diagram) {
206
+ // Draw the diagram directly onto the canvas context.
207
+ diagram.drawOn(this.ctx);
208
+ }
209
+
210
+ /**
211
+ * Create a linear gradient on this canvas context.
212
+ * @param {string} _id
213
+ * @param {Array<{offset: string|number, color: string}>} stops
214
+ * @param {{x0?: number, y0?: number, x1?: number, y1?: number}} [coords]
215
+ * @returns {CanvasGradient|null}
216
+ */
217
+ linearGradient(_id, stops = [], { x0 = 0, y0 = 0, x1 = 0, y1 = 100 } = {}) {
218
+ if (!this.ctx?.createLinearGradient) return null;
219
+ const grad = this.ctx.createLinearGradient(x0, y0, x1, y1);
220
+ for (const stop of stops) {
221
+ const offset = typeof stop.offset === 'number' ? stop.offset : (parseFloat(stop.offset) || 0) / 100;
222
+ grad.addColorStop(offset, stop.color);
223
+ }
224
+ return grad;
225
+ }
201
226
  }