@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
@@ -4,11 +4,174 @@ import { ColorPalette } from '../ColorPalette.js';
4
4
  import { ShapePaths } from '../ShapePaths.js';
5
5
 
6
6
  /**
7
- * PieChart — pie or doughnut chart with optional legend.
7
+ * PieChart — pie or doughnut chart with optional legend, exploded slices,
8
+ * interactive hover slice pull-out (`hoverExplode`), callout leader lines
9
+ * (`showLabels`), `"Other"` slice roll-up (`maxSlices`), and center KPI label
10
+ * (`centerLabel` / `showTotal` when `innerRadiusRatio > 0`).
11
+ *
12
+ * Also serves as the shared base class for `DonutChart`, `PieChart3D`, and `DonutChart3D`.
8
13
  */
9
14
  export class PieChart extends Chart {
10
15
  static chartType = 'pie';
11
16
 
17
+ /**
18
+ * Resolve whether/how far slice `(d, i)` is pre-exploded in pixels.
19
+ * Supports:
20
+ * - per-slice `d.exploded` (`true` -> `explodeOffset`, or a numeric pixel offset)
21
+ * - `options.exploded`:
22
+ * - `true`: all slices exploded by `explodeOffset`
23
+ * - `'max'`: explodes the largest slice
24
+ * - `number`: slice index
25
+ * - `string`: slice label
26
+ * - `Array<number|string>`: matching indices or labels
27
+ * - `Function`: `(d, i) => boolean | number`
28
+ */
29
+ static resolveExplodeDistance(d, i, data, options = {}) {
30
+ const explodeOffset = options.explodeOffset ?? 14;
31
+ if (typeof d?.exploded === 'number') return d.exploded;
32
+ if (d?.exploded === true) return explodeOffset;
33
+
34
+ const { exploded } = options;
35
+ if (exploded == null || exploded === false) return 0;
36
+ if (exploded === true) return explodeOffset;
37
+ if (exploded === 'max') {
38
+ let maxIdx = 0;
39
+ let maxVal = -Infinity;
40
+ data.forEach((item, idx) => {
41
+ if ((item?.value ?? 0) > maxVal) {
42
+ maxVal = item.value;
43
+ maxIdx = idx;
44
+ }
45
+ });
46
+ return i === maxIdx ? explodeOffset : 0;
47
+ }
48
+ if (typeof exploded === 'number') return i === exploded ? explodeOffset : 0;
49
+ if (typeof exploded === 'string') return d?.label === exploded ? explodeOffset : 0;
50
+ if (Array.isArray(exploded)) {
51
+ return (exploded.includes(i) || exploded.includes(d?.label)) ? explodeOffset : 0;
52
+ }
53
+ if (typeof exploded === 'function') {
54
+ const res = exploded(d, i);
55
+ if (typeof res === 'number') return res;
56
+ return res ? explodeOffset : 0;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /**
62
+ * Prepare normalized slice geometry (angles, percentages, colors, explosion offsets,
63
+ * and optional `maxSlices` roll-up into an `"Other"` slice).
64
+ */
65
+ static prepareSlices(data = [], options = {}) {
66
+ const {
67
+ colors,
68
+ maxSlices = 0,
69
+ otherLabel = 'Other',
70
+ startAngle: initialAngle = -Math.PI / 2,
71
+ } = options;
72
+ const palette = new ColorPalette(colors);
73
+ let items = Array.isArray(data) ? data.filter(d => (d?.value ?? 0) > 0) : [];
74
+ if (maxSlices > 1 && items.length > maxSlices) {
75
+ const head = items.slice(0, maxSlices - 1);
76
+ const tail = items.slice(maxSlices - 1);
77
+ const otherVal = tail.reduce((s, d) => s + (d.value || 0), 0);
78
+ items = [...head, { label: otherLabel, value: otherVal }];
79
+ }
80
+ const total = items.reduce((s, d) => s + d.value, 0) || 1;
81
+ let angle = initialAngle;
82
+ const slices = items.map((d, i) => {
83
+ const span = (d.value / total) * Math.PI * 2;
84
+ const startAngle = angle;
85
+ const endAngle = angle + span;
86
+ const midAngle = startAngle + span / 2;
87
+ angle = endAngle;
88
+ const explodeDistance = PieChart.resolveExplodeDistance(d, i, items, options);
89
+ return {
90
+ index: i,
91
+ datum: d,
92
+ label: d.label,
93
+ value: d.value,
94
+ percent: (d.value / total) * 100,
95
+ color: d.color || palette.colorAt(i),
96
+ startAngle,
97
+ endAngle,
98
+ midAngle,
99
+ explodeDistance,
100
+ };
101
+ });
102
+ return { slices, total, palette };
103
+ }
104
+
105
+ /**
106
+ * Wire smooth hover pull-out on a slice's SVG elements (`elements` moves as a unit).
107
+ */
108
+ _wireSliceHover(elements, midAngle, ryRatio = 1) {
109
+ const { hoverExplode = true, hoverOffset = 14 } = this.options;
110
+ if (!hoverExplode || hoverOffset <= 0) return;
111
+ this._wireRadialHover(elements, midAngle, { offset: hoverOffset, ryRatio });
112
+ }
113
+
114
+ /**
115
+ * Draw optional callout leader lines and external labels for slices.
116
+ */
117
+ _drawCalloutLabel(slice, cx, cy, rxOuter, ryOuter, ryRatio = 1, sliceEls = null) {
118
+ const { showLabels = false, minLabelPercent = 2 } = this.options;
119
+ if (!showLabels || slice.percent < minLabelPercent) return;
120
+
121
+ const cos = Math.cos(slice.midAngle);
122
+ const sin = Math.sin(slice.midAngle);
123
+ const sx = cx + Math.cos(slice.midAngle) * slice.explodeDistance;
124
+ const sy = cy + Math.sin(slice.midAngle) * slice.explodeDistance * ryRatio;
125
+
126
+ const x0 = sx + rxOuter * cos;
127
+ const y0 = sy + ryOuter * sin;
128
+ const x1 = sx + (rxOuter + 14) * cos;
129
+ const y1 = sy + (ryOuter + 14 * ryRatio) * sin;
130
+ const rightSide = cos >= 0;
131
+ const x2 = x1 + (rightSide ? 12 : -12);
132
+ const y2 = y1;
133
+
134
+ const lineEl = this.renderer.path(`M${x0},${y0} L${x1},${y1} L${x2},${y2}`, 'none', { color: '#64748b', width: 1 });
135
+ const labelText = showLabels === 'percent'
136
+ ? `${slice.percent.toFixed(1)}%`
137
+ : showLabels === 'label'
138
+ ? slice.label
139
+ : `${slice.label} (${slice.percent.toFixed(0)}%)`;
140
+ const textEl = this.renderer.text(labelText, x2 + (rightSide ? 4 : -4), y2 + 4, {
141
+ textAnchor: rightSide ? 'start' : 'end',
142
+ fontSize: '11px',
143
+ fill: '#334155',
144
+ });
145
+ if (sliceEls) {
146
+ if (lineEl) sliceEls.push(lineEl);
147
+ if (textEl) sliceEls.push(textEl);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Draw an optional center KPI / total label inside a doughnut hole.
153
+ */
154
+ _drawCenterLabel(cx, cy, total, rInner) {
155
+ if (rInner <= 0) return;
156
+ const { centerLabel, centerSubLabel, showTotal = false } = this.options;
157
+ if (centerLabel == null && !showTotal) return;
158
+ const mainText = centerLabel != null ? String(centerLabel) : total.toLocaleString();
159
+ this.renderer.text(mainText, cx, cy + (centerSubLabel ? -2 : 5), {
160
+ textAnchor: 'middle',
161
+ fontSize: '18px',
162
+ fontWeight: 'bold',
163
+ fill: '#1e293b',
164
+ });
165
+ const sub = centerSubLabel ?? (showTotal && centerLabel == null ? 'Total' : null);
166
+ if (sub) {
167
+ this.renderer.text(String(sub), cx, cy + 16, {
168
+ textAnchor: 'middle',
169
+ fontSize: '11px',
170
+ fill: '#64748b',
171
+ });
172
+ }
173
+ }
174
+
12
175
  _render() {
13
176
  const data = this.data;
14
177
  const options = this.options;
@@ -18,31 +181,46 @@ export class PieChart extends Chart {
18
181
  cx = w / 2, cy = h / 2 + 10,
19
182
  radius = Math.min(w, h) / 2 - 60,
20
183
  innerRadiusRatio = 0,
21
- colors, legend: showLegend = true, title,
184
+ legend: showLegend = true, title,
22
185
  } = options;
23
- const palette = new ColorPalette(colors);
24
- const total = data.reduce((s, d) => s + d.value, 0) || 1;
186
+ const { slices, total } = PieChart.prepareSlices(data, options);
25
187
  const rInner = radius * innerRadiusRatio;
26
188
 
27
189
  if (title) {
28
190
  this.renderer.text(title, cx, cy - radius - 20, { textAnchor: 'middle', fontSize: '18px', fill: '#333' });
29
191
  }
30
192
 
31
- let angle = -Math.PI / 2; // start at 12 o'clock
32
- data.forEach((d, i) => {
33
- const slice = (d.value / total) * Math.PI * 2;
34
- const color = palette.colorAt(i);
35
- const path = ShapePaths.donutSlicePath(cx, cy, radius, rInner, angle, angle + slice);
36
- this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
37
- tooltip: `${d.label}: ${d.value} (${(d.value / total * 100).toFixed(1)}%)`,
38
- }), { label: d.label });
39
- angle += slice;
40
- });
193
+ for (const s of slices) {
194
+ const dx = Math.cos(s.midAngle) * s.explodeDistance;
195
+ const dy = Math.sin(s.midAngle) * s.explodeDistance;
196
+ const path = ShapePaths.donutSlicePath(cx + dx, cy + dy, radius, rInner, s.startAngle, s.endAngle);
197
+ const el = this._datum(this.renderer.path(path, s.color, { color: '#fff', width: 1 }, {
198
+ tooltip: `${s.label}: ${s.value} (${s.percent.toFixed(1)}%)`,
199
+ }), { label: s.label });
200
+ const sliceEls = [el];
201
+ if (options.showValues && s.percent >= 4) {
202
+ const rMid = (radius + rInner) / 2;
203
+ const vx = cx + dx + Math.cos(s.midAngle) * rMid;
204
+ const vy = cy + dy + Math.sin(s.midAngle) * rMid;
205
+ const valText = typeof options.format === 'function' ? options.format(s.value) : s.value.toLocaleString();
206
+ const textEl = this.renderer.text(valText, vx, vy + 4, {
207
+ textAnchor: 'middle',
208
+ fontSize: '11px',
209
+ fontWeight: 'bold',
210
+ fill: '#ffffff',
211
+ });
212
+ if (textEl) sliceEls.push(textEl);
213
+ }
214
+ this._drawCalloutLabel(s, cx, cy, radius, radius, 1, sliceEls);
215
+ this._wireSliceHover(sliceEls, s.midAngle, 1);
216
+ }
217
+
218
+ this._drawCenterLabel(cx, cy, total, rInner);
41
219
 
42
220
  if (showLegend) {
43
- const legendItems = data.map((d, i) => ({
44
- label: `${d.label} (${(d.value / total * 100).toFixed(0)}%)`,
45
- color: palette.colorAt(i),
221
+ const legendItems = slices.map(s => ({
222
+ label: `${s.label} (${s.percent.toFixed(0)}%)`,
223
+ color: s.color,
46
224
  }));
47
225
  new Legend({
48
226
  items: legendItems,
@@ -0,0 +1,137 @@
1
+ import { PieChart } from './PieChart.js';
2
+ import { Legend } from '../Legend.js';
3
+ import { ColorPalette } from '../ColorPalette.js';
4
+ import { ShapePaths } from '../ShapePaths.js';
5
+
6
+ /**
7
+ * PieChart3D — extruded 3D tilted pie/doughnut chart with shaded outer/inner
8
+ * cylinder skirts, radial cut walls, pre-exploded slices (`exploded`), and
9
+ * interactive hover pull-out (`hoverExplode`: hovering over any slice smoothly
10
+ * slides the entire 3D wedge out along its radial angle).
11
+ */
12
+ export class PieChart3D extends PieChart {
13
+ static chartType = 'pie3D';
14
+
15
+ _render() {
16
+ const data = this.data;
17
+ const options = this.options;
18
+ const w = this.renderer?.width ?? 640;
19
+ const h = this.renderer?.height ?? 400;
20
+ const {
21
+ tilt = 0.58,
22
+ depth = 26,
23
+ innerRadiusRatio = 0,
24
+ legend: showLegend = true,
25
+ title,
26
+ } = options;
27
+ const rx = options.radius ?? (Math.min(w, h) / 2 - 55);
28
+ const ry = rx * tilt;
29
+ const rxInner = rx * innerRadiusRatio;
30
+ const ryInner = ry * innerRadiusRatio;
31
+ const cx = options.cx ?? (showLegend ? Math.max(rx + 45, w / 2 - 45) : w / 2);
32
+ const cy = options.cy ?? (h / 2 - depth / 2 + 8);
33
+
34
+ const { slices, total } = PieChart.prepareSlices(data, {
35
+ explodeOffset: 16,
36
+ ...options,
37
+ });
38
+
39
+ if (title) {
40
+ this.renderer.text(title, w / 2, Math.max(24, cy - ry - 26), {
41
+ textAnchor: 'middle',
42
+ fontSize: '18px',
43
+ fill: '#333',
44
+ });
45
+ }
46
+
47
+ // Sort slices from back (12 o'clock, sin(θ) = -1) to front (6 o'clock, sin(θ) = +1)
48
+ // so painter's algorithm naturally occludes back wedges with front wedges while keeping
49
+ // all faces of each slice grouped for smooth hover pull-out.
50
+ const ordered = [...slices].sort((a, b) => Math.sin(a.midAngle) - Math.sin(b.midAngle));
51
+
52
+ for (const s of ordered) {
53
+ const dx = Math.cos(s.midAngle) * s.explodeDistance;
54
+ const dy = Math.sin(s.midAngle) * s.explodeDistance * tilt;
55
+ const scx = cx + dx;
56
+ const scy = cy + dy;
57
+ const tooltip = `${s.label}: ${s.value} (${s.percent.toFixed(1)}%)`;
58
+ const datum = { label: s.label };
59
+ const sliceEls = [];
60
+
61
+ const topColor = ColorPalette.lighten(s.color, 0.06);
62
+ const outerSkirtColor = ColorPalette.lighten(s.color, -0.22);
63
+ const innerSkirtColor = ColorPalette.lighten(s.color, -0.28);
64
+ const wallColorA = ColorPalette.lighten(s.color, -0.15);
65
+ const wallColorB = ColorPalette.lighten(s.color, -0.25);
66
+ const stroke = { color: ColorPalette.lighten(s.color, -0.32), width: 0.8 };
67
+
68
+ // Order the two radial cut walls from further back (smaller average sin) to further front
69
+ const walls = [
70
+ { angle: s.startAngle, depthOrder: Math.sin(s.startAngle), color: wallColorA },
71
+ { angle: s.endAngle, depthOrder: Math.sin(s.endAngle), color: wallColorB },
72
+ ].sort((a, b) => a.depthOrder - b.depthOrder);
73
+
74
+ const drawWall = (wSpec) => {
75
+ if (depth <= 0 || slices.length <= 1) return;
76
+ const dPath = ShapePaths.radialWallPath(scx, scy, rxInner, ryInner, rx, ry, depth, wSpec.angle);
77
+ const el = this._datum(this.renderer.path(dPath, wSpec.color, stroke, { tooltip }), datum);
78
+ if (el) sliceEls.push(el);
79
+ };
80
+
81
+ // 1. Back-most radial cut wall
82
+ drawWall(walls[0]);
83
+
84
+ // 2. Inner donut hole cylinder wall (visible along the back half where sin(θ) <= 0)
85
+ if (rxInner > 0 && depth > 0) {
86
+ for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'back')) {
87
+ const innerPath = ShapePaths.cylinderSkirtPath(scx, scy, rxInner, ryInner, depth, a0, a1);
88
+ if (innerPath) {
89
+ const el = this._datum(this.renderer.path(innerPath, innerSkirtColor, stroke, { tooltip }), datum);
90
+ if (el) sliceEls.push(el);
91
+ }
92
+ }
93
+ }
94
+
95
+ // 3. Front-most radial cut wall
96
+ drawWall(walls[1]);
97
+
98
+ // 4. Outer cylinder rim skirt (visible along the front half where sin(θ) >= 0)
99
+ if (depth > 0) {
100
+ for (const [a0, a1] of ShapePaths.visibleArcIntervals(s.startAngle, s.endAngle, 'front')) {
101
+ const skirtPath = ShapePaths.cylinderSkirtPath(scx, scy, rx, ry, depth, a0, a1);
102
+ if (skirtPath) {
103
+ const el = this._datum(this.renderer.path(skirtPath, outerSkirtColor, stroke, { tooltip }), datum);
104
+ if (el) sliceEls.push(el);
105
+ }
106
+ }
107
+ }
108
+
109
+ // 5. Top elliptical slice face
110
+ const topPath = ShapePaths.ellipticalSlicePath(scx, scy, rx, ry, rxInner, ryInner, s.startAngle, s.endAngle);
111
+ const topEl = this._datum(this.renderer.path(topPath, topColor, { color: '#fff', width: 1 }, { tooltip }), datum);
112
+ if (topEl) sliceEls.push(topEl);
113
+
114
+ // 6. Optional callout leader line + label (moves with slice on hover)
115
+ this._drawCalloutLabel(s, cx, cy, rx, ry, tilt, sliceEls);
116
+
117
+ // 7. Wire smooth radial hover pull-out across all 3D faces of this slice
118
+ this._wireSliceHover(sliceEls, s.midAngle, tilt);
119
+ }
120
+
121
+ this._drawCenterLabel(cx, cy, total, rxInner);
122
+
123
+ if (showLegend) {
124
+ const legendItems = slices.map(s => ({
125
+ label: `${s.label} (${s.percent.toFixed(0)}%)`,
126
+ color: s.color,
127
+ }));
128
+ new Legend({
129
+ items: legendItems,
130
+ x: cx + rx + 28,
131
+ y: cy - ry,
132
+ itemHeight: 20,
133
+ markerSize: 12,
134
+ }).draw(this.renderer);
135
+ }
136
+ }
137
+ }
@@ -67,8 +67,11 @@ export class PipeChart extends Chart {
67
67
  this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
68
68
 
69
69
  pts.forEach(p => {
70
- this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
71
- { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` });
70
+ if (typeof this.renderer.circle === 'function') {
71
+ this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
72
+ { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
73
+ { label: p.d.label, series: isMultiline ? s.name : null });
74
+ }
72
75
  });
73
76
 
74
77
  if (seriesIndex === 0) {
@@ -0,0 +1,198 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Chart } from '../Chart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+
5
+ /**
6
+ * PointAndFigureChart — classical financial charting using columns of X's (rising)
7
+ * and O's (falling) to filter out time and track supply/demand breakouts.
8
+ */
9
+ export class PointAndFigureChart extends Chart {
10
+ static chartType = 'pointAndFigure';
11
+
12
+ /**
13
+ * Compute Point and Figure columns from price data.
14
+ * @param {Array<number|object>} data
15
+ * @param {object} [options]
16
+ * @param {number} [options.boxSize] - Price increment per box
17
+ * @param {number} [options.reversal=3] - Number of boxes to trigger reversal
18
+ */
19
+ static computePointAndFigure(data, { boxSize, reversal = 3 } = {}) {
20
+ if (!Array.isArray(data) || data.length === 0) return { columns: [], boxSize: 1, minPrice: 0, maxPrice: 1 };
21
+
22
+ const items = data.map((d, i) => {
23
+ const high = Number(d.high ?? d.close ?? d.value ?? d) || 0;
24
+ const low = Number(d.low ?? d.close ?? d.value ?? d) || 0;
25
+ const label = typeof d === 'object' && d !== null ? (d.label ?? d.date ?? `#${i + 1}`) : `#${i + 1}`;
26
+ return { high, low, label };
27
+ });
28
+
29
+ const p0 = items[0].high;
30
+ const bSize = boxSize ?? Math.max(0.5, Math.round(p0 * 0.015 * 10) / 10);
31
+ const toBox = (price) => Math.floor(price / bSize);
32
+
33
+ let curType = items[1]?.high >= p0 ? 'X' : 'O';
34
+ let curBoxes = [toBox(p0)];
35
+ const columns = [{ type: curType, boxes: curBoxes, label: items[0].label }];
36
+
37
+ for (let i = 1; i < items.length; i++) {
38
+ const { high, low, label } = items[i];
39
+ const col = columns[columns.length - 1];
40
+ const topBox = Math.max(...col.boxes);
41
+ const botBox = Math.min(...col.boxes);
42
+
43
+ if (col.type === 'X') {
44
+ const highBox = toBox(high);
45
+ if (highBox > topBox) {
46
+ for (let b = topBox + 1; b <= highBox; b++) col.boxes.push(b);
47
+ col.label = label;
48
+ } else {
49
+ const lowBox = toBox(low);
50
+ if (topBox - lowBox >= reversal) {
51
+ const newBoxes = [];
52
+ for (let b = topBox - 1; b >= lowBox; b--) newBoxes.push(b);
53
+ columns.push({ type: 'O', boxes: newBoxes, label });
54
+ }
55
+ }
56
+ } else {
57
+ const lowBox = toBox(low);
58
+ if (lowBox < botBox) {
59
+ for (let b = botBox - 1; b >= lowBox; b--) col.boxes.push(b);
60
+ col.label = label;
61
+ } else {
62
+ const highBox = toBox(high);
63
+ if (highBox - botBox >= reversal) {
64
+ const newBoxes = [];
65
+ for (let b = botBox + 1; b <= highBox; b++) newBoxes.push(b);
66
+ columns.push({ type: 'X', boxes: newBoxes, label });
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ let minB = Infinity;
73
+ let maxB = -Infinity;
74
+ columns.forEach(col => {
75
+ col.boxes.forEach(b => {
76
+ if (b < minB) minB = b;
77
+ if (b > maxB) maxB = b;
78
+ });
79
+ });
80
+
81
+ if (!Number.isFinite(minB)) { minB = 0; maxB = 10; }
82
+
83
+ return {
84
+ columns,
85
+ boxSize: bSize,
86
+ minBox: minB,
87
+ maxBox: maxB,
88
+ minPrice: minB * bSize,
89
+ maxPrice: (maxB + 1) * bSize,
90
+ };
91
+ }
92
+
93
+ _render() {
94
+ const options = this.options || {};
95
+ const { columns, boxSize, minBox, maxBox, minPrice, maxPrice } = PointAndFigureChart.computePointAndFigure(this.data, options);
96
+ if (!columns.length) return;
97
+
98
+ const box = ChartBox.fromRenderer(this.renderer);
99
+ const {
100
+ x = box.x, y = box.y, width = box.width, height = box.height,
101
+ xColor = '#26a69a', oColor = '#ef5350',
102
+ showGrid = true, showTrendlines = true,
103
+ title, xAxisLabel, yAxisLabel,
104
+ } = options;
105
+
106
+ const plotBox = { x, y, width, height };
107
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
108
+
109
+ const totalBoxes = (maxBox - minBox + 1) || 1;
110
+ const totalCols = columns.length;
111
+ const colW = width / Math.max(1, totalCols);
112
+ const boxH = height / totalBoxes;
113
+
114
+ const toScreenY = (bIndex) => y + (maxBox - bIndex) * boxH;
115
+
116
+ // 1. Grid lines
117
+ if (showGrid) {
118
+ for (let b = minBox; b <= maxBox + 1; b++) {
119
+ const gy = toScreenY(b);
120
+ this.renderer.path(`M${x},${gy} L${x + width},${gy}`, 'none', { color: '#f0f0f0', width: 0.75 });
121
+ }
122
+ for (let c = 0; c <= totalCols; c++) {
123
+ const gx = x + c * colW;
124
+ this.renderer.path(`M${gx},${y} L${gx},${y + height}`, 'none', { color: '#f0f0f0', width: 0.75 });
125
+ }
126
+ }
127
+
128
+ // 2. Y-axis price labels
129
+ const axisStep = Math.max(1, Math.round(totalBoxes / 8));
130
+ for (let b = minBox; b <= maxBox; b += axisStep) {
131
+ const py = toScreenY(b) + boxH / 2;
132
+ const priceVal = b * boxSize;
133
+ this.renderer.text(Axis.formatValue(priceVal), x - 8, py, {
134
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '9px', fill: '#666',
135
+ });
136
+ }
137
+
138
+ // 3. Columns of X and O
139
+ columns.forEach((col, cIdx) => {
140
+ const cx = x + cIdx * colW + colW / 2;
141
+ const isX = col.type === 'X';
142
+ const color = isX ? xColor : oColor;
143
+ const glyphSize = Math.min(colW, boxH) * 0.7;
144
+
145
+ col.boxes.forEach(bIndex => {
146
+ const cy = toScreenY(bIndex) + boxH / 2;
147
+ const price = bIndex * boxSize;
148
+
149
+ if (isX) {
150
+ // Draw X
151
+ const hs = glyphSize / 2;
152
+ const pathD = `M${cx - hs},${cy - hs} L${cx + hs},${cy + hs} M${cx - hs},${cy + hs} L${cx + hs},${cy - hs}`;
153
+ const el = this.renderer.path(pathD, 'none', { color, width: 2 }, {
154
+ tooltip: `X (${col.label}): ${Axis.formatValue(price)}`,
155
+ });
156
+ this._datum(el, { label: col.label, type: 'X', price });
157
+ } else {
158
+ // Draw O
159
+ const r = glyphSize / 2;
160
+ const el = this.renderer.circle(cx, cy, r, 'none', { color, width: 2 }, {
161
+ tooltip: `O (${col.label}): ${Axis.formatValue(price)}`,
162
+ });
163
+ this._datum(el, { label: col.label, type: 'O', price });
164
+ }
165
+ });
166
+
167
+ // Column label along X axis
168
+ const step = Math.max(1, Math.floor(totalCols / 8));
169
+ if (cIdx % step === 0) {
170
+ this.renderer.text(col.label, cx, y + height + 18, {
171
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '9px', fill: '#555',
172
+ });
173
+ }
174
+ });
175
+
176
+ // 4. Optional 45-degree support/resistance trendlines
177
+ if (showTrendlines && totalCols > 3) {
178
+ // Find major low for bullish support (45 deg up)
179
+ let lowestBox = Infinity, lowestCol = 0;
180
+ columns.forEach((c, idx) => {
181
+ const minB = Math.min(...c.boxes);
182
+ if (minB < lowestBox) { lowestBox = minB; lowestCol = idx; }
183
+ });
184
+
185
+ if (lowestCol < totalCols - 1) {
186
+ const x1 = x + lowestCol * colW + colW / 2;
187
+ const y1 = toScreenY(lowestBox) + boxH;
188
+ const steps = totalCols - 1 - lowestCol;
189
+ const x2 = x1 + steps * colW;
190
+ const y2 = y1 - steps * boxH;
191
+ this.renderer.path(`M${x1},${y1} L${x2},${y2}`, 'none', { color: '#26a69a', width: 1.5, strokeDasharray: '4 4' });
192
+ }
193
+ }
194
+
195
+ // Draw baseline axis
196
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
197
+ }
198
+ }