@peter.prib/renderengine 0.14.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 (109) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49391 -39748
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +38 -0
  6. package/package.json +6 -1
  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 +219 -0
  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 +3 -0
  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 +114 -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/charts/AlluvialDiagram.js +311 -0
  34. package/src/charting/charts/AreaChart.js +52 -15
  35. package/src/charting/charts/BarChart.js +38 -3
  36. package/src/charting/charts/BarChart3D.js +5 -3
  37. package/src/charting/charts/BarChartBase.js +26 -6
  38. package/src/charting/charts/BeeswarmChart.js +164 -0
  39. package/src/charting/charts/BoxPlotChart.js +23 -1
  40. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  41. package/src/charting/charts/CandlestickChart.js +172 -4
  42. package/src/charting/charts/ChordDiagram.js +105 -2
  43. package/src/charting/charts/CirclePackChart.js +81 -0
  44. package/src/charting/charts/ComboChart.js +233 -0
  45. package/src/charting/charts/ContourChart.js +310 -0
  46. package/src/charting/charts/DonutChart3D.js +13 -0
  47. package/src/charting/charts/DumbbellChart.js +217 -0
  48. package/src/charting/charts/EcdfChart.js +150 -0
  49. package/src/charting/charts/FunnelChart.js +4 -1
  50. package/src/charting/charts/GeoMapChart.js +132 -11
  51. package/src/charting/charts/GroupedBarChart.js +44 -3
  52. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  53. package/src/charting/charts/HeatmapChart.js +2 -2
  54. package/src/charting/charts/HexbinChart.js +137 -0
  55. package/src/charting/charts/HierarchyChart.js +77 -3
  56. package/src/charting/charts/HorizonChart.js +105 -0
  57. package/src/charting/charts/KagiChart.js +167 -0
  58. package/src/charting/charts/LineChart.js +74 -18
  59. package/src/charting/charts/OhlcChart.js +79 -0
  60. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  61. package/src/charting/charts/PieChart.js +195 -17
  62. package/src/charting/charts/PieChart3D.js +137 -0
  63. package/src/charting/charts/PointAndFigureChart.js +198 -0
  64. package/src/charting/charts/PolarAreaChart.js +189 -0
  65. package/src/charting/charts/RadarChart.js +7 -1
  66. package/src/charting/charts/RadialBarChart.js +5 -2
  67. package/src/charting/charts/RenkoChart.js +178 -0
  68. package/src/charting/charts/RidgelineChart.js +113 -0
  69. package/src/charting/charts/SankeyDiagram.js +127 -4
  70. package/src/charting/charts/ScatterChart.js +13 -0
  71. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  72. package/src/charting/charts/SlopegraphChart.js +205 -0
  73. package/src/charting/charts/SparklineTableChart.js +166 -0
  74. package/src/charting/charts/StackedBarChart.js +48 -5
  75. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  76. package/src/charting/charts/StreamGraphChart.js +10 -3
  77. package/src/charting/charts/SunburstChart.js +27 -7
  78. package/src/charting/charts/SurfaceChart3D.js +157 -0
  79. package/src/charting/charts/TreemapChart.js +28 -8
  80. package/src/charting/charts/ViolinChart.js +18 -0
  81. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  82. package/src/charting/datamodel/DataLineage.js +150 -0
  83. package/src/charting/datamodel/DataModel.js +83 -1
  84. package/src/charting/datamodel/ModelMapping.js +87 -0
  85. package/src/charting/datamodel/SqlDdl.js +110 -85
  86. package/src/charting/datamodel/index.js +3 -0
  87. package/src/charting/decision/DecisionWorkbench.js +295 -0
  88. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  89. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  90. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  91. package/src/charting/diagrams/SketchView.js +121 -0
  92. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  93. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  94. package/src/charting/modelTree/NavigatorModel.js +410 -0
  95. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  96. package/src/charting/pivot/PivotChart.js +109 -4
  97. package/src/charting/pivot/PivotGridChart.js +321 -33
  98. package/src/charting/pivot/PivotView.js +82 -0
  99. package/src/charting/report/TemplatedReport.js +547 -0
  100. package/src/charting/templates/BoxTemplate.js +28 -27
  101. package/src/charting/templates/PackageTemplate.js +26 -25
  102. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  103. package/src/charting/templates/Template.js +219 -212
  104. package/src/cli.js +146 -0
  105. package/src/index.js +3370 -2995
  106. package/src/workspace/Workspace.js +126 -7
  107. package/src/workspace/archimateServices.js +4 -0
  108. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  109. package/src/workspace/styles.js +36 -1
