@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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  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 +269 -3
  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 +7 -2
  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 +142 -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/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. package/src/workspace/styles.js +36 -1
@@ -74,7 +74,7 @@ catalog `key` for you.
74
74
  | `sankey` | `sankeyDiagram(data, opts)` | `{nodes?: Array<{id,label?,color?}>, links: Array<{source,target,value}>}` | proportional-width flow links; `nodeWidth`, `linkOpacity` |
75
75
  | `chord` | `chordDiagram(data, opts)` | same as `sankey` | nodes on a circle with curved chords of proportional thickness |
76
76
  | `candlestick` | `candlestick(data, opts)` | `Array<{label, open, high, low, close}>` | `upColor`, `downColor`, `markers` (tooltip annotations) |
77
- | `stackedBar` | `stackedBarChart(data, opts)` | `Array<{label, values:number[]}>` | `colors` (no built-in legend — a known gap) |
77
+ | `stackedBar` | `stackedBarChart(data, opts)` | multi-series `Array<{name, values:Array<{label,value}>}>` or `Array<{label, values:number[]}>` | `colors`, `legend`, `seriesLabels`; draws Y-axis ticks and legend |
78
78
  | `pipe` | `pipeChart(data, opts)` | `Array<{label, value, metric?}>` or multi-series | `metric` tapers line width; `legend` |
79
79
  | `radar` | `radarChart(data, opts)` | `Array<{axis, value}>` or multi-series | `colors`, `legend` |
80
80
  | `funnel` | `funnelChart(data, opts)` | `Array<{label, value}>` (largest first) | `colors` |
@@ -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
@@ -3282,6 +3320,35 @@ const mine = ICONS.with({ // a host's own icons over
3282
3320
  the workspace bundle), until a `foreignObject`, whose children are HTML again. A spec's `ns` sets the namespace
3283
3321
  explicitly.
3284
3322
 
3323
+ ## Ask Bar & Next-Word Typeahead (`AskBar` / `AskView`)
3324
+
3325
+ `AskBar` (`src/charting/ask/AskBar.js`) and its DOM-free state model `AskView` (`src/charting/ask/AskView.js`) provide the interactive question bar and scrollable next-word prediction dropdown (as used in `semanticengine/examples/browser/index.html` and the **Cubing** tab in `test/charts.html`).
3326
+
3327
+ ```javascript
3328
+ import { RenderEngine, AskBar, AskView } from '@peter.prib/renderengine';
3329
+
3330
+ const bar = RenderEngine.askBar(document.getElementById('ask-bar'), {
3331
+ value: 'top 3 customers by revenue',
3332
+ // Either pass a custom suggestion callback (e.g. semanticengine's engine.suggest):
3333
+ suggest: (text) => semanticEngine.suggest(text, { sampler: (dim) => semanticEngine.members(dim) }),
3334
+ // Or pass declarative cube/table metadata for built-in next-word prediction:
3335
+ // measures: ['Revenue', 'Cost'],
3336
+ // dimensions: ['Region', 'Quarter'],
3337
+ // members: (dim) => [...],
3338
+ sampleQuestions: ['revenue by region', 'top 3 customers by revenue'],
3339
+ visibleCount: 5, // items visible at once in the scrollable list
3340
+ predictChoices: [0, 3, 5, 8, 10], // 0 = Off; user can switch in the bar's selector
3341
+ onAsk: (question) => runQuery(question),
3342
+ });
3343
+ ```
3344
+
3345
+ - **Configurable scrollable next-word prediction:** `visibleCount` (default `5`) sets how many predicted items show at once before scrolling (`max-height` on `.re-ask-suggest-box` with `overflow-y: auto`), and `predictChoices` (default `[0, 3, 5, 8, 10]`) populates the **Predict next word** selector (`Off`, `3 visible (scrollable)`, `5 visible (scrollable)`, …). Set `showPredictControl: false` to hide the selector.
3346
+ - **Semantic badges:** each suggestion item (`{ display, completion, sense?, reference?, relation?, kind? }`) renders its `display` token, optional `sense → reference` subtitle, lexical `relation` badge (`synonym`, `hypernym`, `hyponym`, `antonym`), and `kind` badge (`metric`, `dimension`, `keyword`, `value`).
3347
+ - **Keyboard navigation:** nothing in the list is selected until you move into it, so `Enter` asks the question as typed. `↑` / `↓` move through the scrollable list, starting at the last or first item and scrolling the active item into view. `Enter` completes the selected item; `Tab` completes the selected item, or the first. Either one immediately predicts the following word. `Escape` closes the popup.
3348
+ - **Async sources and answers:** `suggest` can return a promise, for example from an engine that is still loading or a vocabulary on a server. The list keeps showing what it had until the promise settles. An answer for text the user has since changed is dropped, and a rejected promise shows no suggestions. `onAsk` can also return a promise of `{ rows, columns, complete }`, which `resultEngine` shows once it settles.
3349
+ - **For a host:** `bar.setSuggest(fn)` swaps where suggestions come from, for example when the user picks another model or ontology. `bar.setValue(text)` sets the question without suggesting. `bar.focus()` puts the cursor in the question, and `bar.destroy()` removes the bar and cancels any pending close.
3350
+ - **Accessible:** the input is a WAI-ARIA combobox over a `listbox` of `option`s. `aria-expanded` follows the popup and `aria-activedescendant` follows the active item. Each bar's list has its own id, so several bars can share a page.
3351
+
3285
3352
  ## Where to go next
3286
3353
 
3287
3354
  - **`ARCHITECTURE.md`** — module layout, the chart-type catalog mechanics, how to add a new chart type.
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.13.0",
4
- "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
3
+ "version": "0.15.0",
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,11 +28,12 @@
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",
30
35
  "datamodel": "node scripts/open.mjs examples/data-model.html",
31
- "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
36
+ "test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/table/*.js ./test/charting/pivot/*.js ./test/charting/ask/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
32
37
  "build": "rollup -c",
33
38
  "prepublishOnly": "npm test && npm run build",
34
39
  "screenshots": "node scripts/screenshots/screenshots.mjs"
@@ -38,6 +43,11 @@
38
43
  "archi",
39
44
  "diagram",
40
45
  "diagram-editor",
46
+ "data-modelling",
47
+ "bian",
48
+ "olap",
49
+ "pivot",
50
+ "cube",
41
51
  "modelling",
42
52
  "charts",
43
53
  "svg",
@@ -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
  }