@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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. 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
- this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
63
- tooltip: `${s.name} — ${s.values[i].axis}: ${s.values[i].value}`,
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) => ({ name: s.name, color: palette.colorAt(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
+ }