@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
@@ -4,6 +4,7 @@ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
4
  import { Legend } from '../Legend.js';
5
5
  import { ColorPalette } from '../ColorPalette.js';
6
6
  import { ErrorBars } from '../ErrorBars.js';
7
+ import { Annotations } from '../Annotations.js';
7
8
 
8
9
  /**
9
10
  * LineChart — single or multi-series line chart with optional 3D ribbon
@@ -25,27 +26,59 @@ export class LineChart extends Chart {
25
26
  // Check if data is multiline (array of series) or single line
26
27
  const isMultiline = Array.isArray(data[0]?.values);
27
28
  const series = isMultiline ? data : [{ name: 'default', values: data }];
28
- const legendOptions = { items: series.map(s => ({ name: s.name })), itemHeight: 20, markerSize: 12 };
29
+ const lineColors = colors || ColorPalette.DEFAULT;
30
+ const palette = new ColorPalette(lineColors);
31
+
32
+ const legendOptions = {
33
+ items: series.map((s, idx) => ({
34
+ label: s.name,
35
+ color: palette.colorAt(idx),
36
+ hidden: this._dataFilter.has(idx),
37
+ dataIndex: idx,
38
+ })),
39
+ itemHeight: 20,
40
+ markerSize: 12,
41
+ };
29
42
  // With a legend, the plot leaves room for it beside, so it does not cover the last points.
30
43
  const width = options.width ?? (isMultiline && showLegend
31
44
  ? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, legendOptions) : box.width);
32
45
  const plotBox = { x, y, width, height };
33
46
 
34
- // Determine max value across all series
35
- const maxValue = Math.max(...series.flatMap(s =>
36
- isMultiline ? s.values.map(d => d.value) : s.values.map(d => d.value)
37
- )) || 1;
47
+ // Determine max values per axis (supports secondary right Y-axis and active series)
48
+ const activeSeries = series.filter((_, idx) => !this._dataFilter.has(idx));
49
+ const workingSeries = activeSeries.length ? activeSeries : series;
50
+ const leftSeries = workingSeries.filter(s => s.yAxis !== 'right');
51
+ const rightSeries = workingSeries.filter(s => s.yAxis === 'right');
52
+ const hasSecondaryY = rightSeries.length > 0;
53
+
54
+ const maxLeftValue = Math.max(...leftSeries.flatMap(s => s.values.map(d => d.value)), 1);
55
+ const maxRightValue = hasSecondaryY ? Math.max(...rightSeries.flatMap(s => s.values.map(d => d.value)), 1) : 1;
38
56
 
39
57
  const pointMargin = (isMultiline ? series[0].values.length : data.length) > 1
40
58
  ? width / ((isMultiline ? series[0].values.length : data.length) - 1)
41
59
  : width;
42
60
 
43
61
  const depth = zAxis ? zDepth : 0;
44
- const lineColors = colors || ColorPalette.DEFAULT;
45
- const palette = new ColorPalette(lineColors);
46
62
 
47
63
  new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
48
64
 
65
+ // Draw Left Y Axis and optional Secondary Right Y Axis
66
+ let leftAxisRes;
67
+ if (!zAxis) {
68
+ leftAxisRes = new Axis().drawYRange(this.renderer, plotBox, 0, maxLeftValue, { scale: options.scale });
69
+ if (hasSecondaryY) {
70
+ new Axis().drawSecondaryYRange(this.renderer, plotBox, 0, maxRightValue);
71
+ }
72
+ }
73
+
74
+ const toY = leftAxisRes?.yOf ?? ((v) => y + height - (v / maxLeftValue) * height);
75
+ const toX = (idx) => x + idx * pointMargin;
76
+
77
+ // Draw threshold bands and reference lines
78
+ Annotations.drawThresholdBands(this.renderer, plotBox, options.thresholdBands, { toY });
79
+ const refLines = options.referenceLines ?? options.referenceLine;
80
+ Annotations.drawReferenceLines(this.renderer, plotBox, refLines, { toY, toX });
81
+
49
82
  // 3D Axes
50
83
  const axisPath = zAxis
51
84
  ? `M${x + depth},${y - depth} L${x},${y} L${x},${y + height} L${x + width},${y + height} L${x + width + depth},${y + height - depth} M${x},${y + height} L${x + depth},${y + height - depth}`
@@ -55,35 +88,55 @@ export class LineChart extends Chart {
55
88
 
56
89
  // Draw each series
57
90
  series.forEach((seriesData, seriesIndex) => {
91
+ if (this._dataFilter.has(seriesIndex)) return;
58
92
  const seriesValues = isMultiline ? seriesData.values : seriesData.values;
59
93
  const seriesColor = palette.colorAt(seriesIndex);
60
94
  const seriesDepth = zAxis ? (depth / series.length) * (series.length - 1 - seriesIndex) : 0;
95
+ const seriesMax = seriesData.yAxis === 'right' ? maxRightValue : maxLeftValue;
96
+ const stepMode = seriesData.step ?? options.step;
61
97
 
62
98
  // Draw line
63
99
  if (zAxis) {
64
100
  // 3D effect: draw a "ribbon"
65
101
  const ribbonPath = seriesValues.map((d, i) => {
66
- const pY = y + height - (d.value / maxValue) * height;
102
+ const pY = toY(d.value);
67
103
  return `L${x + i * pointMargin + seriesDepth},${pY - seriesDepth}`;
68
104
  }).join(' ').replace('L', 'M');
69
105
  this.renderer.path(ribbonPath, palette.lighten(seriesColor, -0.2), null);
70
106
  }
71
107
  let pathData = '';
108
+ let prevX = 0, prevY = 0;
72
109
  seriesValues.forEach((d, i) => {
73
110
  const pointX = x + i * pointMargin + seriesDepth;
74
- const pointY = y + height - (d.value / maxValue) * height - seriesDepth;
111
+ const pointY = toY(d.value) - seriesDepth;
75
112
  if (i === 0) {
76
- pathData += `M${pointX},${pointY}`;
113
+ pathData = `M${pointX},${pointY}`;
114
+ } else if (stepMode === 'before') {
115
+ pathData += ` V${pointY} H${pointX}`;
116
+ } else if (stepMode === 'center') {
117
+ const midX = (prevX + pointX) / 2;
118
+ pathData += ` H${midX} V${pointY} H${pointX}`;
119
+ } else if (stepMode) {
120
+ // 'after' or true
121
+ pathData += ` H${pointX} V${pointY}`;
77
122
  } else {
78
123
  pathData += ` L${pointX},${pointY}`;
79
124
  }
125
+ prevX = pointX;
126
+ prevY = pointY;
80
127
  });
81
128
  this.renderer.path(pathData, 'none', { color: seriesColor, width: 2 });
82
129
 
130
+ // Optional trendline per series or for primary
131
+ if (options.trendline && (seriesIndex === 0 || options.trendline?.series === seriesData.name)) {
132
+ const trendPts = seriesValues.map((d, i) => ({ x: i, y: d.value }));
133
+ Annotations.drawTrendline(this.renderer, plotBox, trendPts, options.trendline, { toX, toY });
134
+ }
135
+
83
136
  // Draw points + optional error bars
84
137
  seriesValues.forEach((d, i) => {
85
138
  const pointX = x + i * pointMargin + seriesDepth;
86
- const pointY = y + height - (d.value / maxValue) * height - seriesDepth;
139
+ const pointY = toY(d.value) - seriesDepth;
87
140
  const textAnchor = this.renderer.centerTextAnchor;
88
141
 
89
142
  this._datum(this.renderer.circle(
@@ -94,20 +147,24 @@ export class LineChart extends Chart {
94
147
  { tooltip: `${isMultiline ? seriesData.name + ': ' : ''}${d.label}: ${d.value}` }
95
148
  ), { label: d.label, series: isMultiline ? seriesData.name : null });
96
149
 
150
+ if (options.showValues) {
151
+ this.renderer.text(Axis.formatValue(d.value), pointX, pointY - 8, {
152
+ textAnchor, fontSize: '10px', fill: '#333',
153
+ });
154
+ }
155
+
97
156
  const errMinus = d.errorMinus != null ? d.errorMinus : d.error != null ? d.error : null;
98
157
  const errPlus = d.errorPlus != null ? d.errorPlus : d.error != null ? d.error : null;
99
158
  if (errMinus != null || errPlus != null) {
100
159
  const yHighVal = d.value + (errPlus ?? 0);
101
160
  const yLowVal = d.value - (errMinus ?? 0);
102
- const yHigh = y + height - (yHighVal / maxValue) * height - seriesDepth;
103
- const yLow = y + height - (yLowVal / maxValue) * height - seriesDepth;
161
+ const yHigh = toY(yHighVal) - seriesDepth;
162
+ const yLow = toY(yLowVal) - seriesDepth;
104
163
  ErrorBars.draw(this.renderer, pointX, pointY, null, null, yLow, yHigh, seriesColor);
105
164
  }
106
165
  });
107
166
 
108
- // Draw labels only for first series to avoid clutter. Use this
109
- // series' own seriesDepth (not the raw max `depth`) for the x-shift
110
- // so labels line up under series 0's actual (depth-shifted) points.
167
+ // Draw labels only for first series to avoid clutter.
111
168
  if (seriesIndex === 0) {
112
169
  const step = Axis.categoryLabelStep(seriesValues.map(d => d.label), width);
113
170
  seriesValues.forEach((d, i) => {
@@ -121,8 +178,7 @@ export class LineChart extends Chart {
121
178
 
122
179
  // Draw legend for multiline
123
180
  if (isMultiline && showLegend) {
124
- const legendItems = series.map((s, i) => ({ name: s.name, color: palette.colorAt(i) }));
125
- Legend.beside({ x, y, width }, this.renderer.width, { ...legendOptions, items: legendItems }).draw(this.renderer);
181
+ Legend.beside({ x, y, width }, this.renderer.width, legendOptions).draw(this.renderer);
126
182
  }
127
183
  }
128
184
  }
@@ -0,0 +1,79 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Chart } from '../Chart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+
5
+ /**
6
+ * OhlcChart — Open-High-Low-Close financial price bar chart.
7
+ *
8
+ * Visualizes asset price action with a vertical high-to-low spine, a left tick
9
+ * at open, and a right tick at close, shaded green (up) or red (down).
10
+ */
11
+ export class OhlcChart extends Chart {
12
+ static chartType = 'ohlc';
13
+
14
+ _render() {
15
+ const data = this.data;
16
+ if (!Array.isArray(data) || !data.length) return;
17
+
18
+ const options = this.options;
19
+ const box = ChartBox.fromRenderer(this.renderer);
20
+ const {
21
+ x = box.x, y = box.y, width = box.width, height = box.height,
22
+ title, xAxisLabel, yAxisLabel,
23
+ upColor = '#26a69a', downColor = '#ef5350',
24
+ tickWidth = 5,
25
+ } = options;
26
+ const plotBox = { x, y, width, height };
27
+
28
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
29
+
30
+ const allHighs = data.map(d => Number(d.high) || 0);
31
+ const allLows = data.map(d => Number(d.low) || 0);
32
+ const minVal = Math.min(...allLows);
33
+ const maxVal = Math.max(...allHighs);
34
+
35
+ // Draw axes
36
+ new Axis().drawYRange(this.renderer, plotBox, minVal, maxVal);
37
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
38
+
39
+ const toY = v => y + height - ((v - minVal) / ((maxVal - minVal) || 1)) * height;
40
+ const N = data.length;
41
+ const slotW = width / N;
42
+
43
+ data.forEach((bar, i) => {
44
+ const cx = x + i * slotW + slotW / 2;
45
+ const yHigh = toY(bar.high);
46
+ const yLow = toY(bar.low);
47
+ const yOpen = toY(bar.open);
48
+ const yClose = toY(bar.close);
49
+ const isUp = bar.close >= bar.open;
50
+ const color = isUp ? upColor : downColor;
51
+ const tooltip = `${bar.label}: Open ${bar.open}, High ${bar.high}, Low ${bar.low}, Close ${bar.close}`;
52
+
53
+ // Vertical spine (high to low)
54
+ const spine = this._datum(this.renderer.path(`M${cx},${yHigh} L${cx},${yLow}`, 'none', {
55
+ color, width: 2,
56
+ }, { tooltip }), { label: bar.label, ...bar });
57
+
58
+ // Left open tick
59
+ const openTick = this.renderer.path(`M${cx - tickWidth},${yOpen} L${cx},${yOpen}`, 'none', {
60
+ color, width: 2,
61
+ });
62
+
63
+ // Right close tick
64
+ const closeTick = this.renderer.path(`M${cx},${yClose} L${cx + tickWidth},${yClose}`, 'none', {
65
+ color, width: 2,
66
+ });
67
+
68
+ this._wireHoverTransform([spine, openTick, closeTick], { dx: 0, dy: -2 });
69
+
70
+ // Category label along X
71
+ const step = Axis.categoryLabelStep(data.map(d => d.label), width);
72
+ if (i % step === 0) {
73
+ this.renderer.text(bar.label, cx, y + height + 20, {
74
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '10px', fill: '#333',
75
+ });
76
+ }
77
+ });
78
+ }
79
+ }
@@ -64,6 +64,9 @@ export class ParallelCoordinatesChart extends Chart {
64
64
  }
