@peter.prib/renderengine 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +480 -302
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49217 -37484
- package/dist/renderengine.umd.js +24899 -13093
- package/documentation/REFERENCE.md +68 -1
- package/package.json +13 -3
- 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 +269 -3
- 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 +7 -2
- 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 +142 -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/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +69 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +6 -4
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +25 -3
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +107 -4
- 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/DensityChart.js +1 -1
- 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 +5 -2
- 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 +6 -3
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +12 -5
- package/src/charting/charts/RadialBarChart.js +6 -3
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +129 -6
- package/src/charting/charts/ScatterChart.js +19 -3
- 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 +125 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
- package/src/charting/charts/StreamGraphChart.js +18 -3
- package/src/charting/charts/SunburstChart.js +28 -8
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +31 -11
- package/src/charting/charts/ViolinChart.js +20 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- 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 +310 -13
- package/src/charting/pivot/PivotGridChart.js +322 -34
- package/src/charting/pivot/PivotView.js +626 -21
- 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 -2899
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -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
|
+
}
|
|
@@ -67,8 +67,11 @@ export class PipeChart extends Chart {
|
|
|
67
67
|
this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
|
|
68
68
|
|
|
69
69
|
pts.forEach(p => {
|
|
70
|
-
this.renderer.circle
|
|
71
|
-
|
|
70
|
+
if (typeof this.renderer.circle === 'function') {
|
|
71
|
+
this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
|
|
72
|
+
{ tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
|
|
73
|
+
{ label: p.d.label, series: isMultiline ? s.name : null });
|
|
74
|
+
}
|
|
72
75
|
});
|
|
73
76
|
|
|
74
77
|
if (seriesIndex === 0) {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
2
|
+
import { Chart } from '../Chart.js';
|
|
3
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PointAndFigureChart — classical financial charting using columns of X's (rising)
|
|
7
|
+
* and O's (falling) to filter out time and track supply/demand breakouts.
|
|
8
|
+
*/
|
|
9
|
+
export class PointAndFigureChart extends Chart {
|
|
10
|
+
static chartType = 'pointAndFigure';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compute Point and Figure columns from price data.
|
|
14
|
+
* @param {Array<number|object>} data
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {number} [options.boxSize] - Price increment per box
|
|
17
|
+
* @param {number} [options.reversal=3] - Number of boxes to trigger reversal
|
|
18
|
+
*/
|
|
19
|
+
static computePointAndFigure(data, { boxSize, reversal = 3 } = {}) {
|
|
20
|
+
if (!Array.isArray(data) || data.length === 0) return { columns: [], boxSize: 1, minPrice: 0, maxPrice: 1 };
|
|
21
|
+
|
|
22
|
+
const items = data.map((d, i) => {
|
|
23
|
+
const high = Number(d.high ?? d.close ?? d.value ?? d) || 0;
|
|
24
|
+
const low = Number(d.low ?? d.close ?? d.value ?? d) || 0;
|
|
25
|
+
const label = typeof d === 'object' && d !== null ? (d.label ?? d.date ?? `#${i + 1}`) : `#${i + 1}`;
|
|
26
|
+
return { high, low, label };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const p0 = items[0].high;
|
|
30
|
+
const bSize = boxSize ?? Math.max(0.5, Math.round(p0 * 0.015 * 10) / 10);
|
|
31
|
+
const toBox = (price) => Math.floor(price / bSize);
|
|
32
|
+
|
|
33
|
+
let curType = items[1]?.high >= p0 ? 'X' : 'O';
|
|
34
|
+
let curBoxes = [toBox(p0)];
|
|
35
|
+
const columns = [{ type: curType, boxes: curBoxes, label: items[0].label }];
|
|
36
|
+
|
|
37
|
+
for (let i = 1; i < items.length; i++) {
|
|
38
|
+
const { high, low, label } = items[i];
|
|
39
|
+
const col = columns[columns.length - 1];
|
|
40
|
+
const topBox = Math.max(...col.boxes);
|
|
41
|
+
const botBox = Math.min(...col.boxes);
|
|
42
|
+
|
|
43
|
+
if (col.type === 'X') {
|
|
44
|
+
const highBox = toBox(high);
|
|
45
|
+
if (highBox > topBox) {
|
|
46
|
+
for (let b = topBox + 1; b <= highBox; b++) col.boxes.push(b);
|
|
47
|
+
col.label = label;
|
|
48
|
+
} else {
|
|
49
|
+
const lowBox = toBox(low);
|
|
50
|
+
if (topBox - lowBox >= reversal) {
|
|
51
|
+
const newBoxes = [];
|
|
52
|
+
for (let b = topBox - 1; b >= lowBox; b--) newBoxes.push(b);
|
|
53
|
+
columns.push({ type: 'O', boxes: newBoxes, label });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const lowBox = toBox(low);
|
|
58
|
+
if (lowBox < botBox) {
|
|
59
|
+
for (let b = botBox - 1; b >= lowBox; b--) col.boxes.push(b);
|
|
60
|
+
col.label = label;
|
|
61
|
+
} else {
|
|
62
|
+
const highBox = toBox(high);
|
|
63
|
+
if (highBox - botBox >= reversal) {
|
|
64
|
+
const newBoxes = [];
|
|
65
|
+
for (let b = botBox + 1; b <= highBox; b++) newBoxes.push(b);
|
|
66
|
+
columns.push({ type: 'X', boxes: newBoxes, label });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let minB = Infinity;
|
|
73
|
+
let maxB = -Infinity;
|
|
74
|
+
columns.forEach(col => {
|
|
75
|
+
col.boxes.forEach(b => {
|
|
76
|
+
if (b < minB) minB = b;
|
|
77
|
+
if (b > maxB) maxB = b;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!Number.isFinite(minB)) { minB = 0; maxB = 10; }
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
columns,
|
|
85
|
+
boxSize: bSize,
|
|
86
|
+
minBox: minB,
|
|
87
|
+
maxBox: maxB,
|
|
88
|
+
minPrice: minB * bSize,
|
|
89
|
+
maxPrice: (maxB + 1) * bSize,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_render() {
|
|
94
|
+
const options = this.options || {};
|
|
95
|
+
const { columns, boxSize, minBox, maxBox, minPrice, maxPrice } = PointAndFigureChart.computePointAndFigure(this.data, options);
|
|
96
|
+
if (!columns.length) return;
|
|
97
|
+
|
|
98
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
99
|
+
const {
|
|
100
|
+
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
101
|
+
xColor = '#26a69a', oColor = '#ef5350',
|
|
102
|
+
showGrid = true, showTrendlines = true,
|
|
103
|
+
title, xAxisLabel, yAxisLabel,
|
|
104
|
+
} = options;
|
|
105
|
+
|
|
106
|
+
const plotBox = { x, y, width, height };
|
|
107
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
108
|
+
|
|
109
|
+
const totalBoxes = (maxBox - minBox + 1) || 1;
|
|
110
|
+
const totalCols = columns.length;
|
|
111
|
+
const colW = width / Math.max(1, totalCols);
|
|
112
|
+
const boxH = height / totalBoxes;
|
|
113
|
+
|
|
114
|
+
const toScreenY = (bIndex) => y + (maxBox - bIndex) * boxH;
|
|
115
|
+
|
|
116
|
+
// 1. Grid lines
|
|
117
|
+
if (showGrid) {
|
|
118
|
+
for (let b = minBox; b <= maxBox + 1; b++) {
|
|
119
|
+
const gy = toScreenY(b);
|
|
120
|
+
this.renderer.path(`M${x},${gy} L${x + width},${gy}`, 'none', { color: '#f0f0f0', width: 0.75 });
|
|
121
|
+
}
|
|
122
|
+
for (let c = 0; c <= totalCols; c++) {
|
|
123
|
+
const gx = x + c * colW;
|
|
124
|
+
this.renderer.path(`M${gx},${y} L${gx},${y + height}`, 'none', { color: '#f0f0f0', width: 0.75 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. Y-axis price labels
|
|
129
|
+
const axisStep = Math.max(1, Math.round(totalBoxes / 8));
|
|
130
|
+
for (let b = minBox; b <= maxBox; b += axisStep) {
|
|
131
|
+
const py = toScreenY(b) + boxH / 2;
|
|
132
|
+
const priceVal = b * boxSize;
|
|
133
|
+
this.renderer.text(Axis.formatValue(priceVal), x - 8, py, {
|
|
134
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '9px', fill: '#666',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 3. Columns of X and O
|
|
139
|
+
columns.forEach((col, cIdx) => {
|
|
140
|
+
const cx = x + cIdx * colW + colW / 2;
|
|
141
|
+
const isX = col.type === 'X';
|
|
142
|
+
const color = isX ? xColor : oColor;
|
|
143
|
+
const glyphSize = Math.min(colW, boxH) * 0.7;
|
|
144
|
+
|
|
145
|
+
col.boxes.forEach(bIndex => {
|
|
146
|
+
const cy = toScreenY(bIndex) + boxH / 2;
|
|
147
|
+
const price = bIndex * boxSize;
|
|
148
|
+
|
|
149
|
+
if (isX) {
|
|
150
|
+
// Draw X
|
|
151
|
+
const hs = glyphSize / 2;
|
|
152
|
+
const pathD = `M${cx - hs},${cy - hs} L${cx + hs},${cy + hs} M${cx - hs},${cy + hs} L${cx + hs},${cy - hs}`;
|
|
153
|
+
const el = this.renderer.path(pathD, 'none', { color, width: 2 }, {
|
|
154
|
+
tooltip: `X (${col.label}): ${Axis.formatValue(price)}`,
|
|
155
|
+
});
|
|
156
|
+
this._datum(el, { label: col.label, type: 'X', price });
|
|
157
|
+
} else {
|
|
158
|
+
// Draw O
|
|
159
|
+
const r = glyphSize / 2;
|
|
160
|
+
const el = this.renderer.circle(cx, cy, r, 'none', { color, width: 2 }, {
|
|
161
|
+
tooltip: `O (${col.label}): ${Axis.formatValue(price)}`,
|
|
162
|
+
});
|
|
163
|
+
this._datum(el, { label: col.label, type: 'O', price });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Column label along X axis
|
|
168
|
+
const step = Math.max(1, Math.floor(totalCols / 8));
|
|
169
|
+
if (cIdx % step === 0) {
|
|
170
|
+
this.renderer.text(col.label, cx, y + height + 18, {
|
|
171
|
+
textAnchor: this.renderer.centerTextAnchor, fontSize: '9px', fill: '#555',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 4. Optional 45-degree support/resistance trendlines
|
|
177
|
+
if (showTrendlines && totalCols > 3) {
|
|
178
|
+
// Find major low for bullish support (45 deg up)
|
|
179
|
+
let lowestBox = Infinity, lowestCol = 0;
|
|
180
|
+
columns.forEach((c, idx) => {
|
|
181
|
+
const minB = Math.min(...c.boxes);
|
|
182
|
+
if (minB < lowestBox) { lowestBox = minB; lowestCol = idx; }
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (lowestCol < totalCols - 1) {
|
|
186
|
+
const x1 = x + lowestCol * colW + colW / 2;
|
|
187
|
+
const y1 = toScreenY(lowestBox) + boxH;
|
|
188
|
+
const steps = totalCols - 1 - lowestCol;
|
|
189
|
+
const x2 = x1 + steps * colW;
|
|
190
|
+
const y2 = y1 - steps * boxH;
|
|
191
|
+
this.renderer.path(`M${x1},${y1} L${x2},${y2}`, 'none', { color: '#26a69a', width: 1.5, strokeDasharray: '4 4' });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Draw baseline axis
|
|
196
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
197
|
+
}
|
|
198
|
+
}
|