@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
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Annotations — shared reference lines, threshold bands, and regression trendlines
3
+ * across 2D cartesian charts (Line, Area, Bar, Scatter, Bubble).
4
+ */
5
+ export class Annotations {
6
+ /**
7
+ * Compute least-squares linear regression: y = slope * x + intercept.
8
+ * @param {Array<{x: number, y: number}>} points
9
+ * @returns {{slope: number, intercept: number, r2: number} | null}
10
+ */
11
+ static linearRegression(points) {
12
+ const n = points.length;
13
+ if (n < 2) return null;
14
+
15
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0, sumYY = 0;
16
+ for (let i = 0; i < n; i++) {
17
+ const { x, y } = points[i];
18
+ sumX += x;
19
+ sumY += y;
20
+ sumXY += x * y;
21
+ sumXX += x * x;
22
+ sumYY += y * y;
23
+ }
24
+
25
+ const denom = (n * sumXX - sumX * sumX);
26
+ if (denom === 0) return null;
27
+
28
+ const slope = (n * sumXY - sumX * sumY) / denom;
29
+ const intercept = (sumY - slope * sumX) / n;
30
+
31
+ // Coefficient of determination (R^2)
32
+ const totalVar = n * sumYY - sumY * sumY;
33
+ const r2 = totalVar !== 0 ? Math.pow((n * sumXY - sumX * sumY), 2) / (denom * totalVar) : 1;
34
+
35
+ return { slope, intercept, r2 };
36
+ }
37
+
38
+ /**
39
+ * Draw threshold bands (e.g. target, warning, critical zones) across the horizontal span of the plot.
40
+ * @param {*} renderer
41
+ * @param {{x: number, y: number, width: number, height: number}} plotBox
42
+ * @param {Array<{from: number, to: number, color?: string, label?: string}>} bands
43
+ * @param {{toY: (v: number) => number}} scales
44
+ */
45
+ static drawThresholdBands(renderer, plotBox, bands, { toY }) {
46
+ if (!Array.isArray(bands) || !bands.length || typeof toY !== 'function') return;
47
+
48
+ bands.forEach(band => {
49
+ const y1 = toY(band.from);
50
+ const y2 = toY(band.to);
51
+ const top = Math.min(y1, y2);
52
+ const bandHeight = Math.abs(y1 - y2);
53
+ const color = band.color ?? 'rgba(46, 204, 113, 0.12)';
54
+
55
+ renderer.rect(plotBox.x, top, plotBox.width, bandHeight, color, null);
56
+
57
+ if (band.label) {
58
+ renderer.text(band.label, plotBox.x + plotBox.width - 6, top + 12, {
59
+ textAnchor: 'end', fontSize: '10px', fill: '#666', fontStyle: 'italic',
60
+ });
61
+ }
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Draw reference lines (horizontal target lines, vertical markers, or diagonal thresholds).
67
+ * @param {*} renderer
68
+ * @param {{x: number, y: number, width: number, height: number}} plotBox
69
+ * @param {Array<object> | object} referenceLines
70
+ * @param {{toX?: (v: number) => number, toY?: (v: number) => number}} scales
71
+ */
72
+ static drawReferenceLines(renderer, plotBox, referenceLines, { toX, toY }) {
73
+ if (!referenceLines) return;
74
+ const lines = Array.isArray(referenceLines) ? referenceLines : [referenceLines];
75
+
76
+ lines.forEach(line => {
77
+ const color = line.color ?? '#e74c3c';
78
+ const width = line.width ?? 1.5;
79
+ const strokeDash = line.dash ?? '4,3';
80
+
81
+ // 1. Horizontal Reference Line: { y: number, label?: string }
82
+ if (line.y != null && typeof toY === 'function') {
83
+ const py = toY(line.y);
84
+ const pathEl = renderer.path(`M${plotBox.x},${py} L${plotBox.x + plotBox.width},${py}`, 'none', {
85
+ color, width, 'stroke-dasharray': strokeDash,
86
+ });
87
+ pathEl?.setAttribute?.('stroke-dasharray', strokeDash.replace(',', ' '));
88
+
89
+ if (line.label) {
90
+ const lx = line.labelPosition === 'start' ? plotBox.x + 6 : plotBox.x + plotBox.width - 6;
91
+ const anchor = line.labelPosition === 'start' ? 'start' : 'end';
92
+ renderer.text(line.label, lx, py - 4, {
93
+ textAnchor: anchor, fontSize: '10px', fontWeight: 'bold', fill: color,
94
+ });
95
+ }
96
+ }
97
+ // 2. Vertical Reference Line: { x: number, label?: string }
98
+ else if (line.x != null && typeof toX === 'function') {
99
+ const px = toX(line.x);
100
+ const pathEl = renderer.path(`M${px},${plotBox.y} L${px},${plotBox.y + plotBox.height}`, 'none', {
101
+ color, width, 'stroke-dasharray': strokeDash,
102
+ });
103
+ pathEl?.setAttribute?.('stroke-dasharray', strokeDash.replace(',', ' '));
104
+
105
+ if (line.label) {
106
+ renderer.text(line.label, px + 4, plotBox.y + 12, {
107
+ textAnchor: 'start', fontSize: '10px', fontWeight: 'bold', fill: color,
108
+ });
109
+ }
110
+ }
111
+ // 3. Diagonal / From-To Reference Line: { from: {x,y}, to: {x,y} }
112
+ else if (line.from && line.to && typeof toX === 'function' && typeof toY === 'function') {
113
+ const x1 = toX(line.from.x), y1 = toY(line.from.y);
114
+ const x2 = toX(line.to.x), y2 = toY(line.to.y);
115
+ const pathEl = renderer.path(`M${x1},${y1} L${x2},${y2}`, 'none', {
116
+ color, width, 'stroke-dasharray': strokeDash,
117
+ });
118
+ pathEl?.setAttribute?.('stroke-dasharray', strokeDash.replace(',', ' '));
119
+
120
+ if (line.label) {
121
+ renderer.text(line.label, x2 - 4, y2 - 4, {
122
+ textAnchor: 'end', fontSize: '10px', fill: color,
123
+ });
124
+ }
125
+ }
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Draw linear regression or moving-average trendline.
131
+ * @param {*} renderer
132
+ * @param {{x: number, y: number, width: number, height: number}} plotBox
133
+ * @param {Array<{x: number, y: number}>} points
134
+ * @param {string | object} trendlineOpt e.g. 'linear', or { type: 'linear', color, label }
135
+ * @param {{toX: (v: number) => number, toY: (v: number) => number, xMin?: number, xMax?: number}} scales
136
+ */
137
+ static drawTrendline(renderer, plotBox, points, trendlineOpt, { toX, toY, xMin, xMax }) {
138
+ if (!trendlineOpt || !Array.isArray(points) || points.length < 2) return;
139
+ const opt = typeof trendlineOpt === 'object' ? trendlineOpt : { type: trendlineOpt };
140
+ const color = opt.color ?? '#2980b9';
141
+ const width = opt.width ?? 2;
142
+ const dash = opt.dash ?? '3,3';
143
+
144
+ if (opt.type === 'linear' || trendlineOpt === true || trendlineOpt === 'linear') {
145
+ const reg = Annotations.linearRegression(points);
146
+ if (!reg) return;
147
+
148
+ const minX = xMin ?? Math.min(...points.map(p => p.x));
149
+ const maxX = xMax ?? Math.max(...points.map(p => p.x));
150
+ const yStart = reg.slope * minX + reg.intercept;
151
+ const yEnd = reg.slope * maxX + reg.intercept;
152
+
153
+ const sx1 = toX(minX), sy1 = toY(yStart);
154
+ const sx2 = toX(maxX), sy2 = toY(yEnd);
155
+
156
+ const pathEl = renderer.path(`M${sx1},${sy1} L${sx2},${sy2}`, 'none', {
157
+ color, width, 'stroke-dasharray': dash,
158
+ });
159
+ pathEl?.setAttribute?.('stroke-dasharray', dash.replace(',', ' '));
160
+
161
+ if (opt.showEquation || opt.label) {
162
+ const label = opt.label ?? `y = ${reg.slope.toFixed(2)}x ${reg.slope >= 0 ? '+' : '-'} ${Math.abs(reg.intercept).toFixed(1)} (R²=${reg.r2.toFixed(2)})`;
163
+ renderer.text(label, sx2 - 6, sy2 - 6, {
164
+ textAnchor: 'end', fontSize: '10px', fill: color, fontStyle: 'italic',
165
+ });
166
+ }
167
+ }
168
+ }
169
+ }
@@ -339,6 +339,16 @@ export class ArchimateDiagramEditor extends DiagramEditor {
339
339
  if (view) this._showView(view);
340
340
  }
341
341
 
342
+ /** Open a view by id or view instance, switching active tab and rendering. */
343
+ openView(viewId) {
344
+ const view = typeof viewId === 'string' ? (this.model.findView?.(viewId) || this.model.getView?.(viewId)) : viewId;
345
+ if (!view) return;
346
+ this.currentView = view;
347
+ this.diagrams?.switchTo?.(view);
348
+ this._showView(view);
349
+ this.render();
350
+ }
351
+
342
352
  /** Open (or focus) a view's tab and select it in the tree, which makes it current. @private */
343
353
  _showView(view) {
344
354
  this.diagrams.show(view, () => this.tree.select(view.id, 'view'));
@@ -396,6 +406,12 @@ export class ArchimateDiagramEditor extends DiagramEditor {
396
406
  ${diagram} svg [nodeId]:not(.filter-highlight) { opacity: 0.3; }
397
407
  /* ModelTree's own filter button: this editor has its own above the tree. */
398
408
  ${tree} > div > div:first-child button { display: none !important; }
409
+ @media print {
410
+ body * { visibility: hidden !important; }
411
+ ${diagram}, ${diagram} * { visibility: visible !important; }
412
+ ${diagram} { position: absolute !important; left: 0 !important; top: 0 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
413
+ ${tree}, .diagram-toolbar, .menubar, .tabs-container { display: none !important; }
414
+ }
399
415
  `;
400
416
  this.document.head.appendChild(style);
401
417
  }
@@ -438,7 +454,8 @@ export class ArchimateDiagramEditor extends DiagramEditor {
438
454
  const file = modelFileMenuItems(this.fileFormats, {
439
455
  model: () => this.model,
440
456
  onImport: (model) => this.replaceModel(model),
441
- enabled: (format, direction) => !!f[direction + format.featureName],
457
+ onMerge: (model) => this.mergeModel(model),
458
+ enabled: (format, direction) => direction === 'merge' ? (f.merge !== false && !!f['import' + format.featureName]) : !!f[direction + format.featureName],
442
459
  document: () => this.document,
443
460
  });
444
461
  // The current view as a picture (on unless features.exportImage is false).
@@ -757,6 +774,16 @@ export class ArchimateDiagramEditor extends DiagramEditor {
757
774
  * @param {RenderEngine} [engine]
758
775
  */
759
776
  editItemProperties(item, kind, engine = this.currentEngine) {
777
+ if (kind === 'node') {
778
+ const targetViewId = item.referencedModel || item.reference || item.viewRef || item.targetViewId || item.refViewId || item.viewId;
779
+ if (targetViewId) {
780
+ const targetView = this.model?.findView?.(targetViewId) || this.model?.getView?.(targetViewId) || (this.model?.views ? this.model.views.find(v => v.id === targetViewId || v.name === targetViewId) : null);
781
+ if (targetView) {
782
+ this.openView(targetView);
783
+ return;
784
+ }
785
+ }
786
+ }
760
787
  if (kind === 'node' && this.isFixed(item.id)) return this.tellLocked(`"${item.name || 'This'}"`, item.id);
761
788
  // The dialog calls an element's documentation its description.
762
789
  if (kind === 'node' && item.description === undefined && item.documentation !== undefined) item.description = item.documentation;
@@ -987,18 +1014,10 @@ export class ArchimateDiagramEditor extends DiagramEditor {
987
1014
  render(options = {}) {
988
1015
  if (!this.currentEngine || !this.currentView) return;
989
1016
 
990
- console.log(`[render] START - currentView id: ${this.currentView.id}, instances count: ${this.currentView.elementInstances.size}`);
991
-
992
1017
  // Get elements and relationships to render
993
1018
  let elementsToRender = this.currentView?.elements;
994
1019
  let relationshipsToRender = this.currentView?.relationships || [];
995
1020
 
996
- // Log what we're about to render
997
- const zIndices = Array.from(this.currentView.elementInstances.values()).map(i => `${i.id}:${i.zIndex}`).join(', ');
998
- console.log(`[render] instances zIndex: ${zIndices}`);
999
- const renderZIndexes = elementsToRender?.map(e => `${e.id}:${e.zIndex}`).join(', ');
1000
- console.log(`[render] elementsToRender zIndex: ${renderZIndexes}`);
1001
-
1002
1021
  if (!Array.isArray(elementsToRender)) {
1003
1022
  console.warn('render: currentView.elements is not an array', {
1004
1023
  viewName: this.currentView?.name,
@@ -1083,6 +1102,17 @@ export class ArchimateDiagramEditor extends DiagramEditor {
1083
1102
  onEdgeAdd: (edge) => this._relationshipDrawn(edge),
1084
1103
  onEdgeRemove: (edge) => this._relationshipUndrawn(edge),
1085
1104
  contextMenuItems: [
1105
+ {
1106
+ label: '📂 Open Referenced View',
1107
+ visible: (node) => !!(node.referencedModel || node.reference || node.viewRef || node.targetViewId || node.refViewId),
1108
+ action: (node) => {
1109
+ const targetViewId = node.referencedModel || node.reference || node.viewRef || node.targetViewId || node.refViewId;
1110
+ if (targetViewId) {
1111
+ const targetView = this.model?.findView?.(targetViewId) || this.model?.getView?.(targetViewId) || (this.model?.views ? this.model.views.find(v => v.id === targetViewId || v.name === targetViewId) : null);
1112
+ if (targetView) this.openView(targetView);
1113
+ }
1114
+ },
1115
+ },
1086
1116
  { label: 'Show/Edit Properties…', action: (node, engine) => this.editItemProperties(node, 'node', engine) },
1087
1117
  { label: 'Add Missing Relationships…', action: (node, engine) => {
1088
1118
  const instance = this.currentView?.getElementInstance(node.id);
@@ -1162,7 +1192,6 @@ export class ArchimateDiagramEditor extends DiagramEditor {
1162
1192
  // A canvas shows its hints.
1163
1193
  this.showHintFor();
1164
1194
 
1165
- console.log(`[render] END - render complete`);
1166
1195
  this._notifyRendered();
1167
1196
  }
1168
1197
 
@@ -1518,6 +1547,21 @@ export class ArchimateDiagramEditor extends DiagramEditor {
1518
1547
  if (this.currentView) this._showView(this.currentView);
1519
1548
  }
1520
1549
 
1550
+ /**
1551
+ * Merge an imported model into the current open model.
1552
+ * @param {ArchimateModel|object} model - The model to merge in
1553
+ * @returns {object} merge result details
1554
+ */
1555
+ mergeModel(model) {
1556
+ if (!model) return null;
1557
+ const result = this.model.merge(model);
1558
+ this.filterSystem.model = this.model;
1559
+ this._renderTreePanel();
1560
+ if (this.currentView) this._showView(this.currentView);
1561
+ this.render();
1562
+ return result;
1563
+ }
1564
+
1521
1565
  /**
1522
1566
  * Drop or paste image files onto the diagram; each is handed to
1523
1567
  * `deps.onImageDrop(engine, point, dataURL)`.
@@ -51,6 +51,96 @@ export class Axis {
51
51
  return ticks;
52
52
  }
53
53
 
54
+ /**
55
+ * Compute ticks along a base-10 logarithmic scale across [minVal, maxVal].
56
+ * @param {number} minVal
57
+ * @param {number} maxVal
58
+ * @returns {number[]}
59
+ */
60
+ static logTicks(minVal, maxVal) {
61
+ const min = Math.max(1e-9, minVal || 1);
62
+ const max = Math.max(min * 10, maxVal || 10);
63
+ const startDecade = Math.floor(Math.log10(min));
64
+ const endDecade = Math.ceil(Math.log10(max));
65
+ const decades = endDecade - startDecade;
66
+ const ticks = [];
67
+
68
+ // If span is 3 decades or fewer, include 1, 2, 5 multipliers
69
+ const multipliers = decades <= 3 ? [1, 2, 5] : [1];
70
+ for (let d = startDecade; d <= endDecade; d++) {
71
+ const base = Math.pow(10, d);
72
+ for (const m of multipliers) {
73
+ const val = parseFloat((m * base).toFixed(10));
74
+ if (val >= min * 0.999 && val <= max * 1.001) {
75
+ ticks.push(val);
76
+ }
77
+ }
78
+ }
79
+ return ticks.length ? ticks : [min, max];
80
+ }
81
+
82
+ /**
83
+ * Compute ticks for square-root proportional scale.
84
+ * @param {number} minVal
85
+ * @param {number} maxVal
86
+ * @param {number} [count=4]
87
+ * @returns {number[]}
88
+ */
89
+ static sqrtTicks(minVal, maxVal, count = 4) {
90
+ const lo = Math.max(0, minVal || 0);
91
+ const hi = Math.max(lo + 1, maxVal || 1);
92
+ const sqrtTicks = Axis.niceTicks(Math.sqrt(hi), count);
93
+ return sqrtTicks.map(t => Math.round(t * t));
94
+ }
95
+
96
+ /**
97
+ * Compute evenly-spaced time interval ticks across [minTime, maxTime] (in ms).
98
+ * @param {number | Date} minTime
99
+ * @param {number | Date} maxTime
100
+ * @param {number} [count=5]
101
+ * @returns {Array<{time: number, label: string}>}
102
+ */
103
+ static timeTicks(minTime, maxTime, count = 5) {
104
+ const t0 = typeof minTime === 'number' ? minTime : new Date(minTime).getTime();
105
+ const t1 = typeof maxTime === 'number' ? maxTime : new Date(maxTime).getTime();
106
+ const span = Math.max(1000, t1 - t0);
107
+
108
+ // Standard time steps in milliseconds
109
+ const SECOND = 1000;
110
+ const MINUTE = 60 * SECOND;
111
+ const HOUR = 60 * MINUTE;
112
+ const DAY = 24 * HOUR;
113
+ const MONTH = 30 * DAY;
114
+ const YEAR = 365 * DAY;
115
+
116
+ const STEPS = [
117
+ 1 * SECOND, 5 * SECOND, 15 * SECOND, 30 * SECOND,
118
+ 1 * MINUTE, 5 * MINUTE, 15 * MINUTE, 30 * MINUTE,
119
+ 1 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR,
120
+ 1 * DAY, 2 * DAY, 7 * DAY,
121
+ 15 * DAY, 1 * MONTH, 3 * MONTH, 6 * MONTH,
122
+ 1 * YEAR, 2 * YEAR, 5 * YEAR,
123
+ ];
124
+
125
+ const targetStep = span / count;
126
+ const step = STEPS.find(s => s >= targetStep) ?? STEPS[STEPS.length - 1];
127
+
128
+ const first = Math.ceil(t0 / step) * step;
129
+ const ticks = [];
130
+ for (let t = first; t <= t1; t += step) {
131
+ const d = new Date(t);
132
+ let label;
133
+ if (step >= YEAR) label = String(d.getFullYear());
134
+ else if (step >= MONTH) label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
135
+ else if (step >= DAY) label = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
136
+ else if (step >= MINUTE) label = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
137
+ else label = `${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
138
+
139
+ ticks.push({ time: t, label });
140
+ }
141
+ return ticks;
142
+ }
143
+
54
144
  /**
55
145
  * How often a category label fits along an axis `width` wide: 1 (every
56
146
  * label), 2 (every other), … — so a long series (a minute of samples, a
@@ -77,6 +167,13 @@ export class Axis {
77
167
  * @returns {string}
78
168
  */
79
169
  static formatValue(v) {
170
+ if (v == null || Number.isNaN(v)) return '';
171
+ if (typeof v === 'string') {
172
+ const num = Number(v);
173
+ if (isNaN(num) || v.trim() === '') return v;
174
+ v = num;
175
+ }
176
+ if (typeof v !== 'number' || !Number.isFinite(v)) return String(v ?? '');
80
177
  if (v === 0) return '0';
81
178
  if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(2) + 'M';
82
179
  if (Math.abs(v) >= 1e3) return (v / 1e3).toFixed(2) + 'k';
@@ -108,11 +205,31 @@ export class Axis {
108
205
  * @param {number} rawMax the raw data maximum
109
206
  * @returns {{min:number, max:number}} the nice domain the ticks actually span
110
207
  */
111
- drawYRange(renderer, box, rawMin, rawMax) {
112
- const ticks = Axis.niceRange(rawMin, rawMax);
113
- const min = ticks[0];
114
- const max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
115
- const yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
208
+ drawYRange(renderer, box, rawMin, rawMax, options = {}) {
209
+ const scale = options.scale ?? 'linear';
210
+ let ticks, min, max, yOf;
211
+
212
+ if (scale === 'log') {
213
+ min = Math.max(1e-9, rawMin || 1);
214
+ max = Math.max(min * 10, rawMax || 10);
215
+ ticks = Axis.logTicks(min, max);
216
+ const logMin = Math.log10(min);
217
+ const logMax = Math.log10(max);
218
+ yOf = (v) => box.y + box.height - ((Math.log10(Math.max(1e-9, v)) - logMin) / (logMax - logMin)) * box.height;
219
+ } else if (scale === 'sqrt') {
220
+ min = Math.max(0, rawMin || 0);
221
+ max = Math.max(min + 1, rawMax || 1);
222
+ ticks = Axis.sqrtTicks(min, max);
223
+ const sqrtMin = Math.sqrt(min);
224
+ const sqrtMax = Math.sqrt(max);
225
+ yOf = (v) => box.y + box.height - ((Math.sqrt(Math.max(0, v)) - sqrtMin) / (sqrtMax - sqrtMin)) * box.height;
226
+ } else {
227
+ ticks = Axis.niceRange(rawMin, rawMax);
228
+ min = ticks[0];
229
+ max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
230
+ yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
231
+ }
232
+
116
233
  for (const t of ticks) {
117
234
  const ty = yOf(t);
118
235
  renderer.path(`M${box.x - 5},${ty} L${box.x},${ty}`, 'none', { color: '#999', width: 1 });
@@ -125,6 +242,101 @@ export class Axis {
125
242
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
126
243
  });
127
244
  }
245
+ const result = { min, max };
246
+ Object.defineProperty(result, 'yOf', { value: yOf, writable: true, configurable: true, enumerable: false });
247
+ return result;
248
+ }
249
+
250
+ /**
251
+ * Draw an x-axis over a range that may go below zero (niceRange): ticks, labels, vertical gridlines,
252
+ * and a zero line when range goes below zero.
253
+ * @param {*} renderer a renderer implementing .path() and .text()
254
+ * @param {{x:number, y:number, width:number, height:number}} box plot-area box
255
+ * @param {number} rawMin the raw data minimum
256
+ * @param {number} rawMax the raw data maximum
257
+ * @param {object} [options]
258
+ * @returns {{min:number, max:number, xOf?: Function}} the nice domain the ticks actually span
259
+ */
260
+ drawXRange(renderer, box, rawMin, rawMax, options = {}) {
261
+ const scale = options.scale ?? 'linear';
262
+ let ticks, min, max, xOf;
263
+
264
+ if (scale === 'time') {
265
+ const timeItems = Axis.timeTicks(rawMin, rawMax);
266
+ min = rawMin;
267
+ max = rawMax;
268
+ xOf = (v) => box.x + ((v - min) / (max - min)) * box.width;
269
+ for (const item of timeItems) {
270
+ const tx = xOf(item.time);
271
+ renderer.path(`M${tx},${box.y + box.height} L${tx},${box.y + box.height + 5}`, 'none', { color: '#999', width: 1 });
272
+ renderer.path(`M${tx},${box.y} L${tx},${box.y + box.height}`, 'none', { color: '#eee', width: 1 });
273
+ renderer.text(item.label, tx, box.y + box.height + 16, {
274
+ textAnchor: renderer.centerTextAnchor, verticalAlign: 'top', fontSize: '10px', fill: '#666',
275
+ });
276
+ }
277
+ const result = { min, max };
278
+ Object.defineProperty(result, 'xOf', { value: xOf, writable: true, configurable: true, enumerable: false });
279
+ return result;
280
+ } else if (scale === 'log') {
281
+ min = Math.max(1e-9, rawMin || 1);
282
+ max = Math.max(min * 10, rawMax || 10);
283
+ ticks = Axis.logTicks(min, max);
284
+ const logMin = Math.log10(min);
285
+ const logMax = Math.log10(max);
286
+ xOf = (v) => box.x + ((Math.log10(Math.max(1e-9, v)) - logMin) / (logMax - logMin)) * box.width;
287
+ } else if (scale === 'sqrt') {
288
+ min = Math.max(0, rawMin || 0);
289
+ max = Math.max(min + 1, rawMax || 1);
290
+ ticks = Axis.sqrtTicks(min, max);
291
+ const sqrtMin = Math.sqrt(min);
292
+ const sqrtMax = Math.sqrt(max);
293
+ xOf = (v) => box.x + ((Math.sqrt(Math.max(0, v)) - sqrtMin) / (sqrtMax - sqrtMin)) * box.width;
294
+ } else {
295
+ ticks = Axis.niceRange(rawMin, rawMax);
296
+ min = ticks[0];
297
+ max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
298
+ xOf = (v) => box.x + ((v - min) / (max - min)) * box.width;
299
+ }
300
+
301
+ for (const t of ticks) {
302
+ const tx = xOf(t);
303
+ renderer.path(`M${tx},${box.y + box.height} L${tx},${box.y + box.height + 5}`, 'none', { color: '#999', width: 1 });
304
+ if (t !== 0) {
305
+ renderer.path(`M${tx},${box.y} L${tx},${box.y + box.height}`, 'none', { color: '#eee', width: 1 });
306
+ } else if (min < 0) {
307
+ renderer.path(`M${tx},${box.y} L${tx},${box.y + box.height}`, 'none', { color: '#666', width: 1, zeroLine: true });
308
+ }
309
+ renderer.text(Axis.formatValue(t), tx, box.y + box.height + 16, {
310
+ textAnchor: renderer.centerTextAnchor, verticalAlign: 'top', fontSize: '10px', fill: '#666',
311
+ });
312
+ }
313
+ const result = { min, max };
314
+ Object.defineProperty(result, 'xOf', { value: xOf, writable: true, configurable: true, enumerable: false });
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * Draw a secondary (right-hand) y-axis: ticks, labels, and tick marks on the right side of the plot.
320
+ * @param {*} renderer
321
+ * @param {{x:number, y:number, width:number, height:number}} box
322
+ * @param {number} rawMin
323
+ * @param {number} rawMax
324
+ * @returns {{min:number, max:number}}
325
+ */
326
+ drawSecondaryYRange(renderer, box, rawMin, rawMax) {
327
+ const ticks = Axis.niceRange(rawMin, rawMax);
328
+ const min = ticks[0];
329
+ const max = ticks.length > 1 ? ticks[ticks.length - 1] : 1;
330
+ const yOf = (v) => box.y + box.height - ((v - min) / (max - min)) * box.height;
331
+ const rightX = box.x + box.width;
332
+ renderer.path(`M${rightX},${box.y} L${rightX},${box.y + box.height}`, 'none', { color: '#999', width: 1 });
333
+ for (const t of ticks) {
334
+ const ty = yOf(t);
335
+ renderer.path(`M${rightX},${ty} L${rightX + 5},${ty}`, 'none', { color: '#999', width: 1 });
336
+ renderer.text(Axis.formatValue(t), rightX + 8, ty, {
337
+ textAnchor: 'start', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
338
+ });
339
+ }
128
340
  return { min, max };
129
341
  }
130
342
  }
@@ -1,3 +1,5 @@
1
+ import { DataFilter } from './DataFilter.js';
2
+
1
3
  /**
2
4
  * Chart — abstract base class for one chart type.
3
5
  *
@@ -22,6 +24,26 @@ export class Chart {
22
24
  this.options = options;
23
25
  /** The marks drawn for values, `{ shape, datum }`, as the last render() drew them. */
24
26
  this.datums = [];
27
+ this._dataFilter = options.dataFilter ?? new DataFilter();
28
+ }
29
+
30
+ /**
31
+ * Default click handler for interactive legend filtering.
32
+ * When a legend item is clicked, toggles its visibility in _dataFilter and re-renders.
33
+ */
34
+ onClick(event, hitShape) {
35
+ if (!hitShape) return;
36
+ if (hitShape.legendItem && hitShape.dataIndex !== undefined) {
37
+ this._dataFilter.toggle(hitShape.dataIndex);
38
+ this.render();
39
+ return;
40
+ }
41
+ if (typeof this.options?.onDatumClick === 'function') {
42
+ const entry = this.datums.find(d => d.shape === hitShape);
43
+ if (entry) {
44
+ this.options.onDatumClick(entry.datum, event);
45
+ }
46
+ }
25
47
  }
26
48
 
27
49
  /** Render the chart. Subclasses implement `_render()`. */
@@ -39,24 +61,92 @@ export class Chart {
39
61
  return shape;
40
62
  }
41
63
 
42
- /** Gives each recorded mark the host's onDatumClick, where the renderer's shapes take clicks. */
64
+ /** Gives each recorded mark the host's onDatumClick and default hover elevation on SVG. */
43
65
  _wireDatums() {
66
+ if (this.renderer?.supportsSceneGraph !== true) return;
44
67
  const handler = this.options?.onDatumClick;
45
- if (typeof handler !== 'function' || this.renderer.supportsSceneGraph !== true) return;
68
+ const hoverEffect = this.options?.hoverEffect !== false;
46
69
  for (const { shape, datum } of this.datums) {
47
70
  if (typeof shape?.addEventListener !== 'function') continue;
48
- shape.addEventListener('click', (event) => { event.stopPropagation?.(); handler(datum, event); });
49
- if (shape.style) shape.style.cursor = 'pointer';
71
+ if (typeof handler === 'function') {
72
+ shape.addEventListener('click', (event) => { event.stopPropagation?.(); handler(datum, event); });
73
+ if (shape.style) shape.style.cursor = 'pointer';
74
+ }
75
+ if (hoverEffect && !shape._hoverWired && shape.style) {
76
+ shape._hoverWired = true;
77
+ shape.style.transition = 'transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), filter 0.2s ease, opacity 0.2s ease';
78
+ shape.addEventListener('mouseenter', () => {
79
+ shape.style.filter = 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.22)) brightness(1.06)';
80
+ });
81
+ shape.addEventListener('mouseleave', () => {
82
+ shape.style.filter = '';
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Wire a coordinated hover translation and drop-shadow across one or more SVG elements
90
+ * (e.g. all faces of a 3D bar or a radial wedge + its label).
91
+ */
92
+ _wireHoverTransform(elements, {
93
+ dx = 0,
94
+ dy = -5,
95
+ shadow = 'drop-shadow(0 5px 8px rgba(0, 0, 0, 0.22))',
96
+ } = {}) {
97
+ if (this.renderer?.supportsSceneGraph !== true) return;
98
+ if (this.options?.hoverEffect === false) return;
99
+ const validEls = (elements || []).filter(el => el && typeof el.addEventListener === 'function');
100
+ if (!validEls.length) return;
101
+
102
+ const tx = Number(dx).toFixed(2);
103
+ const ty = Number(dy).toFixed(2);
104
+ for (const el of validEls) {
105
+ el._hoverWired = true;
106
+ if (el.style) {
107
+ el.style.transition = 'transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), filter 0.22s ease';
108
+ if (!el.style.cursor) el.style.cursor = 'pointer';
109
+ }
50
110
  }
111
+ const onEnter = () => {
112
+ for (const el of validEls) {
113
+ if (el.style) {
114
+ el.style.transform = `translate(${tx}px, ${ty}px)`;
115
+ if (shadow) el.style.filter = shadow;
116
+ }
117
+ }
118
+ };
119
+ const onLeave = () => {
120
+ for (const el of validEls) {
121
+ if (el.style) {
122
+ el.style.transform = 'translate(0px, 0px)';
123
+ el.style.filter = '';
124
+ }
125
+ }
126
+ };
127
+ for (const el of validEls) {
128
+ el.addEventListener('mouseenter', onEnter);
129
+ el.addEventListener('mouseleave', onLeave);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Wire smooth radial pull-out along `midAngle` on hover (used by Pie, Donut, 3D Pie/Donut,
135
+ * RadialBar, Sunburst, PolarArea, and Chord outer arcs).
136
+ */
137
+ _wireRadialHover(elements, midAngle, {
138
+ offset = 12,
139
+ ryRatio = 1,
140
+ shadow = 'drop-shadow(0 5px 8px rgba(0, 0, 0, 0.22))',
141
+ } = {}) {
142
+ if (!offset || offset <= 0) return;
143
+ const dx = Math.cos(midAngle) * offset;
144
+ const dy = Math.sin(midAngle) * offset * ryRatio;
145
+ this._wireHoverTransform(elements, { dx, dy, shadow });
51
146
  }
52
147
 
53
148
  /** Subclasses implement the actual drawing here. */
54
149
  _render() {
55
150
  throw new Error(`${this.constructor.name} must implement _render()`);
56
151
  }
57
-
58
- /** Optional click handler; subclasses override to respond to hits. */
59
- onClick(event, hitShape) {
60
- // no-op by default
61
- }
62
152
  }