@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,164 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Statistics } from '../Statistics.js';
3
+ import { GroupedDistributionChart } from './GroupedDistributionChart.js';
4
+
5
+ /**
6
+ * BeeswarmChart — individual sample points deterministically force/circle-packed
7
+ * along each group's vertical slot (`Array<{label, values: number[]}>`) so every
8
+ * observation is visible without random jitter.
9
+ *
10
+ * When `raincloud: true` (default `true` unless disabled via `raincloud: false`),
11
+ * combines:
12
+ * 1. A right-half Gaussian KDE "cloud" silhouette,
13
+ * 2. A compact median + IQR mini-boxplot on the slot axis, and
14
+ * 3. The packed beeswarm "rain" dots on the left.
15
+ */
16
+ export class BeeswarmChart extends GroupedDistributionChart {
17
+ static chartType = 'beeswarm';
18
+
19
+ /**
20
+ * Deterministic 1D circle-packing horizontal offsets for `values` mapped
21
+ * through `toY(v)` with dot radius `r`, bounded to `[-maxOffset, +maxOffset]`.
22
+ * @returns {Array<{value: number, py: number, dx: number}>}
23
+ */
24
+ static packOffsets(values, toY, r = 4, maxOffset = 28) {
25
+ const indexed = values.map((v, idx) => ({ value: v, py: toY(v), idx, dx: 0 }));
26
+ indexed.sort((a, b) => a.py - b.py);
27
+ const minDist = r * 2.05;
28
+ const minDistSq = minDist * minDist;
29
+ const placed = [];
30
+
31
+ for (const item of indexed) {
32
+ // Find candidate dx with minimal |dx| that does not collide with nearby placed dots
33
+ let bestDx = 0;
34
+ const nearby = placed.filter(p => Math.abs(p.py - item.py) < minDist);
35
+ if (nearby.length > 0) {
36
+ const step = Math.max(1.5, r * 0.65);
37
+ for (let mag = 0; mag <= maxOffset; mag += step) {
38
+ const candidates = mag === 0 ? [0] : (placed.length % 2 === 0 ? [mag, -mag] : [-mag, mag]);
39
+ let found = false;
40
+ for (const cand of candidates) {
41
+ const collides = nearby.some(p => {
42
+ const dy = p.py - item.py;
43
+ const ddx = p.dx - cand;
44
+ return ddx * ddx + dy * dy < minDistSq;
45
+ });
46
+ if (!collides) {
47
+ bestDx = cand;
48
+ found = true;
49
+ break;
50
+ }
51
+ }
52
+ if (found) break;
53
+ bestDx = (placed.length % 2 === 0 ? 1 : -1) * Math.min(maxOffset, mag);
54
+ }
55
+ }
56
+ item.dx = bestDx;
57
+ placed.push(item);
58
+ }
59
+ return placed;
60
+ }
61
+
62
+ _render() {
63
+ if (!Array.isArray(this.data) || !this.data.length) return;
64
+ this._setup();
65
+ this._domain = this._computeDomain();
66
+ this._drawBaseAxis();
67
+
68
+ // Draw Y-axis ticks for readability
69
+ const { x, y, width, height } = this._plotBox;
70
+ const { vMin, vMax, range } = this._domain;
71
+ const ticks = Axis.niceTicks(range, 5).map(t => vMin + t).filter(t => t <= vMax + 1e-6);
72
+ for (const tick of ticks) {
73
+ const ty = this._toY(tick);
74
+ this.renderer.path(`M${x},${ty} L${x + width},${ty}`, 'none', { color: '#eee', width: 1, dasharray: '3,3' });
75
+ this.renderer.text(Axis.formatValue(tick), x - 6, ty, {
76
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
77
+ });
78
+ }
79
+
80
+ const slotWidth = this._slotWidth();
81
+ this.data.forEach((d, i) => {
82
+ const cx = this._plotBox.x + i * slotWidth + slotWidth / 2;
83
+ this._renderGlyph(cx, slotWidth, i, d);
84
+ this._drawXAxisLabel(cx, d.label);
85
+ });
86
+ }
87
+
88
+ _renderGlyph(cx, slotWidth, i, d) {
89
+ const values = Array.isArray(d.values) ? d.values : [];
90
+ if (!values.length) return;
91
+
92
+ const color = this._palette.colorAt(i);
93
+ const darkColor = this._palette.darken(color, 0.25);
94
+ const {
95
+ raincloud = true,
96
+ dotRadius = 4,
97
+ } = this.options;
98
+
99
+ const sorted = [...values].sort((a, b) => a - b);
100
+ const q1 = Statistics.quantile(sorted, 0.25);
101
+ const median = Statistics.quantile(sorted, 0.5);
102
+ const q3 = Statistics.quantile(sorted, 0.75);
103
+ const halfSlot = slotWidth * 0.42;
104
+
105
+ if (raincloud) {
106
+ // 1. Right-half KDE density "cloud"
107
+ const curve = Statistics.kernelDensityEstimate(values, { min: this._domain.vMin, max: this._domain.vMax });
108
+ const maxDensity = Math.max(...curve.map(p => p.density)) || 1;
109
+ const cloudWidth = halfSlot * 0.85;
110
+ const axisX = cx + 2;
111
+ const rightPts = curve.map(p => ({
112
+ px: axisX + (p.density / maxDensity) * cloudWidth,
113
+ py: this._toY(p.x),
114
+ }));
115
+ const cloudPath = `M${axisX},${rightPts[0].py} L`
116
+ + rightPts.map(p => `${p.px},${p.py}`).join(' L')
117
+ + ` L${axisX},${rightPts[rightPts.length - 1].py} Z`;
118
+ const cloudEl = this._datum(this.renderer.path(cloudPath, color, { color: darkColor, width: 1.2 }, {
119
+ 'fill-opacity': 0.55,
120
+ tooltip: `${d.label}: n=${values.length}, median=${Axis.formatValue(median)} (IQR ${Axis.formatValue(q1)}–${Axis.formatValue(q3)})`,
121
+ }), { label: d.label, series: d.label, value: median });
122
+ this._wireHoverTransform([cloudEl], { dx: 2, dy: -2 });
123
+
124
+ // 2. Compact IQR mini-box + median dot at cx
125
+ const yQ1 = this._toY(q1);
126
+ const yQ3 = this._toY(q3);
127
+ const yMed = this._toY(median);
128
+ this.renderer.rect(cx - 3, Math.min(yQ1, yQ3), 6, Math.max(2, Math.abs(yQ1 - yQ3)), darkColor, null, {
129
+ rx: 2, ry: 2,
130
+ });
131
+ this.renderer.circle(cx, yMed, 2.5, '#ffffff', { color: darkColor, width: 1 });
132
+
133
+ // 3. Packed beeswarm "rain" dots on the left of cx
134
+ const maxRainSpan = Math.max(8, halfSlot * 0.75);
135
+ const packed = BeeswarmChart.packOffsets(values, v => this._toY(v), dotRadius, maxRainSpan * 0.5);
136
+ const swarmCenterX = cx - halfSlot * 0.45;
137
+ for (const pt of packed) {
138
+ const dotX = swarmCenterX + pt.dx;
139
+ const dotEl = this._datum(this.renderer.circle(dotX, pt.py, dotRadius, color, {
140
+ color: '#ffffff', width: 1,
141
+ }, {
142
+ tooltip: `${d.label}: ${Axis.formatValue(pt.value)}`,
143
+ }), { label: d.label, series: d.label, value: pt.value });
144
+ this._wireHoverTransform([dotEl], { dx: 0, dy: -2 });
145
+ }
146
+ } else {
147
+ // Pure symmetrical Beeswarm around cx + median crossbar
148
+ const maxSpan = Math.max(10, halfSlot * 0.85);
149
+ const packed = BeeswarmChart.packOffsets(values, v => this._toY(v), dotRadius, maxSpan);
150
+ const yMed = this._toY(median);
151
+ this.renderer.path(`M${cx - maxSpan * 0.7},${yMed} L${cx + maxSpan * 0.7},${yMed}`, 'none', {
152
+ color: darkColor, width: 2, dasharray: '4,2',
153
+ });
154
+ for (const pt of packed) {
155
+ const dotEl = this._datum(this.renderer.circle(cx + pt.dx, pt.py, dotRadius, color, {
156
+ color: '#ffffff', width: 1,
157
+ }, {
158
+ tooltip: `${d.label}: ${Axis.formatValue(pt.value)}`,
159
+ }), { label: d.label, series: d.label, value: pt.value });
160
+ this._wireHoverTransform([dotEl], { dx: 0, dy: -2 });
161
+ }
162
+ }
163
+ }
164
+ }
@@ -23,16 +23,38 @@ export class BoxPlotChart extends GroupedDistributionChart {
23
23
 
24
24
  _renderGlyph(cx, slotWidth, i, d) {
25
25
  const s = Statistics.boxPlotStats(d.values);
26
- const boxWidth = slotWidth * 0.5;
27
26
  const boxColor = this.options.boxColor ?? this._palette.colorAt(0);
28
27
 
28
+ if (this._isHorizontal()) {
29
+ const cy = cx;
30
+ const slotHeight = slotWidth;
31
+ const boxHeight = slotHeight * 0.5;
32
+ const xMin = this._toX(s.min), xMax = this._toX(s.max);
33
+ const xQ1 = this._toX(s.q1), xQ3 = this._toX(s.q3);
34
+ const xMed = this._toX(s.median);
35
+
36
+ this.renderer.path(`M${xMin},${cy} L${xMax},${cy}`, 'none', { color: '#555', width: 1 });
37
+ this.renderer.path(`M${xMin},${cy - boxHeight / 4} L${xMin},${cy + boxHeight / 4}`, 'none', { color: '#555', width: 1 });
38
+ this.renderer.path(`M${xMax},${cy - boxHeight / 4} L${xMax},${cy + boxHeight / 4}`, 'none', { color: '#555', width: 1 });
39
+ this._datum(this.renderer.rect(Math.min(xQ1, xQ3), cy - boxHeight / 2, Math.max(1, Math.abs(xQ3 - xQ1)), boxHeight, boxColor, { color: '#333', width: 1 }, {
40
+ tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
41
+ }), { label: d.label, series: d.label });
42
+ this.renderer.path(`M${xMed},${cy - boxHeight / 2} L${xMed},${cy + boxHeight / 2}`, 'none', { color: '#fff', width: 2 });
43
+ s.outliers.forEach(v => {
44
+ this.renderer.circle(this._toX(v), cy, 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
45
+ });
46
+ return;
47
+ }
48
+
49
+ const boxWidth = slotWidth * 0.5;
50
+
29
51
  this.renderer.path(`M${cx},${this._toY(s.min)} L${cx},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
30
52
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
31
53
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
32
54
  const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
33
- this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
55
+ this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
34
56
  tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
35
- });
57
+ }), { label: d.label, series: d.label });
36
58
  this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
37
59
  s.outliers.forEach(v => {
38
60
  this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
@@ -83,7 +83,10 @@ export class BubbleChart extends Chart {
83
83
  // 3D effect: draw a line to the "floor"
84
84
  this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
85
85
  }
86
- this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` });
86
+ const series = catField ? ChartData.readColorCol(d, catField) : null;
87
+ this._datum(this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` }), {
88
+ label: d.label ?? String(d.x), series: series != null ? String(series) : null,
89
+ });
87
90
  });
88
91
 
89
92
  // Category color legend
@@ -72,6 +72,7 @@ export class CalendarHeatmapChart extends Chart {
72
72
  cellSize,
73
73
  cellGap = 2,
74
74
  showValues = false,
75
+ midValue,
75
76
  title,
76
77
  monthLabels = true,
77
78
  weekdayLabels = true,
@@ -179,7 +180,7 @@ export class CalendarHeatmapChart extends Chart {
179
180
  const cx = gridX + week * step;
180
181
  const cy = gridY + day * step;
181
182
  const color = hasValue
182
- ? ColorPalette.scaleColor(colorScale, vMin, vMax, value)
183
+ ? ColorPalette.scaleColor(colorScale, vMin, vMax, value, midValue)
183
184
  : emptyColor;
184
185
 
185
186
  this.renderer.rect(cx, cy, size, size, color, null, {
@@ -5,32 +5,140 @@ import { ChartBox, ChartLabels } from '../ChartBox.js';
5
5
  /**
6
6
  * CandlestickChart — OHLC financial chart. Each entry draws a vertical wick
7
7
  * from low to high and a rectangular body from open to close.
8
+ *
9
+ * Supported options:
10
+ * - volume: boolean (shows volume sub-pane if data has .volume, or if volume: true)
11
+ * - sma: number | number[] | Array<{period: number, color?: string}>
12
+ * - ema: number | number[] | Array<{period: number, color?: string}>
13
+ * - bollinger: boolean | { period?: number, stdDev?: number, color?: string, bandColor?: string }
8
14
  */
9
15
  export class CandlestickChart extends Chart {
10
16
  static chartType = 'candlestick';
11
17
 
18
+ static computeSMA(data, period) {
19
+ const result = new Array(data.length).fill(null);
20
+ let sum = 0;
21
+ for (let i = 0; i < data.length; i++) {
22
+ sum += data[i].close;
23
+ if (i >= period) {
24
+ sum -= data[i - period].close;
25
+ }
26
+ if (i >= period - 1) {
27
+ result[i] = sum / period;
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+
33
+ static computeEMA(data, period) {
34
+ const result = new Array(data.length).fill(null);
35
+ if (data.length < period) return result;
36
+ const k = 2 / (period + 1);
37
+ let sum = 0;
38
+ for (let i = 0; i < period; i++) sum += data[i].close;
39
+ let prevEma = sum / period;
40
+ result[period - 1] = prevEma;
41
+ for (let i = period; i < data.length; i++) {
42
+ prevEma = data[i].close * k + prevEma * (1 - k);
43
+ result[i] = prevEma;
44
+ }
45
+ return result;
46
+ }
47
+
48
+ static computeBollinger(data, period = 20, stdDev = 2) {
49
+ const sma = CandlestickChart.computeSMA(data, period);
50
+ const upper = new Array(data.length).fill(null);
51
+ const lower = new Array(data.length).fill(null);
52
+ for (let i = period - 1; i < data.length; i++) {
53
+ const mean = sma[i];
54
+ let sqDiffSum = 0;
55
+ for (let j = i - period + 1; j <= i; j++) {
56
+ sqDiffSum += Math.pow(data[j].close - mean, 2);
57
+ }
58
+ const sd = Math.sqrt(sqDiffSum / period);
59
+ upper[i] = mean + stdDev * sd;
60
+ lower[i] = mean - stdDev * sd;
61
+ }
62
+ return { middle: sma, upper, lower };
63
+ }
64
+
12
65
  _render() {
13
66
  const data = this.data;
14
- const options = this.options;
67
+ const options = this.options || {};
15
68
  const box = ChartBox.fromRenderer(this.renderer);
16
69
  const {
17
70
  x = box.x, y = box.y, width = box.width, height = box.height,
18
71
  upColor = '#26a69a', downColor = '#ef5350', title, xAxisLabel, yAxisLabel,
19
72
  markers = [],
73
+ volume = (data.some(d => d.volume != null) && options.volume !== false),
74
+ sma, ema, bollinger,
20
75
  } = options;
21
76
 
77
+ if (!data || !data.length) return;
78
+
79
+ const hasVolume = Boolean(volume && data.some(d => d.volume != null));
80
+ const priceHeight = hasVolume ? height * 0.72 : height;
81
+ const volGap = hasVolume ? height * 0.06 : 0;
82
+ const volHeight = hasVolume ? height - priceHeight - volGap : 0;
83
+ const volY = y + priceHeight + volGap;
84
+
22
85
  const vMax = Math.max(...data.map(d => d.high));
23
86
  const vMin = Math.min(...data.map(d => d.low));
24
87
  const range = (vMax - vMin) || 1;
25
88
  const slotWidth = width / data.length;
26
89
  const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width, { fontSize: 10 });
27
90
  const bodyWidth = slotWidth * 0.6;
28
- const toY = (v) => y + height - ((v - vMin) / range) * height;
91
+ const toY = (v) => y + priceHeight - ((v - vMin) / range) * priceHeight;
29
92
  const textAnchor = this.renderer.centerTextAnchor;
30
93
 
31
94
  new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, { x, y, width, height });
32
- this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
33
95
 
96
+ // Axes boundaries
97
+ this.renderer.path(`M${x},${y} L${x},${y + priceHeight} L${x + width},${y + priceHeight}`, 'none', { color: 'black', width: 1 });
98
+
99
+ // 1. Technical indicator: Bollinger Bands background
100
+ if (bollinger) {
101
+ const bOpts = typeof bollinger === 'object' ? bollinger : {};
102
+ const { period = 20, stdDev = 2, bandColor = 'rgba(33, 150, 243, 0.12)', strokeColor = '#2196f3' } = bOpts;
103
+ const bb = CandlestickChart.computeBollinger(data, period, stdDev);
104
+
105
+ let bandPath = '';
106
+ const validIndices = [];
107
+ for (let i = 0; i < data.length; i++) {
108
+ if (bb.upper[i] != null && bb.lower[i] != null) validIndices.push(i);
109
+ }
110
+
111
+ if (validIndices.length > 1) {
112
+ // Top curve
113
+ validIndices.forEach((idx, i) => {
114
+ const cx = x + idx * slotWidth + slotWidth / 2;
115
+ const cy = toY(bb.upper[idx]);
116
+ bandPath += (i === 0 ? `M${cx},${cy} ` : `L${cx},${cy} `);
117
+ });
118
+ // Bottom curve back
119
+ for (let i = validIndices.length - 1; i >= 0; i--) {
120
+ const idx = validIndices[i];
121
+ const cx = x + idx * slotWidth + slotWidth / 2;
122
+ const cy = toY(bb.lower[idx]);
123
+ bandPath += `L${cx},${cy} `;
124
+ }
125
+ bandPath += 'Z';
126
+ this.renderer.path(bandPath, bandColor, { color: 'none' });
127
+
128
+ // Bollinger middle line
129
+ let midPoints = [];
130
+ validIndices.forEach(idx => {
131
+ const cx = x + idx * slotWidth + slotWidth / 2;
132
+ midPoints.push([cx, toY(bb.middle[idx])]);
133
+ });
134
+ if (midPoints.length > 1) {
135
+ const lineData = midPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${p[1]}`).join(' ');
136
+ this.renderer.path(lineData, 'none', { color: strokeColor, width: 1, strokeDasharray: '3 3' });
137
+ }
138
+ }
139
+ }
140
+
141
+ // 2. Candlestick wicks & bodies
34
142
  data.forEach((d, i) => {
35
143
  const cx = x + i * slotWidth + slotWidth / 2;
36
144
  const isUp = d.close >= d.open;
@@ -39,13 +147,73 @@ export class CandlestickChart extends Chart {
39
147
  const bodyTop = toY(Math.max(d.open, d.close));
40
148
  const bodyBottom = toY(Math.min(d.open, d.close));
41
149
  this.renderer.rect(cx - bodyWidth / 2, bodyTop, bodyWidth, Math.max(1, bodyBottom - bodyTop), color, null, {
42
- tooltip: `${d.label}: O ${d.open} H ${d.high} L ${d.low} C ${d.close}`,
150
+ tooltip: `${d.label}: O ${d.open} H ${d.high} L ${d.low} C ${d.close}${d.volume ? ` Vol ${d.volume}` : ''}`,
43
151
  });
44
152
  if (i % labelStep === 0) {
45
153
  this.renderer.text(d.label, cx, y + height + 20, { textAnchor, fontSize: '10px' });
46
154
  }
47
155
  });
48
156
 
157
+ // 3. Technical indicators: SMA & EMA overlays
158
+ const drawIndicatorLine = (values, lineColor) => {
159
+ let path = '';
160
+ let started = false;
161
+ values.forEach((v, idx) => {
162
+ if (v == null) return;
163
+ const cx = x + idx * slotWidth + slotWidth / 2;
164
+ const cy = toY(v);
165
+ path += (!started ? `M${cx},${cy} ` : `L${cx},${cy} `);
166
+ started = true;
167
+ });
168
+ if (path) this.renderer.path(path.trim(), 'none', { color: lineColor, width: 1.5 });
169
+ };
170
+
171
+ if (sma) {
172
+ const smaList = Array.isArray(sma) ? sma : [sma];
173
+ const smaColors = ['#e67e22', '#9b59b6', '#3498db'];
174
+ smaList.forEach((spec, sIdx) => {
175
+ const period = typeof spec === 'object' ? spec.period : spec;
176
+ const color = (typeof spec === 'object' && spec.color) ? spec.color : smaColors[sIdx % smaColors.length];
177
+ const vals = CandlestickChart.computeSMA(data, period);
178
+ drawIndicatorLine(vals, color);
179
+ });
180
+ }
181
+
182
+ if (ema) {
183
+ const emaList = Array.isArray(ema) ? ema : [ema];
184
+ const emaColors = ['#1abc9c', '#f1c40f', '#e91e63'];
185
+ emaList.forEach((spec, eIdx) => {
186
+ const period = typeof spec === 'object' ? spec.period : spec;
187
+ const color = (typeof spec === 'object' && spec.color) ? spec.color : emaColors[eIdx % emaColors.length];
188
+ const vals = CandlestickChart.computeEMA(data, period);
189
+ drawIndicatorLine(vals, color);
190
+ });
191
+ }
192
+
193
+ // 4. Volume sub-pane
194
+ if (hasVolume) {
195
+ this.renderer.path(`M${x},${volY} L${x},${volY + volHeight} L${x + width},${volY + volHeight}`, 'none', { color: '#aaa', width: 1 });
196
+ const maxVol = Math.max(...data.map(d => d.volume || 0)) || 1;
197
+
198
+ data.forEach((d, i) => {
199
+ if (!d.volume) return;
200
+ const cx = x + i * slotWidth + slotWidth / 2;
201
+ const barH = (d.volume / maxVol) * volHeight;
202
+ const barTop = volY + volHeight - barH;
203
+ const isUp = d.close >= d.open;
204
+ const barColor = isUp ? upColor : downColor;
205
+ this.renderer.rect(cx - bodyWidth / 2, barTop, bodyWidth, Math.max(1, barH), barColor, null, {
206
+ opacity: 0.7,
207
+ tooltip: `${d.label} Volume: ${Axis.formatValue(d.volume)}`,
208
+ });
209
+ });
210
+
211
+ // Volume axis label
212
+ this.renderer.text('Vol', x - 6, volY + 10, { textAnchor: 'end', fontSize: '9px', fill: '#666' });
213
+ this.renderer.text(Axis.formatValue(maxVol), x - 6, volY + volHeight, { textAnchor: 'end', fontSize: '9px', fill: '#888' });
214
+ }
215
+
216
+ // 5. Markers
49
217
  markers.forEach((m) => {
50
218
  const idx = data.findIndex(d => d.label === m.label);
51
219
  if (idx < 0) return;
@@ -80,6 +80,7 @@ export class ChordDiagram extends Chart {
80
80
  // Draw chords first so they sit under the node rim.
81
81
  const maxLink = Math.max(...links.map(l => l.value));
82
82
  const maxStroke = radius * 0.08;
83
+ const chordRecords = [];
83
84
  links.forEach(l => {
84
85
  const s = nodeMap.get(l.source);
85
86
  const t = nodeMap.get(l.target);
@@ -92,19 +93,22 @@ export class ChordDiagram extends Chart {
92
93
  const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
93
94
  const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
94
95
  const width = Math.max(1, (l.value / maxLink) * maxStroke);
95
- this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
96
+ const el = this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
96
97
  color: s.color, width,
97
98
  }, {
98
99
  tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
99
- });
100
+ }), { label: s.label, series: t.label });
101
+ if (el?.style) el.style.opacity = '0.72';
102
+ chordRecords.push({ el, source: s, target: t });
100
103
  });