65
65
 
66
66
  data.forEach((row, rowIndex) => {
67
+ const catIdx = hasCategories ? categories.indexOf(row.category) : -1;
68
+ if (catIdx >= 0 && this._dataFilter.has(catIdx)) return;
69
+
67
70
  const pts = [];
68
71
  let valid = true;
69
72
  dimensions.forEach((dim, i) => {
@@ -93,7 +96,12 @@ export class ParallelCoordinatesChart extends Chart {
93
96
  });
94
97
 
95
98
  if (legendVisible && hasCategories) {
96
- const legendItems = categories.map((c, i) => ({ name: c, color: palette.colorAt(i) }));
99
+ const legendItems = categories.map((c, i) => ({
100
+ name: c,
101
+ color: palette.colorAt(i),
102
+ hidden: this._dataFilter.has(i),
103
+ dataIndex: i,
104
+ }));
97
105
  Legend.beside({ x, y, width }, this.renderer.width, {
98
106
  items: legendItems,
99
107
  itemHeight: 20, markerSize: 12,
@@ -4,11 +4,174 @@ import { ColorPalette } from '../ColorPalette.js';
4
4
  import { ShapePaths } from '../ShapePaths.js';
5
5
 
6
6
  /**
7
- * PieChart — pie or doughnut chart with optional legend.
7
+ * PieChart — pie or doughnut chart with optional legend, exploded slices,
8
+ * interactive hover slice pull-out (`hoverExplode`), callout leader lines
9
+ * (`showLabels`), `"Other"` slice roll-up (`maxSlices`), and center KPI label
10
+ * (`centerLabel` / `showTotal` when `innerRadiusRatio > 0`).
11
+ *
12
+ * Also serves as the shared base class for `DonutChart`, `PieChart3D`, and `DonutChart3D`.
8
13
  */
9
14
  export class PieChart extends Chart {
10
15
  static chartType = 'pie';
11
16
 
17
+ /**
18
+ * Resolve whether/how far slice `(d, i)` is pre-exploded in pixels.
19
+ * Supports:
20
+ * - per-slice `d.exploded` (`true` -> `explodeOffset`, or a numeric pixel offset)
21
+ * - `options.exploded`:
22
+ * - `true`: all slices exploded by `explodeOffset`
23
+ * - `'max'`: explodes the largest slice
24
+ * - `number`: slice index
25
+ * - `string`: slice label
26
+ * - `Array<number|string>`: matching indices or labels
27
+ * - `Function`: `(d, i) => boolean | number`
28
+ */
29
+ static resolveExplodeDistance(d, i, data, options = {}) {
30
+ const explodeOffset = options.explodeOffset ?? 14;
31
+ if (typeof d?.exploded === 'number') return d.exploded;
32
+ if (d?.exploded === true) return explodeOffset;
33
+
34
+ const { exploded } = options;
35
+ if (exploded == null || exploded === false) return 0;
36
+ if (exploded === true) return explodeOffset;
37
+ if (exploded === 'max') {
38
+ let maxIdx = 0;
39
+ let maxVal = -Infinity;
40
+ data.forEach((item, idx) => {
41
+ if ((item?.value ?? 0) > maxVal) {
42
+ maxVal = item.value;
43
+ maxIdx = idx;
44
+ }
45
+ });
46
+ return i === maxIdx ? explodeOffset : 0;
47
+ }
48
+ if (typeof exploded === 'number') return i === exploded ? explodeOffset : 0;
49
+ if (typeof exploded === 'string') return d?.label === exploded ? explodeOffset : 0;
50
+ if (Array.isArray(exploded)) {
51
+ return (exploded.includes(i) || exploded.includes(d?.label)) ? explodeOffset : 0;
52
+ }
53
+ if (typeof exploded === 'function') {
54
+ const res = exploded(d, i);
55
+ if (typeof res === 'number') return res;
56
+ return res ? explodeOffset : 0;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /**
62
+ * Prepare normalized slice geometry (angles, percentages, colors, explosion offsets,
63
+ * and optional `maxSlices` roll-up into an `"Other"` slice).
64
+ */
65
+ static prepareSlices(data = [], options = {}) {
66
+ const {
67
+ colors,
68
+ maxSlices = 0,
69
+ otherLabel = 'Other',
70
+ startAngle: initialAngle = -Math.PI / 2,
71
+ } = options;
72
+ const palette = new ColorPalette(colors);
73
+ let items = Array.isArray(data) ? data.filter(d => (d?.value ?? 0) > 0) : [];
74
+ if (maxSlices > 1 && items.length > maxSlices) {
75
+ const head = items.slice(0, maxSlices - 1);
76
+ const tail = items.slice(maxSlices - 1);
77
+ const otherVal = tail.reduce((s, d) => s + (d.value || 0), 0);
78
+ items = [...head, { label: otherLabel, value: otherVal }];
79
+ }
80
+ const total = items.reduce((s, d) => s + d.value, 0) || 1;
81
+ let angle = initialAngle;
82
+ const slices = items.map((d, i) => {
83
+ const span = (d.value / total) * Math.PI * 2;
84
+ const startAngle = angle;
85
+ const endAngle = angle + span;
86
+ const midAngle = startAngle + span / 2;
87
+ angle = endAngle;
88
+ const explodeDistance = PieChart.resolveExplodeDistance(d, i, items, options);
89
+ return {
90
+ index: i,
91
+ datum: d,
92
+ label: d.label,
93
+ value: d.value,
94
+ percent: (d.value / total) * 100,
95
+ color: d.color || palette.colorAt(i),
96
+ startAngle,
97
+ endAngle,
98
+ midAngle,
99
+ explodeDistance,
100
+ };
101
+ });
102
+ return { slices, total, palette };
103
+ }
104
+
105
+ /**
106
+ * Wire smooth hover pull-out on a slice's SVG elements (`elements` moves as a unit).
107
+ */
108
+ _wireSliceHover(elements, midAngle, ryRatio = 1) {
109
+ const { hoverExplode = true, hoverOffset = 14 } = this.options;
110
+ if (!hoverExplode || hoverOffset <= 0) return;
111
+ this._wireRadialHover(elements, midAngle, { offset: hoverOffset, ryRatio });
112
+ }
113
+
114
+ /**
115
+ * Draw optional callout leader lines and external labels for slices.
116
+ */
117
+ _drawCalloutLabel(slice, cx, cy, rxOuter, ryOuter, ryRatio = 1, sliceEls = null) {
118
+ const { showLabels = false, minLabelPercent = 2 } = this.options;
119
+ if (!showLabels || slice.percent < minLabelPercent) return;
120
+
121
+ const cos = Math.cos(slice.midAngle);
122
+ const sin = Math.sin(slice.midAngle);
123
+ const sx = cx + Math.cos(slice.midAngle) * slice.explodeDistance;
124
+ const sy = cy + Math.sin(slice.midAngle) * slice.explodeDistance * ryRatio;
125
+
126
+ const x0 = sx + rxOuter * cos;
127
+ const y0 = sy + ryOuter * sin;
128
+ const x1 = sx + (rxOuter + 14) * cos;
129
+ const y1 = sy + (ryOuter + 14 * ryRatio) * sin;
130
+ const rightSide = cos >= 0;
131
+ const x2 = x1 + (rightSide ? 12 : -12);
132
+ const y2 = y1;
133
+
134
+ const lineEl = this.renderer.path(`M${x0},${y0} L${x1},${y1} L${x2},${y2}`, 'none', { color: '#64748b', width: 1 });
135
+ const labelText = showLabels === 'percent'
136
+ ? `${slice.percent.toFixed(1)}%`
137
+ : showLabels === 'label'
138
+ ? slice.label
139
+ : `${slice.label} (${slice.percent.toFixed(0)}%)`;
140
+ const textEl = this.renderer.text(labelText, x2 + (rightSide ? 4 : -4), y2 + 4, {
141
+ textAnchor: rightSide ? 'start' : 'end',
142
+ fontSize: '11px',
143
+ fill: '#334155',
144
+ });
145
+ if (sliceEls) {
146
+ if (lineEl) sliceEls.push(lineEl);
147
+ if (textEl) sliceEls.push(textEl);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Draw an optional center KPI / total label inside a doughnut hole.
153
+ */
154
+ _drawCenterLabel(cx, cy, total, rInner) {
155
+ if (rInner <= 0) return;
156
+ const { centerLabel, centerSubLabel, showTotal = false } = this.options;
157
+ if (centerLabel == null && !showTotal) return;
158
+ const mainText = centerLabel != null ? String(centerLabel) : total.toLocaleString();
159
+ this.renderer.text(mainText, cx, cy + (centerSubLabel ? -2 : 5), {
160
+ textAnchor: 'middle',
161
+ fontSize: '18px',
162
+ fontWeight: 'bold',
163
+ fill: '#1e293b',
164
+ });
165
+ const sub = centerSubLabel ?? (showTotal && centerLabel == null ? 'Total' : null);
166
+ if (sub) {
167
+ this.renderer.text(String(sub), cx, cy + 16, {
168
+ textAnchor: 'middle',
169
+ fontSize: '11px',
170
+ fill: '#64748b',
171
+ });
172
+ }
173
+ }
174
+
12
175
  _render() {
13
176
  const data = this.data;
14
177
  const options = this.options;
@@ -18,31 +181,46 @@ export class PieChart extends Chart {
18
181
  cx = w / 2, cy = h / 2 + 10,
19
182
  radius = Math.min(w, h) / 2 - 60,
20
183
  innerRadiusRatio = 0,
21
- colors, legend: showLegend = true, title,
184
+ legend: showLegend = true, title,
22
185
  } = options;
23
- const palette = new ColorPalette(colors);
24
- const total = data.reduce((s, d) => s + d.value, 0) || 1;
186
+ const { slices, total } = PieChart.prepareSlices(data, options);
25
187
  const rInner = radius * innerRadiusRatio;
26
188
 
27
189
  if (title) {
28
190
  this.renderer.text(title, cx, cy - radius - 20, { textAnchor: 'middle', fontSize: '18px', fill: '#333' });
29
191
  }
30
192
 
31
- let angle = -Math.PI / 2; // start at 12 o'clock
32
- data.forEach((d, i) => {
33
- const slice = (d.value / total) * Math.PI * 2;
34
- const color = palette.colorAt(i);
35
- const path = ShapePaths.donutSlicePath(cx, cy, radius, rInner, angle, angle + slice);
36
- this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
37
- tooltip: `${d.label}: ${d.value} (${(d.value / total * 100).toFixed(1)}%)`,
38
- }), { label: d.label });
39
- angle += slice;
40
- });
193
+ for (const s of slices) {
194
+ const dx = Math.cos(s.midAngle) * s.explodeDistance;
195
+ const dy = Math.sin(s.midAngle) * s.explodeDistance;
196
+ const path = ShapePaths.donutSlicePath(cx + dx, cy + dy, radius, rInner, s.startAngle, s.endAngle);
197
+ const el = this._datum(this.renderer.path(path, s.color, { color: '#fff', width: 1 }, {
198
+ tooltip: `${s.label}: ${s.value} (${s.percent.toFixed(1)}%)`,
199
+ }), { label: s.label });
200
+ const sliceEls = [el];
201
+ if (options.showValues && s.percent >= 4) {
202
+ const rMid = (radius + rInner) / 2;
203
+ const vx = cx + dx + Math.cos(s.midAngle) * rMid;
204
+ const vy = cy + dy + Math.sin(s.midAngle) * rMid;
205
+ const valText = typeof options.format === 'function' ? options.format(s.value) : s.value.toLocaleString();
206
+ const textEl = this.renderer.text(valText, vx, vy + 4, {
207
+ textAnchor: 'middle',
208
+ fontSize: '11px',
209
+ fontWeight: 'bold',
210
+ fill: '#ffffff',
211
+ });
212
+ if (textEl) sliceEls.push(textEl);
213
+ }
214
+ this._drawCalloutLabel(s, cx, cy, radius, radius, 1, sliceEls);
215
+ this._wireSliceHover(sliceEls, s.midAngle, 1);
216
+ }
217
+
218
+ this._drawCenterLabel(cx, cy, total, rInner);
41
219
 
42
220
  if (showLegend) {
43
- const legendItems = data.map((d, i) => ({
44
- label: `${d.label} (${(d.value / total * 100).toFixed(0)}%)`,
45
- color: palette.colorAt(i),
221
+ const legendItems = slices.map(s => ({
222
+ label: `${s.label} (${s.percent.toFixed(0)}%)`,
223
+ color: s.color,
46
224
  }));
47
225
  new Legend({
48
226
  items: legendItems,
@@ -0,0 +1,137 @@
1
+ import { PieChart } from './PieChart.js';
2
+ import { Legend } from '../Legend.js';
3
+ import { ColorPalette } from '../ColorPalette.js';
4
+ import { ShapePaths } from '../ShapePaths.js';
5
+
6
+ /**
7
+ * PieChart3D — extruded 3D tilted pie/doughnut chart with shaded outer/inner
8
+ * cylinder skirts, radial cut walls, pre-exploded slices (`exploded`), and
9
+ * interactive hover pull-out (`hoverExplode`: hovering over any slice smoothly
10
+ * slides the entire 3D wedge out along its radial angle).
11
+ */
12
+ export class PieChart3D extends PieChart {
13
+ static chartType = 'pie3D';
14
+
15
+ _render() {
16
+ const data = this.data;
17
+ const options = this.options;
18
+ const w = this.renderer?.width ?? 640;
19
+ const h = this.renderer?.height ?? 400;
20
+ const {
21
+ tilt = 0.58,
22
+ depth = 26,
23
+ innerRadiusRatio = 0,
24
+ legend: showLegend = true,
25
+ title,
26
+ } = options;
27
+ const rx = options.radius ?? (Math.min(w, h) / 2 - 55);
28
+ const ry = rx * tilt;
29
+ const rxInner = rx * innerRadiusRatio;
30
+ const ryInner = ry * innerRadiusRatio;
31
+ const cx = options.cx ?? (showLegend ? Math.max(rx + 45, w / 2 - 45) : w / 2);
32
+ const cy = options.cy ?? (h / 2 - depth / 2 + 8);
33
+
34
+ const { slices, total } = PieChart.prepareSlices(data, {
35
+ explodeOffset: 16,
36
+ ...options,
37
+ });
38
+
39
+ if (title) {
40
+ this.renderer.text(title, w / 2, Math.max(24, cy - ry - 26), {
41
+ textAnchor: 'middle',
42
+ fontSize: '18px',
43
+ fill: '#333',
44
+ });
45
+ }
46
+
47
+ // Sort slices from back (12 o'clock, sin(θ) = -1) to front (6 o'clock, sin(θ) = +1)
48
+ // so painter's algorithm naturally occludes back wedges with front wedges while keeping
49
+ // all faces of each slice grouped for smooth hover pull-out.
50
+ const ordered = [...slices].sort((a, b) => Math.sin(a.midAngle) - Math.sin(b.midAngle));
51
+
52
+ for (const s of ordered) {
53
+ const dx = Math.cos(s.midAngle) * s.explodeDistance;
54
+ const dy = Math.sin(s.midAngle) * s.explodeDistance * tilt;
55
+ const scx = cx + dx;
56
+ const scy = cy + dy;
57
+ const tooltip = `${s.label}: ${s.value} (${s.percent.toFixed(1)}%)`;
58
+ const datum = { label: s.label };
59
+ const sliceEls = [];
60
+
61
+ const topColor = ColorPalette.lighten(s.color, 0.06);
62
+ const outerSkirtColor = ColorPalette.lighten(s.color, -0.22);
63
+ const innerSkirtColor = ColorPalette.lighten(s.color, -0.28);
64
+ const wallColorA = ColorPalette.lighten(s.color, -0.15);
65
+ const wallColorB = ColorPalette.lighten(s.color, -0.25);
66
+ const stroke = { color: ColorPalette.lighten(s.color, -0.32), width: 0.8 };
67
+
68
+ // Order the two radial cut walls from further back (smaller average sin) to further front
69
+ const walls = [
70
+ { angle: s.startAngle, depthOrder: Math.sin(s.startAngle), color: wallColorA },
71
+ { angle: s.endAngle, depthOrder: Math.sin(s.endAngle), color: wallColorB },
72
+ ].sort((a, b) => a.depthOrder - b.depthOrder);
73
+
74
+ const drawWall = (wSpec) => {
75
+ if (depth <= 0 || slices.length <= 1) return;
76
+ const dPath = ShapePaths.radialWallPath(scx, scy, rxInner, ryInner, rx, ry, depth, wSpec.angle);
77
+ const el = this._datum(this.renderer.path(dPath, wSpec.color, stroke, { tooltip }), datum);
78
+ if (el) sliceEls.push(el);
79
+ };
80
+
81
+ // 1. Back-most radial cut wall
82
+ drawWall(walls[0]);
83
+
84
+ // 2. Inner donut hole cylinder wall (visible along the back half where sin(θ) <= 0)
85
+ if (rxInner > 0 && depth > 0) {
86
+ for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'back')) {
87
+ const innerPath = ShapePaths.cylinderSkirtPath(scx, scy, rxInner, ryInner, depth, a0, a1);
88
+ if (innerPath) {
89
+ const el = this._datum(this.renderer.path(innerPath, innerSkirtColor, stroke, { tooltip }), datum);
90
+ if (el) sliceEls.push(el);
91
+ }
92
+ }
93
+ }
94
+
95
+ // 3. Front-most radial cut wall
96
+ drawWall(walls[1]);
97
+
98
+ // 4. Outer cylinder rim skirt (visible along the front half where sin(θ) >= 0)
99
+ if (depth > 0) {
100
+ for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'front')) {
101
+ const skirtPath = ShapePaths.cylinderSkirtPath(scx, scy, rx, ry, depth, a0, a1);
102
+ if (skirtPath) {
103
+ const el = this._datum(this.renderer.path(skirtPath, outerSkirtColor, stroke, { tooltip }), datum);
104
+ if (el) sliceEls.push(el);
105
+ }
106
+ }
107
+ }
108
+
109
+ // 5. Top elliptical slice face
110
+ const topPath = ShapePaths.ellipticalSlicePath(scx, scy, rx, ry, rxInner, ryInner, s.startAngle, s.endAngle);
111
+ const topEl = this._datum(this.renderer.path(topPath, topColor, { color: '#fff', width: 1 }, { tooltip }), datum);
112
+ if (topEl) sliceEls.push(topEl);
113
+
114
+ // 6. Optional callout leader line + label (moves with slice on hover)
115
+ this._drawCalloutLabel(s, cx, cy, rx, ry, tilt, sliceEls);
116
+
117
+ // 7. Wire smooth radial hover pull-out across all 3D faces of this slice
118
+ this._wireSliceHover(sliceEls, s.midAngle, tilt);
119
+ }
120
+
121
+ this._drawCenterLabel(cx, cy, total, rxInner);
122
+
123
+ if (showLegend) {
124
+ const legendItems = slices.map(s => ({
125
+ label: `${s.label} (${s.percent.toFixed(0)}%)`,
126
+ color: s.color,
127
+ }));
128
+ new Legend({
129
+ items: legendItems,
130
+ x: cx + rx + 28,
131
+ y: cy - ry,
132
+ itemHeight: 20,
133
+ markerSize: 12,
134
+ }).draw(this.renderer);
135
+ }
136
+ }
137
+ }