@@ -10,11 +10,16 @@ import { ColorPalette } from '../ColorPalette.js';
10
10
  * x-axis label drawing. Subclasses implement `_renderGlyph(cx, slotWidth, i, d)`.
11
11
  */
12
12
  export class GroupedDistributionChart extends Chart {
13
+ _isHorizontal() {
14
+ return this.options.orientation === 'horizontal';
15
+ }
16
+
13
17
  _setup() {
14
18
  const box = ChartBox.fromRenderer(this.renderer);
15
19
  const options = this.options;
20
+ const leftExtra = this._isHorizontal() && options.x == null ? 40 : 0;
16
21
  const {
17
- x = box.x, y = box.y, width = box.width, height = box.height,
22
+ x = box.x + leftExtra, y = box.y, width = box.width - leftExtra, height = box.height,
18
23
  colors, title, xAxisLabel, yAxisLabel,
19
24
  } = options;
20
25
  this._plotBox = { x, y, width, height };
@@ -36,10 +41,19 @@ export class GroupedDistributionChart extends Chart {
36
41
  return y + height - ((v - this._domain.vMin) / this._domain.range) * height;
37
42
  }
38
43
 
44
+ _toX(v) {
45
+ const { x, width } = this._plotBox;
46
+ return x + ((v - this._domain.vMin) / this._domain.range) * width;
47
+ }
48
+
39
49
  _slotWidth() {
40
50
  return this._plotBox.width / this.data.length;
41
51
  }
42
52
 
53
+ _slotHeight() {
54
+ return this._plotBox.height / this.data.length;
55
+ }
56
+
43
57
  _drawBaseAxis() {
44
58
  const { x, y, width, height } = this._plotBox;
45
59
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
@@ -54,6 +68,17 @@ export class GroupedDistributionChart extends Chart {
54
68
  this._setup();
55
69
  this._domain = this._computeDomain();
56
70
  this._drawBaseAxis();
71
+ if (this._isHorizontal()) {
72
+ const slotHeight = this._slotHeight();
73
+ this.data.forEach((d, i) => {
74
+ const cy = this._plotBox.y + i * slotHeight + slotHeight / 2;
75
+ this._renderGlyph(cy, slotHeight, i, d);
76
+ this.renderer.text(d.label, this._plotBox.x - 8, cy, {
77
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#333',
78
+ });
79
+ });
80
+ return;
81
+ }
57
82
  const slotWidth = this._slotWidth();
58
83
  const data = this.data;
59
84
  data.forEach((d, i) => {
@@ -17,7 +17,7 @@ export class HeatmapChart extends Chart {
17
17
  const {
18
18
  x = box.x, y = box.y, width = box.width, height = box.height,
19
19
  xLabels, yLabels, colorScale = ['#f7fbff', '#4292c6', '#08306b'],
20
- showValues = false, title,
20
+ showValues = false, midValue, title,
21
21
  } = options;
22
22
 
23
23
  // Grid cost is xCats.length * yCats.length, NOT data.length — a row
@@ -51,7 +51,7 @@ export class HeatmapChart extends Chart {
51
51
  const v = cellByKey.get(`${xc}|${yc}`);
52
52
  const cellX = x + xi * cellW;
53
53
  const cellY = y + yi * cellH;
54
- const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
54
+ const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v, midValue);
55
55
  const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
56
56
  tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
57
57
  });
@@ -0,0 +1,137 @@
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
+ * HexbinChart — 2D hexagonal binning and density plot for dense (x, y) point clouds.
8
+ *
9
+ * Partitions the continuous 2D space into regular hexagon cells and shades each
10
+ * hexagon by observation count (or aggregated value), eliminating overplotting.
11
+ */
12
+ export class HexbinChart extends Chart {
13
+ static chartType = 'hexbin';
14
+
15
+ /**
16
+ * Compute regular hexagon vertices centered at (cx, cy) with radius r (pointy-topped).
17
+ */
18
+ static hexagonPath(cx, cy, r) {
19
+ const pts = [];
20
+ for (let i = 0; i < 6; i++) {
21
+ const angle = (Math.PI / 3) * i - Math.PI / 6;
22
+ const px = cx + r * Math.cos(angle);
23
+ const py = cy + r * Math.sin(angle);
24
+ pts.push(`${px.toFixed(2)},${py.toFixed(2)}`);
25
+ }
26
+ return 'M' + pts.join(' L') + ' Z';
27
+ }
28
+
29
+ /**
30
+ * Bin 2D points into pointy-topped hexagons of radius r in screen coordinates.
31
+ * @returns {Map<string, {cx: number, cy: number, count: number, points: Array}>}
32
+ */
33
+ static binPoints(points, r) {
34
+ const dx = r * Math.sqrt(3);
35
+ const dy = r * 1.5;
36
+ const bins = new Map();
37
+
38
+ for (const pt of points) {
39
+ const px = pt.px;
40
+ const py = pt.py;
41
+ // Pointy-top hex grid coordinate estimation
42
+ const q = (Math.sqrt(3) / 3 * px - 1 / 3 * py) / r;
43
+ const s = (2 / 3 * py) / r;
44
+ // Cube round
45
+ let rx = Math.round(q);
46
+ let rz = Math.round(s);
47
+ let ry = Math.round(-q - s);
48
+ const xDiff = Math.abs(rx - q);
49
+ const yDiff = Math.abs(ry - (-q - s));
50
+ const zDiff = Math.abs(rz - s);
51
+ if (xDiff > yDiff && xDiff > zDiff) {
52
+ rx = -ry - rz;
53
+ } else if (yDiff > zDiff) {
54
+ ry = -rx - rz;
55
+ } else {
56
+ rz = -rx - ry;
57
+ }
58
+
59
+ // Hex center from cube coordinates
60
+ const cx = r * (Math.sqrt(3) * rx + Math.sqrt(3) / 2 * rz);
61
+ const cy = r * (3 / 2 * rz);
62
+ const key = `${rx},${rz}`;
63
+ let b = bins.get(key);
64
+ if (!b) {
65
+ b = { cx, cy, count: 0, sum: 0, points: [] };
66
+ bins.set(key, b);
67
+ }
68
+ b.count += 1;
69
+ b.sum += Number(pt.value ?? 1) || 1;
70
+ b.points.push(pt);
71
+ }
72
+ return bins;
73
+ }
74
+
75
+ _render() {
76
+ const data = this.data;
77
+ if (!Array.isArray(data) || !data.length) return;
78
+
79
+ const options = this.options;
80
+ const box = ChartBox.fromRenderer(this.renderer);
81
+ const {
82
+ x = box.x, y = box.y, width = box.width, height = box.height,
83
+ title, xAxisLabel, yAxisLabel,
84
+ radius = 14,
85
+ minCount = 1,
86
+ colorScale = ['#e0f3f8', '#67a9cf', '#02818a', '#bd0026'],
87
+ } = options;
88
+ const plotBox = { x, y, width, height };
89
+
90
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
91
+
92
+ const pts = data.map((d, i) => {
93
+ const rawX = Array.isArray(d) ? d[0] : (d.x ?? i);
94
+ const rawY = Array.isArray(d) ? d[1] : (d.y ?? d.value ?? 0);
95
+ return { rawX: Number(rawX) || 0, rawY: Number(rawY) || 0, label: d.label, value: d.value };
96
+ });
97
+
98
+ const xMin = Math.min(...pts.map(p => p.rawX));
99
+ const xMax = Math.max(...pts.map(p => p.rawX));
100
+ const yMin = Math.min(...pts.map(p => p.rawY));
101
+ const yMax = Math.max(...pts.map(p => p.rawY));
102
+ const xRange = (xMax - xMin) || 1;
103
+ const yRange = (yMax - yMin) || 1;
104
+
105
+ // Draw axes
106
+ new Axis().drawYRange(this.renderer, plotBox, yMin, yMax);
107
+ new Axis().drawXRange(this.renderer, plotBox, xMin, xMax);
108
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
109
+
110
+ const mapped = pts.map(p => ({
111
+ ...p,
112
+ px: x + ((p.rawX - xMin) / xRange) * width,
113
+ py: y + height - ((p.rawY - yMin) / yRange) * height,
114
+ }));
115
+
116
+ const bins = HexbinChart.binPoints(mapped, radius);
117
+ const binList = Array.from(bins.values()).filter(b => b.count >= minCount);
118
+ if (!binList.length) return;
119
+
120
+ const maxCount = Math.max(...binList.map(b => b.count));
121
+ const palette = new ColorPalette();
122
+
123
+ binList.forEach(b => {
124
+ // Clip to plot box bounds with a small margin
125
+ if (b.cx < x - radius || b.cx > x + width + radius || b.cy < y - radius || b.cy > y + height + radius) return;
126
+ const t = b.count / maxCount;
127
+ const color = ColorPalette.scaleColor(colorScale, 0, maxCount, b.count);
128
+ const hexD = HexbinChart.hexagonPath(b.cx, b.cy, radius * 0.96);
129
+ const tooltip = `Count: ${b.count} points (peak: ${maxCount})`;
130
+
131
+ const hexEl = this._datum(this.renderer.path(hexD, color, { color: '#ffffff', width: 0.8 }, {
132
+ tooltip,
133
+ }), { count: b.count, value: b.count });
134
+ this._wireHoverTransform([hexEl], { dx: 0, dy: -2 });
135
+ });
136
+ }
137
+ }
@@ -7,9 +7,82 @@ import { Hierarchy } from '../Hierarchy.js';
7
7
  * HierarchyChart — abstract base for charts that render a hierarchy of nodes.
8
8
  *
9
9
  * Owns top-level palette assignment, per-depth color lightening, text-color
10
- * contrast, and value formatting so subclasses stay focused on layout/rendering.
10
+ * contrast, value formatting, and interactive drill-down / breadcrumbs.
11
11
  */
12
12
  export class HierarchyChart extends Chart {
13
+ constructor(renderer, data, options = {}) {
14
+ super(renderer, data, options);
15
+ this._drillStack = [];
16
+ }
17
+
18
+ _activeData() {
19
+ if (this._drillStack.length > 0) {
20
+ const currentRoot = this._drillStack[this._drillStack.length - 1];
21
+ return currentRoot.children || [currentRoot];
22
+ }
23
+ return this.data;
24
+ }
25
+
26
+ drillTo(node) {
27
+ if (!node || !node.children?.length) return;
28
+ this._drillStack.push(node);
29
+ this._topLevelColorMap = null;
30
+ this.render();
31
+ }
32
+
33
+ drillUp() {
34
+ if (!this._drillStack.length) return;
35
+ this._drillStack.pop();
36
+ this._topLevelColorMap = null;
37
+ this.render();
38
+ }
39
+
40
+ drillToCrumb(index) {
41
+ if (index < 0) {
42
+ this._drillStack = [];
43
+ } else {
44
+ this._drillStack = this._drillStack.slice(0, index + 1);
45
+ }
46
+ this._topLevelColorMap = null;
47
+ this.render();
48
+ }
49
+
50
+ _drawBreadcrumbs(renderer, { x, y, width }) {
51
+ if (this._drillStack.length === 0) return 0;
52
+ const crumbH = 24;
53
+ const crumbs = [{ label: 'Root', index: -1 }, ...this._drillStack.map((n, i) => ({ label: n.label, index: i }))];
54
+
55
+ let curX = x;
56
+ crumbs.forEach((c, i) => {
57
+ if (i > 0) {
58
+ renderer.text('/', curX + 4, y + crumbH / 2, {
59
+ verticalAlign: 'middle', fontSize: '11px', fill: '#999',
60
+ });
61
+ curX += 14;
62
+ }
63
+ const isLast = i === crumbs.length - 1;
64
+ const textW = c.label.length * 6 + 10;
65
+ const bgRect = renderer.rect(curX, y + 2, textW, crumbH - 4, isLast ? '#e0f2fe' : '#f1f5f9', { color: isLast ? '#0284c7' : '#cbd5e1', width: 1 }, {
66
+ cursor: isLast ? 'default' : 'pointer',
67
+ rx: 3, ry: 3,
68
+ });
69
+ renderer.text(c.label, curX + textW / 2, y + crumbH / 2, {
70
+ textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px',
71
+ fill: isLast ? '#0369a1' : '#334155', fontWeight: isLast ? 'bold' : 'normal',
72
+ 'pointer-events': 'none',
73
+ });
74
+ if (!isLast && typeof bgRect?.addEventListener === 'function') {
75
+ bgRect.addEventListener('click', (e) => {
76
+ e.stopPropagation();
77
+ this.drillToCrumb(c.index);
78
+ });
79
+ }
80
+ curX += textW + 4;
81
+ });
82
+
83
+ return crumbH + 6;
84
+ }
85
+
13
86
  _palette() {
14
87
  if (!this._paletteInstance) {
15
88
  this._paletteInstance = new ColorPalette(this.options.colors);
@@ -19,7 +92,8 @@ export class HierarchyChart extends Chart {
19
92
 
20
93
  _topLevelColor() {
21
94
  if (!this._topLevelColorMap) {
22
- this._topLevelColorMap = new Map(this.data.map((n, i) => [n, this._palette().colorAt(i)]));
95
+ const active = this._activeData();
96
+ this._topLevelColorMap = new Map(active.map((n, i) => [n, this._palette().colorAt(i)]));
23
97
  }
24
98
  return this._topLevelColorMap;
25
99
  }
@@ -30,7 +104,7 @@ export class HierarchyChart extends Chart {
30
104
  }
31
105
 
32
106
  _textColorForNode(topAncestor, depth) {
33
- return this._palette().isDark(this._colorForNode(topAncestor, depth)) ? '#fff' : '#222';
107
+ return ColorPalette.isDark(this._colorForNode(topAncestor, depth)) ? '#fff' : '#222';
34
108
  }
35
109
 
36
110
  _formatNodeValue(node) {
@@ -0,0 +1,105 @@
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
+ * HorizonChart — 2-band to 4-band layered area chart folding positive and
8
+ * negative magnitude bands into compact horizontal strips.
9
+ *
10
+ * Allows comparing many time series in a single viewport by folding higher
11
+ * amplitude values onto lower layers with progressive color intensity.
12
+ */
13
+ export class HorizonChart extends Chart {
14
+ static chartType = 'horizon';
15
+
16
+ _render() {
17
+ const data = this.data;
18
+ if (!Array.isArray(data) || !data.length) return;
19
+
20
+ const options = this.options;
21
+ const box = ChartBox.fromRenderer(this.renderer);
22
+ const {
23
+ x = box.x, y = box.y, width = box.width, height = box.height,
24
+ title, bands = 3,
25
+ posColors = ['#bdd7e7', '#6baed6', '#2171b5'],
26
+ negColors = ['#fcae91', '#fb6a4a', '#cb181d'],
27
+ } = options;
28
+ const plotBox = { x, y, width, height };
29
+
30
+ new ChartLabels({ title }).draw(this.renderer, plotBox);
31
+
32
+ // Normalize data into series: Array<{label: string, values: number[]}>
33
+ const series = data.map((d, i) => {
34
+ const label = d.label ?? d.name ?? `Series ${i + 1}`;
35
+ const values = Array.isArray(d.values)
36
+ ? d.values.map(v => typeof v === 'object' && v !== null ? v.value : Number(v) || 0)
37
+ : (Array.isArray(d) ? d : [Number(d.value) || 0]);
38
+ return { label, values };
39
+ });
40
+
41
+ const numSeries = series.length;
42
+ const rowHeight = height / numSeries;
43
+ const leftLabelW = 90;
44
+ const stripX = x + leftLabelW;
45
+ const stripW = width - leftLabelW;
46
+
47
+ // Global max absolute amplitude across all series to anchor band step
48
+ const allVals = series.flatMap(s => s.values);
49
+ const maxAbs = Math.max(...allVals.map(Math.abs), 1);
50
+ const bandStep = maxAbs / bands;
51
+
52
+ series.forEach((s, sIdx) => {
53
+ const rowY = y + sIdx * rowHeight;
54
+ const values = s.values;
55
+ const N = values.length;
56
+ if (N < 2) return;
57
+ const stepX = stripW / (N - 1);
58
+
59
+ // Strip background & baseline
60
+ this.renderer.rect(stripX, rowY, stripW, rowHeight, 'rgba(0,0,0,0.015)', { color: '#e8e8e8', width: 0.8 });
61
+ this.renderer.text(s.label, stripX - 8, rowY + rowHeight / 2, {
62
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fontWeight: '600', fill: '#333',
63
+ });
64
+
65
+ // Draw positive and negative bands
66
+ for (let b = 0; b < bands; b++) {
67
+ const bFloor = b * bandStep;
68
+ const bCeil = (b + 1) * bandStep;
69
+ const posColor = posColors[b % posColors.length];
70
+ const negColor = negColors[b % negColors.length];
71
+
72
+ // Positive band path
73
+ const posPts = values.map((v, i) => {
74
+ const clamped = Math.max(0, Math.min(bandStep, v - bFloor));
75
+ const py = rowY + rowHeight - (clamped / bandStep) * rowHeight;
76
+ return { px: stripX + i * stepX, py };
77
+ });
78
+ const posD = `M${stripX},${rowY + rowHeight} L`
79
+ + posPts.map(p => `${p.px.toFixed(1)},${p.py.toFixed(1)}`).join(' L')
80
+ + ` L${stripX + stripW},${rowY + rowHeight} Z`;
81
+
82
+ this.renderer.path(posD, posColor, null, {
83
+ 'fill-opacity': 0.85,
84
+ tooltip: `${s.label} +Band ${b + 1}: max ${Axis.formatValue(bCeil)}`,
85
+ });
86
+
87
+ // Negative band path (reflected upward)
88
+ const negPts = values.map((v, i) => {
89
+ const negVal = Math.max(0, -v);
90
+ const clamped = Math.max(0, Math.min(bandStep, negVal - bFloor));
91
+ const py = rowY + rowHeight - (clamped / bandStep) * rowHeight;
92
+ return { px: stripX + i * stepX, py };
93
+ });
94
+ const negD = `M${stripX},${rowY + rowHeight} L`
95
+ + negPts.map(p => `${p.px.toFixed(1)},${p.py.toFixed(1)}`).join(' L')
96
+ + ` L${stripX + stripW},${rowY + rowHeight} Z`;
97
+
98
+ this.renderer.path(negD, negColor, null, {
99
+ 'fill-opacity': 0.85,
100
+ tooltip: `${s.label} -Band ${b + 1}: -${Axis.formatValue(bCeil)}`,
101
+ });
102
+ }
103
+ });
104
+ }
105
+ }
@@ -0,0 +1,167 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Chart } from '../Chart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+
5
+ /**
6
+ * KagiChart — Japanese price-action chart independent of time.
7
+ *
8
+ * Tracks price movements with vertical lines connected by short horizontal inflection steps.
9
+ * Switches between Yang (thick, bullish) and Yin (thin, bearish) lines when breaking
10
+ * previous peaks or troughs.
11
+ */
12
+ export class KagiChart extends Chart {
13
+ static chartType = 'kagi';
14
+
15
+ /**
16
+ * Compute Kagi line segments and inflection points from prices.
17
+ * @param {Array<number|object>} data - Price sequence
18
+ * @param {number|string} [reversal=0.04] - Reversal amount or percentage (e.g. 4, '4%', 0.04)
19
+ * @returns {Array<object>}
20
+ */
21
+ static computeKagi(data, reversal = 0.04) {
22
+ if (!Array.isArray(data) || data.length === 0) return { segments: [], inflections: [] };
23
+
24
+ const prices = data.map((d, i) => {
25
+ const val = typeof d === 'number' ? d : Number(d.close ?? d.value ?? d.y ?? 0);
26
+ const label = typeof d === 'object' && d !== null ? (d.label ?? d.date ?? `#${i + 1}`) : `#${i + 1}`;
27
+ return { price: val, label };
28
+ }).filter(p => Number.isFinite(p.price));
29
+
30
+ if (prices.length < 2) return { segments: [], inflections: [] };
31
+
32
+ const p0 = prices[0].price;
33
+ let revAmount = typeof reversal === 'string' && reversal.endsWith('%')
34
+ ? p0 * (parseFloat(reversal) / 100)
35
+ : (typeof reversal === 'number' && reversal < 1 ? p0 * reversal : Number(reversal) || (p0 * 0.04));
36
+
37
+ if (revAmount <= 0) revAmount = 1;
38
+
39
+ let dir = prices[1].price >= p0 ? 1 : -1;
40
+ let curStart = p0;
41
+ let curEnd = prices[1].price;
42
+ let prevPeak = dir === 1 ? curEnd : p0;
43
+ let prevTrough = dir === -1 ? curEnd : p0;
44
+ let isYang = dir === 1;
45
+
46
+ const kagiColumns = [];
47
+ kagiColumns.push({
48
+ start: curStart,
49
+ end: curEnd,
50
+ dir,
51
+ isYang,
52
+ label: prices[1].label,
53
+ });
54
+
55
+ for (let i = 2; i < prices.length; i++) {
56
+ const p = prices[i].price;
57
+ const curCol = kagiColumns[kagiColumns.length - 1];
58
+
59
+ if (curCol.dir === 1) {
60
+ // Rising
61
+ if (p > curCol.end) {
62
+ curCol.end = p;
63
+ curCol.label = prices[i].label;
64
+ if (p > prevPeak) curCol.isYang = true;
65
+ } else if (curCol.end - p >= revAmount) {
66
+ // Reverse downward
67
+ prevPeak = Math.max(prevPeak, curCol.end);
68
+ const newYang = curCol.isYang && p >= prevTrough;
69
+ kagiColumns.push({
70
+ start: curCol.end,
71
+ end: p,
72
+ dir: -1,
73
+ isYang: newYang,
74
+ label: prices[i].label,
75
+ });
76
+ }
77
+ } else {
78
+ // Falling
79
+ if (p < curCol.end) {
80
+ curCol.end = p;
81
+ curCol.label = prices[i].label;
82
+ if (p < prevTrough) curCol.isYang = false;
83
+ } else if (p - curCol.end >= revAmount) {
84
+ // Reverse upward
85
+ prevTrough = Math.min(prevTrough, curCol.end);
86
+ const newYang = curCol.isYang || p > prevPeak;
87
+ kagiColumns.push({
88
+ start: curCol.end,
89
+ end: p,
90
+ dir: 1,
91
+ isYang: newYang,
92
+ label: prices[i].label,
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ return { columns: kagiColumns, prevPeak, prevTrough, revAmount };
99
+ }
100
+
101
+ _render() {
102
+ const data = this.data;
103
+ const options = this.options || {};
104
+ const { columns } = KagiChart.computeKagi(data, options.reversal ?? 0.04);
105
+ if (!columns.length) return;
106
+
107
+ const box = ChartBox.fromRenderer(this.renderer);
108
+ const {
109
+ x = box.x, y = box.y, width = box.width, height = box.height,
110
+ yangColor = '#26a69a', yinColor = '#ef5350',
111
+ yangWidth = 3, yinWidth = 1.2,
112
+ title, xAxisLabel, yAxisLabel,
113
+ } = options;
114
+
115
+ const allPrices = columns.flatMap(c => [c.start, c.end]);
116
+ const vMax = Math.max(...allPrices);
117
+ const vMin = Math.min(...allPrices);
118
+ const plotBox = { x, y, width, height };
119
+
120
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
121
+ const yDomain = new Axis().drawYRange(this.renderer, plotBox, vMin, vMax);
122
+
123
+ const colCount = columns.length;
124
+ const colWidth = width / Math.max(1, colCount);
125
+ const toY = (val) => y + height - ((val - yDomain.min) / ((yDomain.max - yDomain.min) || 1)) * height;
126
+
127
+ // Draw baseline axis
128
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
129
+
130
+ columns.forEach((col, i) => {
131
+ const cx = x + i * colWidth + colWidth / 2;
132
+ const y1 = toY(col.start);
133
+ const y2 = toY(col.end);
134
+ const color = col.isYang ? yangColor : yinColor;
135
+ const strokeWidth = col.isYang ? yangWidth : yinWidth;
136
+
137
+ // Horizontal inflection line from previous column
138
+ if (i > 0) {
139
+ const prevCx = x + (i - 1) * colWidth + colWidth / 2;
140
+ this.renderer.path(`M${prevCx},${y1} L${cx},${y1}`, 'none', { color, width: strokeWidth });
141
+ }
142
+
143
+ // Vertical segment
144
+ const pathEl = this.renderer.path(`M${cx},${y1} L${cx},${y2}`, 'none', { color, width: strokeWidth }, {
145
+ tooltip: `Kagi ${col.dir === 1 ? 'Up' : 'Down'} (${col.isYang ? 'Yang' : 'Yin'}): ${Axis.formatValue(col.start)} → ${Axis.formatValue(col.end)}`,
146
+ });
147
+
148
+ this._datum(pathEl, {
149
+ label: col.label,
150
+ start: col.start,
151
+ end: col.end,
152
+ type: col.isYang ? 'Yang' : 'Yin',
153
+ });
154
+
155
+ // Inflection point markers
156
+ this.renderer.circle(cx, y2, 3, color, { color: '#ffffff', width: 1 });
157
+
158
+ // Periodic category/turn labels
159
+ const step = Math.max(1, Math.floor(colCount / 8));
160
+ if (i % step === 0) {
161
+ this.renderer.text(col.label, cx, y + height + 18, {
162
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '9px', fill: '#555',
163
+ });
164
+ }
165
+ });
166
+ }
167
+ }