101
104
 
102
105
  // Draw node arcs.
106
+ const arcRecords = [];
103
107
  nodes.forEach(n => {
104
108
  const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
105
- this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
109
+ const arcEl = this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
106
110
  tooltip: `${n.label}: ${n.value}`,
107
- });
111
+ }), { label: n.label });
108
112
 
109
113
  // Label outside the circle.
110
114
  const lx = cx + (radius + 16) * Math.cos(n.mid);
@@ -115,7 +119,106 @@ export class ChordDiagram extends Chart {
115
119
  textAnchor: 'middle', fontSize: '10px', fill: '#333',
116
120
  transform: `rotate(${rotate - 90},${lx},${ly})`,
117
121
  });
122
+ arcRecords.push({ el: arcEl, node: n });
118
123
  });
124
+
125
+ this._wireChordHover(arcRecords, chordRecords, { cx, cy });
126
+ }
127
+
128
+ _wireChordHover(arcRecords, chordRecords, center = {}) {
129
+ const { cx = 0, cy = 0 } = center;
130
+ if (this.renderer?.supportsSceneGraph !== true || this.options?.hoverEffect === false) return;
131
+ const resetAll = () => {
132
+ for (const c of chordRecords) {
133
+ if (c.el?.style) {
134
+ c.el.style.opacity = '0.72';
135
+ c.el.style.filter = '';
136
+ }
137
+ }
138
+ for (const a of arcRecords) {
139
+ if (a.el?.style) {
140
+ a.el.style.opacity = '1';
141
+ a.el.style.transform = 'translate(0px, 0px)';
142
+ a.el.style.filter = '';
143
+ }
144
+ }
145
+ };
146
+
147
+ for (const aRec of arcRecords) {
148
+ if (typeof aRec.el?.addEventListener !== 'function') continue;
149
+ aRec.el._hoverWired = true;
150
+ const dx = (Math.cos(aRec.node.mid) * 8).toFixed(2);
151
+ const dy = (Math.sin(aRec.node.mid) * 8).toFixed(2);
152
+ if (aRec.el.style) {
153
+ aRec.el.style.transition = 'transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease, filter 0.2s ease';
154
+ aRec.el.style.cursor = 'pointer';
155
+ }
156
+
157
+ if (this.options?.draggable !== false) {
158
+ aRec.el.addEventListener('pointerdown', (e) => {
159
+ if (e.button !== 0) return;
160
+ e.preventDefault();
161
+ const onMove = (me) => {
162
+ const rect = this.renderer.container?.getBoundingClientRect?.() || { left: 0, top: 0 };
163
+ const mx = me.clientX - rect.left;
164
+ const my = me.clientY - rect.top;
165
+ const angle = Math.atan2(my - cy, mx - cx);
166
+ this.options?.onNodeDrag?.(aRec.node.id, angle);
167
+ };
168
+ const onUp = () => {
169
+ window.removeEventListener('pointermove', onMove);
170
+ window.removeEventListener('pointerup', onUp);
171
+ };
172
+ window.addEventListener('pointermove', onMove);
173
+ window.addEventListener('pointerup', onUp);
174
+ });
175
+ }
176
+ aRec.el.addEventListener('mouseenter', () => {
177
+ const connected = new Set([aRec.node]);
178
+ for (const c of chordRecords) {
179
+ const hit = c.source === aRec.node || c.target === aRec.node;
180
+ if (hit) {
181
+ connected.add(c.source);
182
+ connected.add(c.target);
183
+ }
184
+ if (c.el?.style) {
185
+ c.el.style.opacity = hit ? '0.96' : '0.12';
186
+ c.el.style.filter = hit ? 'drop-shadow(0 2px 5px rgba(0,0,0,0.22))' : '';
187
+ }
188
+ }
189
+ for (const other of arcRecords) {
190
+ if (!other.el?.style) continue;
191
+ other.el.style.opacity = connected.has(other.node) ? '1' : '0.4';
192
+ }
193
+ if (aRec.el.style) {
194
+ aRec.el.style.transform = `translate(${dx}px, ${dy}px)`;
195
+ aRec.el.style.filter = 'drop-shadow(0 4px 7px rgba(0,0,0,0.24))';
196
+ }
197
+ });
198
+ aRec.el.addEventListener('mouseleave', resetAll);
199
+ }
200
+
201
+ for (const cRec of chordRecords) {
202
+ if (typeof cRec.el?.addEventListener !== 'function') continue;
203
+ cRec.el._hoverWired = true;
204
+ if (cRec.el.style) {
205
+ cRec.el.style.transition = 'opacity 0.2s ease, filter 0.2s ease';
206
+ cRec.el.style.cursor = 'pointer';
207
+ }
208
+ cRec.el.addEventListener('mouseenter', () => {
209
+ for (const other of chordRecords) {
210
+ if (!other.el?.style) continue;
211
+ other.el.style.opacity = other === cRec ? '0.98' : '0.12';
212
+ other.el.style.filter = other === cRec ? 'drop-shadow(0 2px 6px rgba(0,0,0,0.25))' : '';
213
+ }
214
+ for (const aRec of arcRecords) {
215
+ if (!aRec.el?.style) continue;
216
+ const hit = aRec.node === cRec.source || aRec.node === cRec.target;
217
+ aRec.el.style.opacity = hit ? '1' : '0.4';
218
+ }
219
+ });
220
+ cRec.el.addEventListener('mouseleave', resetAll);
221
+ }
119
222
  }
120
223
 
121
224
  _arcPath(cx, cy, rOuter, rInner, a0, a1) {