@peter.prib/renderengine 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -19
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49641 -39747
- package/dist/renderengine.umd.js +25366 -15650
- package/documentation/REFERENCE.md +206 -0
- package/examples/decision-workbench.html +26 -0
- package/package.json +7 -1
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +219 -0
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +3 -0
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +114 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +52 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +5 -3
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +23 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +105 -2
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +4 -1
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +2 -2
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/RadarChart.js +7 -1
- package/src/charting/charts/RadialBarChart.js +5 -2
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +127 -4
- package/src/charting/charts/ScatterChart.js +13 -0
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +48 -5
- package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
- package/src/charting/charts/StreamGraphChart.js +10 -3
- package/src/charting/charts/SunburstChart.js +27 -7
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +28 -8
- package/src/charting/charts/ViolinChart.js +18 -0
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +109 -4
- package/src/charting/pivot/PivotGridChart.js +321 -33
- package/src/charting/pivot/PivotView.js +82 -0
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2995
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/index.js +3 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/parts/DecisionWorkbenchPart.js +58 -0
- package/src/workspace/parts/ViewsPart.js +196 -0
- 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
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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 =
|
|
111
|
+
const pointY = toY(d.value) - seriesDepth;
|
|
75
112
|
if (i === 0) {
|
|
76
|
-
pathData
|
|
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 =
|
|
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 =
|
|
103
|
-
const yLow =
|
|
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.
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
184
|
+
legend: showLegend = true, title,
|
|
22
185
|
} = options;
|
|
23
|
-
const
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
44
|
-
label: `${
|
|
45
|
-
color:
|
|
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
|
+
}
|