@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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Chart } from '../Chart.js';
|
|
2
|
+
import { PieChart } from './PieChart.js';
|
|
3
|
+
import { Legend } from '../Legend.js';
|
|
4
|
+
import { ColorPalette } from '../ColorPalette.js';
|
|
5
|
+
import { ShapePaths } from '../ShapePaths.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PolarAreaChart — Nightingale Rose / Coxcomb chart where each category occupies an
|
|
9
|
+
* equal angular sector and its outer radius (or area when `scale: 'area'`) encodes `value`.
|
|
10
|
+
*
|
|
11
|
+
* Supports:
|
|
12
|
+
* - 2D and 3D extruded wedges (`extrude3D: true` or `depth > 0` with `tilt`)
|
|
13
|
+
* - Pre-exploded wedges (`exploded`: `'max'`, index, label, array, or per-item `d.exploded`)
|
|
14
|
+
* - Interactive radial hover pull-out (`hoverExplode = true`, `hoverOffset = 14`)
|
|
15
|
+
* - Concentric reference rings (`showGrid = true`)
|
|
16
|
+
*/
|
|
17
|
+
export class PolarAreaChart extends Chart {
|
|
18
|
+
static chartType = 'polarArea';
|
|
19
|
+
|
|
20
|
+
_render() {
|
|
21
|
+
const rawData = Array.isArray(this.data) ? this.data.filter(d => (d?.value ?? 0) >= 0) : [];
|
|
22
|
+
const options = this.options || {};
|
|
23
|
+
const w = this.renderer?.width ?? 640;
|
|
24
|
+
const h = this.renderer?.height ?? 400;
|
|
25
|
+
const {
|
|
26
|
+
extrude3D = false,
|
|
27
|
+
depth = extrude3D ? 22 : 0,
|
|
28
|
+
tilt = depth > 0 ? 0.62 : 1,
|
|
29
|
+
innerRadiusRatio = 0.12,
|
|
30
|
+
padAngle = 0.02,
|
|
31
|
+
scale = 'radius', // 'radius' | 'area'
|
|
32
|
+
showGrid = true,
|
|
33
|
+
showValues = false,
|
|
34
|
+
legend: showLegend = true,
|
|
35
|
+
hoverExplode = true,
|
|
36
|
+
hoverOffset = 14,
|
|
37
|
+
colors,
|
|
38
|
+
title,
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
const rxMax = options.radius ?? (Math.min(w, h) / 2 - 58);
|
|
42
|
+
const ryMax = rxMax * tilt;
|
|
43
|
+
const rxInner = rxMax * innerRadiusRatio;
|
|
44
|
+
const ryInner = ryMax * innerRadiusRatio;
|
|
45
|
+
const cx = options.cx ?? (showLegend ? Math.max(rxMax + 45, w / 2 - 45) : w / 2);
|
|
46
|
+
const cy = options.cy ?? (h / 2 - depth / 2 + 10);
|
|
47
|
+
|
|
48
|
+
const palette = new ColorPalette(colors);
|
|
49
|
+
if (title) {
|
|
50
|
+
this.renderer.text(title, w / 2, Math.max(24, cy - ryMax - 26), {
|
|
51
|
+
textAnchor: 'middle',
|
|
52
|
+
fontSize: '18px',
|
|
53
|
+
fill: '#333',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!rawData.length) return;
|
|
57
|
+
|
|
58
|
+
const maxValue = Math.max(...rawData.map(d => d.value), 1);
|
|
59
|
+
const total = rawData.reduce((s, d) => s + d.value, 0) || 1;
|
|
60
|
+
const n = rawData.length;
|
|
61
|
+
const sectorSpan = (Math.PI * 2) / n;
|
|
62
|
+
|
|
63
|
+
// Draw subtle concentric reference rings
|
|
64
|
+
if (showGrid) {
|
|
65
|
+
for (const frac of [0.33, 0.66, 1.0]) {
|
|
66
|
+
const grx = rxInner + (rxMax - rxInner) * frac;
|
|
67
|
+
const gry = ryInner + (ryMax - ryInner) * frac;
|
|
68
|
+
const ringPath = ShapePaths.ellipticalSlicePath(cx, cy, grx, gry, Math.max(0.1, grx - 0.5), Math.max(0.1, gry - 0.5), 0, Math.PI * 2);
|
|
69
|
+
const ringEl = this.renderer.path(ringPath, 'none', { color: '#e2e8f0', width: 1 });
|
|
70
|
+
ringEl?.setAttribute?.('stroke-dasharray', '3 3');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sectors = rawData.map((d, i) => {
|
|
75
|
+
const startAngle = -Math.PI / 2 + i * sectorSpan + padAngle / 2;
|
|
76
|
+
const endAngle = -Math.PI / 2 + (i + 1) * sectorSpan - padAngle / 2;
|
|
77
|
+
const midAngle = (startAngle + endAngle) / 2;
|
|
78
|
+
const norm = Math.max(0, d.value) / maxValue;
|
|
79
|
+
const ratio = scale === 'area' ? Math.sqrt(norm) : norm;
|
|
80
|
+
const rx = rxInner + ratio * (rxMax - rxInner);
|
|
81
|
+
const ry = ryInner + ratio * (ryMax - ryInner);
|
|
82
|
+
const explodeDistance = PieChart.resolveExplodeDistance(d, i, rawData, {
|
|
83
|
+
explodeOffset: 14,
|
|
84
|
+
...options,
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
index: i,
|
|
88
|
+
datum: d,
|
|
89
|
+
label: d.label,
|
|
90
|
+
value: d.value,
|
|
91
|
+
percent: (d.value / total) * 100,
|
|
92
|
+
color: d.color || palette.colorAt(i),
|
|
93
|
+
startAngle,
|
|
94
|
+
endAngle,
|
|
95
|
+
midAngle,
|
|
96
|
+
rx,
|
|
97
|
+
ry,
|
|
98
|
+
explodeDistance,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Sort back-to-front when extruded in 3D so front wedges occlude back wedges cleanly
|
|
103
|
+
const ordered = depth > 0
|
|
104
|
+
? [...sectors].sort((a, b) => Math.sin(a.midAngle) - Math.sin(b.midAngle))
|
|
105
|
+
: sectors;
|
|
106
|
+
|
|
107
|
+
for (const s of ordered) {
|
|
108
|
+
const dx = Math.cos(s.midAngle) * s.explodeDistance;
|
|
109
|
+
const dy = Math.sin(s.midAngle) * s.explodeDistance * tilt;
|
|
110
|
+
const scx = cx + dx;
|
|
111
|
+
const scy = cy + dy;
|
|
112
|
+
const tooltip = `${s.label}: ${s.value} (${s.percent.toFixed(1)}%)`;
|
|
113
|
+
const datum = { label: s.label };
|
|
114
|
+
const wedgeEls = [];
|
|
115
|
+
|
|
116
|
+
if (depth > 0) {
|
|
117
|
+
const stroke = { color: ColorPalette.lighten(s.color, -0.32), width: 0.8 };
|
|
118
|
+
const walls = [
|
|
119
|
+
{ angle: s.startAngle, depthOrder: Math.sin(s.startAngle), color: ColorPalette.lighten(s.color, -0.15) },
|
|
120
|
+
{ angle: s.endAngle, depthOrder: Math.sin(s.endAngle), color: ColorPalette.lighten(s.color, -0.25) },
|
|
121
|
+
].sort((a, b) => a.depthOrder - b.depthOrder);
|
|
122
|
+
|
|
123
|
+
const w0 = this._datum(this.renderer.path(
|
|
124
|
+
ShapePaths.radialWallPath(scx, scy, rxInner, ryInner, s.rx, s.ry, depth, walls[0].angle),
|
|
125
|
+
walls[0].color, stroke, { tooltip }
|
|
126
|
+
), datum);
|
|
127
|
+
if (w0) wedgeEls.push(w0);
|
|
128
|
+
|
|
129
|
+
if (rxInner > 0) {
|
|
130
|
+
for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'back')) {
|
|
131
|
+
const innerSkirt = ShapePaths.cylinderSkirtPath(scx, scy, rxInner, ryInner, depth, a0, a1);
|
|
132
|
+
if (innerSkirt) {
|
|
133
|
+
const el = this._datum(this.renderer.path(innerSkirt, ColorPalette.lighten(s.color, -0.28), stroke, { tooltip }), datum);
|
|
134
|
+
if (el) wedgeEls.push(el);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const w1 = this._datum(this.renderer.path(
|
|
140
|
+
ShapePaths.radialWallPath(scx, scy, rxInner, ryInner, s.rx, s.ry, depth, walls[1].angle),
|
|
141
|
+
walls[1].color, stroke, { tooltip }
|
|
142
|
+
), datum);
|
|
143
|
+
if (w1) wedgeEls.push(w1);
|
|
144
|
+
|
|
145
|
+
for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'front')) {
|
|
146
|
+
const outerSkirt = ShapePaths.cylinderSkirtPath(scx, scy, s.rx, s.ry, depth, a0, a1);
|
|
147
|
+
if (outerSkirt) {
|
|
148
|
+
const el = this._datum(this.renderer.path(outerSkirt, ColorPalette.lighten(s.color, -0.22), stroke, { tooltip }), datum);
|
|
149
|
+
if (el) wedgeEls.push(el);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const topColor = depth > 0 ? ColorPalette.lighten(s.color, 0.06) : s.color;
|
|
155
|
+
const topPath = ShapePaths.ellipticalSlicePath(scx, scy, s.rx, s.ry, rxInner, ryInner, s.startAngle, s.endAngle);
|
|
156
|
+
const topEl = this._datum(this.renderer.path(topPath, topColor, { color: '#fff', width: 1 }, { tooltip }), datum);
|
|
157
|
+
if (topEl) wedgeEls.push(topEl);
|
|
158
|
+
|
|
159
|
+
if (showValues) {
|
|
160
|
+
const lx = scx + (s.rx + 14) * Math.cos(s.midAngle);
|
|
161
|
+
const ly = scy + (s.ry + 14 * tilt) * Math.sin(s.midAngle);
|
|
162
|
+
const txtEl = this.renderer.text(`${s.label} (${s.value})`, lx, ly + 4, {
|
|
163
|
+
textAnchor: Math.cos(s.midAngle) >= 0 ? 'start' : 'end',
|
|
164
|
+
fontSize: '10px',
|
|
165
|
+
fill: '#334155',
|
|
166
|
+
});
|
|
167
|
+
if (txtEl) wedgeEls.push(txtEl);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (hoverExplode && hoverOffset > 0) {
|
|
171
|
+
this._wireRadialHover(wedgeEls, s.midAngle, { offset: hoverOffset, ryRatio: tilt });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (showLegend) {
|
|
176
|
+
const legendItems = sectors.map(s => ({
|
|
177
|
+
label: `${s.label} (${s.value})`,
|
|
178
|
+
color: s.color,
|
|
179
|
+
}));
|
|
180
|
+
new Legend({
|
|
181
|
+
items: legendItems,
|
|
182
|
+
x: cx + rxMax + 28,
|
|
183
|
+
y: cy - ryMax,
|
|
184
|
+
itemHeight: 20,
|
|
185
|
+
markerSize: 12,
|
|
186
|
+
}).draw(this.renderer);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -69,15 +69,15 @@ export class PopulationPyramidChart extends Chart {
|
|
|
69
69
|
|
|
70
70
|
// Left bar grows from center to the left.
|
|
71
71
|
if (leftW > 0) {
|
|
72
|
-
this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
72
|
+
this._datum(this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
|
|
73
73
|
tooltip: `${d.label} — ${leftLabel}: ${d.left}`,
|
|
74
|
-
});
|
|
74
|
+
}), { label: d.label, series: leftLabel });
|
|
75
75
|
}
|
|
76
76
|
// Right bar grows from center to the right.
|
|
77
77
|
if (rightW > 0) {
|
|
78
|
-
this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
78
|
+
this._datum(this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
|
|
79
79
|
tooltip: `${d.label} — ${rightLabel}: ${d.right}`,
|
|
80
|
-
});
|
|
80
|
+
}), { label: d.label, series: rightLabel });
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// Row label centered on the axis.
|
|
@@ -21,7 +21,7 @@ export class RadarChart extends Chart {
|
|
|
21
21
|
} = options;
|
|
22
22
|
const isMulti = Array.isArray(data[0]?.values);
|
|
23
23
|
const series = isMulti ? data : [{ name: 'default', values: data }];
|
|
24
|
-
const axes = series[0].values.map(v => v.axis);
|
|
24
|
+
const axes = series[0].values.map(v => v.axis ?? v.label);
|
|
25
25
|
const n = axes.length;
|
|
26
26
|
const palette = new ColorPalette(colors);
|
|
27
27
|
const axisMax = axes.map((_, ai) => Math.max(...series.map(s => s.values[ai]?.value ?? 0)) || 1);
|
|
@@ -53,21 +53,28 @@ export class RadarChart extends Chart {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
series.forEach((s, seriesIndex) => {
|
|
56
|
+
if (this._dataFilter.has(seriesIndex)) return;
|
|
56
57
|
const color = palette.colorAt(seriesIndex);
|
|
57
58
|
const pts = s.values.map((v, i) => pointFor(i, v.value));
|
|
58
59
|
const path = 'M' + pts.map(p => `${p.px},${p.py}`).join(' L') + ' Z';
|
|
59
60
|
const { r, g, b } = ColorPalette.hexToRgb(color);
|
|
60
61
|
this.renderer.path(path, `rgba(${r},${g},${b},0.25)`, { color, width: 2 }, { tooltip: s.name });
|
|
61
62
|
pts.forEach((p, i) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const axisLabel = s.values[i].axis ?? s.values[i].label;
|
|
64
|
+
this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
|
|
65
|
+
tooltip: `${s.name} — ${axisLabel}: ${s.values[i].value}`,
|
|
66
|
+
}), { label: axisLabel, series: isMulti ? s.name : null });
|
|
65
67
|
});
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
if (isMulti && showLegend) {
|
|
69
71
|
new Legend({
|
|
70
|
-
items: series.map((s, i) => ({
|
|
72
|
+
items: series.map((s, i) => ({
|
|
73
|
+
name: s.name,
|
|
74
|
+
color: palette.colorAt(i),
|
|
75
|
+
hidden: this._dataFilter.has(i),
|
|
76
|
+
dataIndex: i,
|
|
77
|
+
})),
|
|
71
78
|
x: cx + radius + 40, y: cy - radius, markerSize: 12,
|
|
72
79
|
}).draw(this.renderer);
|
|
73
80
|
}
|
|
@@ -56,9 +56,9 @@ export class RadialBarChart extends Chart {
|
|
|
56
56
|
const rOuter = innerRadius + barLen;
|
|
57
57
|
const color = d.color ?? palette.colorAt(i);
|
|
58
58
|
const path = ShapePaths.donutSlicePath(cx, cy, rOuter, innerRadius, a0, a1);
|
|
59
|
-
this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
59
|
+
const sliceEl = this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
|
|
60
60
|
tooltip: `${d.label}: ${d.value}`,
|
|
61
|
-
});
|
|
61
|
+
}), { label: d.label });
|
|
62
62
|
|
|
63
63
|
// Category label around the outer track.
|
|
64
64
|
const midAngle = (a0 + a1) / 2;
|
|
@@ -71,15 +71,18 @@ export class RadialBarChart extends Chart {
|
|
|
71
71
|
transform: `rotate(${rotate - 90},${lx},${ly})`,
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
const hoverEls = [sliceEl];
|
|
74
75
|
if (showValues && barLen > 16) {
|
|
75
76
|
const vmid = (innerRadius + rOuter) / 2;
|
|
76
77
|
const vx = cx + vmid * Math.cos(midAngle);
|
|
77
78
|
const vy = cy + vmid * Math.sin(midAngle);
|
|
78
|
-
this.renderer.text(String(d.value), vx, vy, {
|
|
79
|
+
const valEl = this.renderer.text(String(d.value), vx, vy, {
|
|
79
80
|
textAnchor: 'middle', verticalAlign: 'middle', fontSize: '9px',
|
|
80
81
|
fill: ColorPalette.isDark(color) ? '#fff' : '#333',
|
|
81
82
|
});
|
|
83
|
+
hoverEls.push(valEl);
|
|
82
84
|
}
|
|
85
|
+
this._wireRadialHover(hoverEls, midAngle, { offset: options.hoverOffset ?? 10 });
|
|
83
86
|
});
|
|
84
87
|
|
|
85
88
|
if (showLegend) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
2
|
+
import { Chart } from '../Chart.js';
|
|
3
|
+
import { ChartBox, ChartLabels } from '../ChartBox.js';
|
|
4
|
+
import { ColorPalette } from '../ColorPalette.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RenkoChart — financial price-action brick and Heikin-Ashi chart.
|
|
8
|
+
*
|
|
9
|
+
* Filters out time noise by plotting price movements of fixed box size.
|
|
10
|
+
* In `heikinAshi: true` mode, renders smoothed trend momentum candlesticks.
|
|
11
|
+
*/
|
|
12
|
+
export class RenkoChart extends Chart {
|
|
13
|
+
static chartType = 'renko';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute Heikin-Ashi smoothed candles from raw OHLC data.
|
|
17
|
+
*/
|
|
18
|
+
static computeHeikinAshi(candles) {
|
|
19
|
+
if (!Array.isArray(candles) || !candles.length) return [];
|
|
20
|
+
const result = [];
|
|
21
|
+
let prevOpen = (candles[0].open + candles[0].close) / 2;
|
|
22
|
+
let prevClose = (candles[0].open + candles[0].high + candles[0].low + candles[0].close) / 4;
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < candles.length; i++) {
|
|
25
|
+
const c = candles[i];
|
|
26
|
+
const haClose = (c.open + c.high + c.low + c.close) / 4;
|
|
27
|
+
const haOpen = i === 0 ? prevOpen : (prevOpen + prevClose) / 2;
|
|
28
|
+
const haHigh = Math.max(c.high, haOpen, haClose);
|
|
29
|
+
const haLow = Math.min(c.low, haOpen, haClose);
|
|
30
|
+
|
|
31
|
+
result.push({
|
|
32
|
+
...c,
|
|
33
|
+
open: haOpen,
|
|
34
|
+
high: haHigh,
|
|
35
|
+
low: haLow,
|
|
36
|
+
close: haClose,
|
|
37
|
+
raw: c,
|
|
38
|
+
});
|
|
39
|
+
prevOpen = haOpen;
|
|
40
|
+
prevClose = haClose;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute discrete Renko bricks from price sequence.
|
|
47
|
+
* @param {Array<{close?: number, value?: number, label?: string, date?: string}>} data
|
|
48
|
+
* @param {number} [boxSize]
|
|
49
|
+
* @returns {Array<{index: number, bottom: number, top: number, isUp: boolean, date: string}>}
|
|
50
|
+
*/
|
|
51
|
+
static computeRenkoBricks(data, boxSize) {
|
|
52
|
+
if (!Array.isArray(data) || !data.length) return [];
|
|
53
|
+
const prices = data.map(d => d.close ?? d.value ?? d.price ?? d.open ?? 0);
|
|
54
|
+
const dates = data.map(d => d.date ?? d.label ?? d.time ?? '');
|
|
55
|
+
|
|
56
|
+
let computedBox = boxSize;
|
|
57
|
+
if (!computedBox || computedBox <= 0) {
|
|
58
|
+
const minP = Math.min(...prices);
|
|
59
|
+
const maxP = Math.max(...prices);
|
|
60
|
+
computedBox = Math.max(0.1, (maxP - minP) / 24);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bricks = [];
|
|
64
|
+
let lastPrice = prices[0];
|
|
65
|
+
let trend = 0; // +1 up, -1 down
|
|
66
|
+
|
|
67
|
+
for (let i = 1; i < prices.length; i++) {
|
|
68
|
+
const p = prices[i];
|
|
69
|
+
const diff = p - lastPrice;
|
|
70
|
+
|
|
71
|
+
if (diff >= computedBox) {
|
|
72
|
+
const count = Math.floor(diff / computedBox);
|
|
73
|
+
for (let k = 0; k < count; k++) {
|
|
74
|
+
const bottom = lastPrice;
|
|
75
|
+
const top = lastPrice + computedBox;
|
|
76
|
+
bricks.push({ bottom, top, isUp: true, date: dates[i] });
|
|
77
|
+
lastPrice = top;
|
|
78
|
+
}
|
|
79
|
+
trend = 1;
|
|
80
|
+
} else if (diff <= -computedBox) {
|
|
81
|
+
const count = Math.floor(-diff / computedBox);
|
|
82
|
+
for (let k = 0; k < count; k++) {
|
|
83
|
+
const top = lastPrice;
|
|
84
|
+
const bottom = lastPrice - computedBox;
|
|
85
|
+
bricks.push({ bottom, top, isUp: false, date: dates[i] });
|
|
86
|
+
lastPrice = bottom;
|
|
87
|
+
}
|
|
88
|
+
trend = -1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return bricks;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_render() {
|
|
95
|
+
const data = this.data;
|
|
96
|
+
if (!Array.isArray(data) || !data.length) return;
|
|
97
|
+
|
|
98
|
+
const options = this.options;
|
|
99
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
100
|
+
const {
|
|
101
|
+
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
102
|
+
title, xAxisLabel, yAxisLabel,
|
|
103
|
+
upColor = '#27ae60', downColor = '#e74c3c',
|
|
104
|
+
boxSize, heikinAshi = false,
|
|
105
|
+
} = options;
|
|
106
|
+
const plotBox = { x, y, width, height };
|
|
107
|
+
|
|
108
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
109
|
+
|
|
110
|
+
// 1. Heikin-Ashi mode: Render smoothed candlestick bars
|
|
111
|
+
if (heikinAshi) {
|
|
112
|
+
const candles = RenkoChart.computeHeikinAshi(data);
|
|
113
|
+
const highPrices = candles.map(c => c.high);
|
|
114
|
+
const lowPrices = candles.map(c => c.low);
|
|
115
|
+
const rawMax = Math.max(...highPrices, 1);
|
|
116
|
+
const rawMin = Math.min(...lowPrices);
|
|
117
|
+
|
|
118
|
+
new Axis().drawYRange(this.renderer, plotBox, rawMin, rawMax);
|
|
119
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: '#999', width: 1 });
|
|
120
|
+
|
|
121
|
+
const n = candles.length;
|
|
122
|
+
const candleSlot = width / n;
|
|
123
|
+
const bodyWidth = Math.max(3, candleSlot * 0.7);
|
|
124
|
+
const toY = (v) => y + height - ((v - rawMin) / (rawMax - rawMin || 1)) * height;
|
|
125
|
+
|
|
126
|
+
candles.forEach((c, i) => {
|
|
127
|
+
const cx = x + i * candleSlot + candleSlot / 2;
|
|
128
|
+
const isBull = c.close >= c.open;
|
|
129
|
+
const color = isBull ? upColor : downColor;
|
|
130
|
+
|
|
131
|
+
// Wicks
|
|
132
|
+
const yHigh = toY(c.high);
|
|
133
|
+
const yLow = toY(c.low);
|
|
134
|
+
this.renderer.path(`M${cx},${yHigh} L${cx},${yLow}`, 'none', { color, width: 1.5 });
|
|
135
|
+
|
|
136
|
+
// Body
|
|
137
|
+
const yTop = toY(Math.max(c.open, c.close));
|
|
138
|
+
const yBottom = toY(Math.min(c.open, c.close));
|
|
139
|
+
const bodyH = Math.max(2, yBottom - yTop);
|
|
140
|
+
const bodyX = cx - bodyWidth / 2;
|
|
141
|
+
|
|
142
|
+
this._datum(this.renderer.rect(bodyX, yTop, bodyWidth, bodyH, color, null, {
|
|
143
|
+
tooltip: `${c.date || c.label || `Bar ${i + 1}`}: Open=${Axis.formatValue(c.open)}, High=${Axis.formatValue(c.high)}, Low=${Axis.formatValue(c.low)}, Close=${Axis.formatValue(c.close)}`,
|
|
144
|
+
}), { label: c.date || c.label, value: c.close });
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 2. Renko Brick mode
|
|
150
|
+
const bricks = RenkoChart.computeRenkoBricks(data, boxSize);
|
|
151
|
+
if (!bricks.length) return;
|
|
152
|
+
|
|
153
|
+
const allBottoms = bricks.map(b => b.bottom);
|
|
154
|
+
const allTops = bricks.map(b => b.top);
|
|
155
|
+
const rawMin = Math.min(...allBottoms);
|
|
156
|
+
const rawMax = Math.max(...allTops, 1);
|
|
157
|
+
|
|
158
|
+
new Axis().drawYRange(this.renderer, plotBox, rawMin, rawMax);
|
|
159
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: '#999', width: 1 });
|
|
160
|
+
|
|
161
|
+
const toY = (v) => y + height - ((v - rawMin) / (rawMax - rawMin || 1)) * height;
|
|
162
|
+
const n = bricks.length;
|
|
163
|
+
const brickSlot = width / n;
|
|
164
|
+
const brickW = Math.max(4, brickSlot * 0.85);
|
|
165
|
+
|
|
166
|
+
bricks.forEach((b, i) => {
|
|
167
|
+
const bx = x + i * brickSlot + (brickSlot - brickW) / 2;
|
|
168
|
+
const byTop = toY(b.top);
|
|
169
|
+
const byBottom = toY(b.bottom);
|
|
170
|
+
const bh = Math.max(2, byBottom - byTop);
|
|
171
|
+
const color = b.isUp ? upColor : downColor;
|
|
172
|
+
|
|
173
|
+
this._datum(this.renderer.rect(bx, byTop, brickW, bh, color, { color: '#ffffff', width: 1 }, {
|
|
174
|
+
tooltip: `${b.date || `Brick ${i + 1}`}: ${b.isUp ? 'Up' : 'Down'} [${Axis.formatValue(b.bottom)} - ${Axis.formatValue(b.top)}]`,
|
|
175
|
+
}), { label: b.date || `Brick ${i + 1}`, value: b.top });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Axis } from '../Axis.js';
|
|
2
|
+
import { Statistics } from '../Statistics.js';
|
|
3
|
+
import { GroupedDistributionChart } from './GroupedDistributionChart.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RidgelineChart (Joy Plot) — vertically staggered, partially overlapping
|
|
7
|
+
* Gaussian-KDE density curves (`Array<{label, values: number[]}>`, the same
|
|
8
|
+
* data contract as `BoxPlotChart`, `DensityChart`, and `ViolinChart`).
|
|
9
|
+
*
|
|
10
|
+
* Reuses `GroupedDistributionChart` for cartesian setup and value-domain math
|
|
11
|
+
* and `Statistics.kernelDensityEstimate` for smooth density estimation.
|
|
12
|
+
*/
|
|
13
|
+
export class RidgelineChart extends GroupedDistributionChart {
|
|
14
|
+
static chartType = 'ridgeline';
|
|
15
|
+
|
|
16
|
+
_render() {
|
|
17
|
+
if (!Array.isArray(this.data) || !this.data.length) return;
|
|
18
|
+
this._setup();
|
|
19
|
+
this._domain = this._computeDomain();
|
|
20
|
+
|
|
21
|
+
const options = this.options;
|
|
22
|
+
const {
|
|
23
|
+
overlap = 0.65,
|
|
24
|
+
fillOpacity = 0.74,
|
|
25
|
+
showMedian = true,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
const data = this.data;
|
|
29
|
+
const n = data.length;
|
|
30
|
+
const leftPad = 56;
|
|
31
|
+
const plotX = this._plotBox.x + leftPad;
|
|
32
|
+
const plotW = Math.max(40, this._plotBox.width - leftPad);
|
|
33
|
+
const plotY = this._plotBox.y;
|
|
34
|
+
const plotH = this._plotBox.height;
|
|
35
|
+
|
|
36
|
+
// Allocate vertical lanes with headroom for the top ridge's peak
|
|
37
|
+
const rowStep = plotH / (n + overlap);
|
|
38
|
+
const maxPeakH = rowStep * (1 + Math.max(0, overlap));
|
|
39
|
+
const { vMin, vMax, range } = this._domain;
|
|
40
|
+
const toX = v => plotX + ((v - vMin) / range) * plotW;
|
|
41
|
+
|
|
42
|
+
// Draw bottom X-axis and nice ticks
|
|
43
|
+
this.renderer.path(`M${plotX},${plotY + plotH} L${plotX + plotW},${plotY + plotH}`, 'none', {
|
|
44
|
+
color: 'black', width: 1,
|
|
45
|
+
});
|
|
46
|
+
const ticks = Axis.niceTicks(range, 5).map(t => vMin + t).filter(t => t <= vMax + 1e-6);
|
|
47
|
+
for (const tick of ticks) {
|
|
48
|
+
const tx = toX(tick);
|
|
49
|
+
this.renderer.path(`M${tx},${plotY} L${tx},${plotY + plotH}`, 'none', {
|
|
50
|
+
color: '#eee', width: 1, dasharray: '3,3',
|
|
51
|
+
});
|
|
52
|
+
this.renderer.text(Axis.formatValue(tick), tx, plotY + plotH + 16, {
|
|
53
|
+
textAnchor: this.renderer.centerTextAnchor, fontSize: '10px', fill: '#666',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Render from top row (i=0) to bottom row (i=n-1) so lower ridges
|
|
58
|
+
// naturally overlap in front of upper ridges (the classic Joy Plot look).
|
|
59
|
+
data.forEach((d, i) => {
|
|
60
|
+
const values = Array.isArray(d.values) ? d.values : [];
|
|
61
|
+
if (!values.length) return;
|
|
62
|
+
const baselineY = plotY + (i + 1 + overlap * 0.35) * rowStep;
|
|
63
|
+
const clampedBaselineY = Math.min(plotY + plotH, baselineY);
|
|
64
|
+
const color = this._palette.colorAt(i);
|
|
65
|
+
const curve = Statistics.kernelDensityEstimate(values, { min: vMin, max: vMax });
|
|
66
|
+
const maxDensity = Math.max(...curve.map(p => p.density)) || 1;
|
|
67
|
+
|
|
68
|
+
const pts = curve.map(p => ({
|
|
69
|
+
px: toX(p.x),
|
|
70
|
+
py: clampedBaselineY - (p.density / maxDensity) * maxPeakH,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
74
|
+
const median = Statistics.quantile(sorted, 0.5);
|
|
75
|
+
const tooltip = `${d.label}: n=${values.length}, median=${Axis.formatValue(median)}`;
|
|
76
|
+
|
|
77
|
+
// Baseline guide + category label on left
|
|
78
|
+
this.renderer.path(`M${plotX},${clampedBaselineY} L${plotX + plotW},${clampedBaselineY}`, 'none', {
|
|
79
|
+
color: '#ddd', width: 1,
|
|
80
|
+
});
|
|
81
|
+
this.renderer.text(d.label, plotX - 8, clampedBaselineY - 2, {
|
|
82
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fontWeight: '600', fill: '#333',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Closed area path + crisp upper ridge stroke
|
|
86
|
+
const areaPath = `M${pts[0].px},${clampedBaselineY} L`
|
|
87
|
+
+ pts.map(p => `${p.px},${p.py}`).join(' L')
|
|
88
|
+
+ ` L${pts[pts.length - 1].px},${clampedBaselineY} Z`;
|
|
89
|
+
const ridgePath = 'M' + pts.map(p => `${p.px},${p.py}`).join(' L');
|
|
90
|
+
|
|
91
|
+
const areaEl = this._datum(this.renderer.path(areaPath, color, null, {
|
|
92
|
+
'fill-opacity': fillOpacity, tooltip,
|
|
93
|
+
}), { label: d.label, series: d.label, value: median });
|
|
94
|
+
const strokeEl = this.renderer.path(ridgePath, 'none', {
|
|
95
|
+
color: this._palette.darken(color, 0.22), width: 2,
|
|
96
|
+
}, { tooltip });
|
|
97
|
+
|
|
98
|
+
const groupEls = [areaEl, strokeEl];
|
|
99
|
+
if (showMedian) {
|
|
100
|
+
const mx = toX(median);
|
|
101
|
+
// Interpolate density at median for median tick height
|
|
102
|
+
const nearest = curve.reduce((best, p) => Math.abs(p.x - median) < Math.abs(best.x - median) ? p : best, curve[0]);
|
|
103
|
+
const myTop = clampedBaselineY - ((nearest?.density ?? maxDensity * 0.5) / maxDensity) * maxPeakH;
|
|
104
|
+
const medLine = this.renderer.path(`M${mx},${clampedBaselineY} L${mx},${myTop}`, 'none', {
|
|
105
|
+
color: '#ffffff', width: 1.8, dasharray: '3,2',
|
|
106
|
+
});
|
|
107
|
+
groupEls.push(medLine);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._wireHoverTransform(groupEls, { dx: 0, dy: -4 });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|