@svgrid/grid 3.0.3 → 3.0.4

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 (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  273. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  276. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  277. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  279. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  280. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  281. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
@@ -0,0 +1,443 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ atr, bollingerBands, computeOverlay, computeOverlayFit, indicatorPane, macd, obv, overlayName, rsi, stochastic, vwap, wma,
4
+ } from './chart-indicators'
5
+ import { exponentialFit, linearFit, logarithmicFit, polynomialFit, powerFit, rSquared } from './chart-stats'
6
+ import { regressionFit } from './chart-indicators'
7
+ import { resampleOhlc, rowsToOhlcSpec } from './chart-financial'
8
+ import { applyChartFormat, buildChart, CHART_RESPONSIVE_PRESETS } from './chart'
9
+ import type { ChartSpec, OhlcBar } from './chart-types'
10
+
11
+ const closes = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64]
12
+ const bars = (cs: number[]): OhlcBar[] => cs.map((c, i) => ({ o: i ? cs[i - 1]! : c, h: c + 1, l: c - 1, c }))
13
+ const finite = (a: number[]) => a.filter(Number.isFinite)
14
+
15
+ describe('indicators', () => {
16
+ it('wma weights the latest value most and pads the warm-up with NaN', () => {
17
+ const out = wma([1, 2, 3, 4], 3)
18
+ expect(out.slice(0, 2).every(Number.isNaN)).toBe(true)
19
+ // (1*1 + 2*2 + 3*3) / 6 = 14/6
20
+ expect(out[2]).toBeCloseTo(14 / 6, 6)
21
+ expect(out[3]).toBeCloseTo((2 + 6 + 12) / 6, 6)
22
+ })
23
+
24
+ it('bollinger bands sit k deviations around the average', () => {
25
+ const { middle, upper, lower } = bollingerBands([2, 4, 4, 4, 5, 5, 7, 9], 8, 2)
26
+ expect(middle[7]).toBe(5)
27
+ // Population standard deviation of that sample is exactly 2.
28
+ expect(upper[7]).toBe(9)
29
+ expect(lower[7]).toBe(1)
30
+ expect(Number.isNaN(upper[6])).toBe(true)
31
+ })
32
+
33
+ it('rsi is 0..100, NaN before the period, and 100 with no losses', () => {
34
+ const out = rsi(closes, 14)
35
+ expect(out.slice(0, 14).every(Number.isNaN)).toBe(true)
36
+ expect(finite(out).every((v) => v >= 0 && v <= 100)).toBe(true)
37
+ // The textbook 14-day RSI on this series lands near 70 at bar 14.
38
+ expect(out[14]).toBeGreaterThan(65)
39
+ expect(out[14]).toBeLessThan(75)
40
+ expect(rsi([1, 2, 3, 4, 5], 3)[3]).toBe(100)
41
+ })
42
+
43
+ it('macd is fast EMA minus slow EMA, with a signal and a histogram', () => {
44
+ const series = Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i / 5) * 10)
45
+ const m = macd(series, 12, 26, 9)
46
+ expect(m.macd.slice(0, 25).every(Number.isNaN)).toBe(true)
47
+ expect(Number.isFinite(m.macd[25])).toBe(true)
48
+ // Histogram is the line minus its signal wherever both exist.
49
+ for (let i = 0; i < 60; i += 1) {
50
+ if (Number.isFinite(m.signal[i])) expect(m.histogram[i]).toBeCloseTo(m.macd[i]! - m.signal[i]!, 9)
51
+ }
52
+ expect(finite(m.signal).length).toBeGreaterThan(20)
53
+ })
54
+
55
+ it('vwap is the running volume-weighted typical price', () => {
56
+ const b: OhlcBar[] = [{ o: 1, h: 3, l: 1, c: 2 }, { o: 2, h: 5, l: 3, c: 4 }]
57
+ // typical prices 2 and 4; volumes 1 and 3 -> (2*1 + 4*3) / 4 = 3.5
58
+ expect(vwap(b, [1, 3])).toEqual([2, 3.5])
59
+ })
60
+
61
+ it('atr seeds with the mean true range and then smooths', () => {
62
+ const b = bars(closes)
63
+ const out = atr(b, 14)
64
+ expect(out.slice(0, 13).every(Number.isNaN)).toBe(true)
65
+ expect(Number.isFinite(out[13])).toBe(true)
66
+ expect(finite(out).every((v) => v > 0)).toBe(true)
67
+ })
68
+
69
+ it('stochastic %K is the close inside the window range, %D its average', () => {
70
+ const b = bars(closes)
71
+ const { k, d } = stochastic(b, 5, 3)
72
+ expect(finite(k).every((v) => v >= 0 && v <= 100)).toBe(true)
73
+ expect(Number.isNaN(k[3])).toBe(true)
74
+ expect(Number.isFinite(k[4])).toBe(true)
75
+ expect(Number.isNaN(d[5])).toBe(true)
76
+ expect(d[6]).toBeCloseTo((k[4]! + k[5]! + k[6]!) / 3, 9)
77
+ })
78
+
79
+ it('obv adds volume on up closes and subtracts it on down closes', () => {
80
+ expect(obv([10, 11, 10.5, 10.5, 12], [100, 200, 300, 400, 500])).toEqual([0, 200, -100, -100, 400])
81
+ })
82
+
83
+ it('computeOverlay dispatches the new kinds', () => {
84
+ expect(computeOverlay([1, 2, 3, 4], 'wma:3')[3]).toBeCloseTo(20 / 6, 6)
85
+ expect(computeOverlay([2, 4, 4, 4, 5, 5, 7, 9], 'bb:8:2')[7]).toBe(5)
86
+ const b: OhlcBar[] = [{ o: 1, h: 3, l: 1, c: 2 }, { o: 2, h: 5, l: 3, c: 4 }]
87
+ expect(computeOverlay([2, 4], 'vwap', { ohlc: b, volumes: [1, 3] })).toEqual([2, 3.5])
88
+ // Without bars and volumes a vwap is gaps, not zeros.
89
+ expect(computeOverlay([2, 4], 'vwap').every(Number.isNaN)).toBe(true)
90
+ })
91
+ })
92
+
93
+ describe('indicatorPane', () => {
94
+ const price: ChartSpec = {
95
+ type: 'candlestick',
96
+ categories: closes.map((_, i) => `2026-01-${String(i + 1).padStart(2, '0')}`),
97
+ series: [{ label: 'ACME', values: closes, ohlc: bars(closes), volumes: closes.map((_, i) => 100 + i) }],
98
+ xType: 'ordinal-time',
99
+ height: 300,
100
+ }
101
+
102
+ it('builds an RSI pane with the 30 / 70 lines and a fixed 0..100 axis', () => {
103
+ const pane = indicatorPane(price, { kind: 'rsi' })
104
+ expect(pane.type).toBe('line')
105
+ expect(pane.categories).toBe(price.categories)
106
+ expect(pane.referenceLines?.map((r) => r.value)).toEqual([30, 70])
107
+ expect(pane.yAxis).toMatchObject({ min: 0, max: 100 })
108
+ expect(pane.height).toBe(100)
109
+ expect(pane.series[0]!.values).toHaveLength(closes.length)
110
+ })
111
+
112
+ it('builds a MACD pane as a histogram with two lines on top', () => {
113
+ const pane = indicatorPane(price, { kind: 'macd', params: { fast: 3, slow: 6, signal: 2 } })
114
+ expect(pane.type).toBe('bar')
115
+ expect(pane.series.map((s) => s.label)).toEqual(['Histogram', 'MACD', 'Signal'])
116
+ expect(pane.series[1]!.type).toBe('line')
117
+ expect(pane.title).toBe('MACD 3, 6, 2')
118
+ })
119
+
120
+ it('colours volume bars by candle direction', () => {
121
+ const pane = indicatorPane(price, { kind: 'volume', height: 80 })
122
+ expect(pane.series[0]!.values).toEqual(price.series[0]!.volumes)
123
+ expect(pane.series[0]!.colors![1]).toBe('#ef4444')
124
+ expect(pane.series[0]!.colors![2]).toBe('#16a34a')
125
+ expect(pane.height).toBe(80)
126
+ })
127
+
128
+ it('reads a plain line series as closes when there are no bars', () => {
129
+ const line: ChartSpec = { type: 'line', categories: price.categories, series: [{ label: 'x', values: closes }] }
130
+ const pane = indicatorPane(line, { kind: 'rsi', params: { period: 5 } })
131
+ expect(finite(pane.series[0]!.values).length).toBe(closes.length - 5)
132
+ expect(finite(indicatorPane(line, { kind: 'stochastic' }).series[0]!.values).length).toBeGreaterThan(0)
133
+ })
134
+ })
135
+
136
+ describe('resampleOhlc / rowsToOhlcSpec', () => {
137
+ const days = ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-12', '2026-01-13']
138
+ const ohlc: OhlcBar[] = [
139
+ { o: 10, h: 12, l: 9, c: 11 }, { o: 11, h: 15, l: 10, c: 14 }, { o: 14, h: 14, l: 8, c: 9 },
140
+ { o: 9, h: 10, l: 7, c: 8 }, { o: 8, h: 11, l: 8, c: 10 }, { o: 10, h: 13, l: 9, c: 12 }, { o: 12, h: 16, l: 11, c: 15 },
141
+ ]
142
+ const volumes = [1, 2, 3, 4, 5, 6, 7]
143
+
144
+ it('rolls daily bars into weekly ones: first open, max high, min low, last close, summed volume', () => {
145
+ const out = resampleOhlc(days, ohlc, { bucket: 'week', volumes })
146
+ expect(out.categories).toEqual(['2026-01-05', '2026-01-12'])
147
+ expect(out.ohlc[0]).toEqual({ o: 10, h: 15, l: 7, c: 10 })
148
+ expect(out.ohlc[1]).toEqual({ o: 10, h: 16, l: 9, c: 15 })
149
+ expect(out.volumes).toEqual([15, 13])
150
+ })
151
+
152
+ it('takes the open from the earliest bar and the close from the latest, whatever the row order', () => {
153
+ const shuffled = [3, 0, 4, 1, 2]
154
+ const out = resampleOhlc(shuffled.map((i) => days[i]!), shuffled.map((i) => ohlc[i]!), { bucket: 'week' })
155
+ expect(out.ohlc[0]).toEqual({ o: 10, h: 15, l: 7, c: 10 })
156
+ })
157
+
158
+ it('rowsToOhlcSpec sorts rows by date, keeps volume on the series and can bucket', () => {
159
+ const rows = days.map((d, i) => ({ day: d, o: ohlc[i]!.o, h: ohlc[i]!.h, l: ohlc[i]!.l, c: ohlc[i]!.c, v: volumes[i]! })).reverse()
160
+ const spec = rowsToOhlcSpec(rows, { date: 'day', open: 'o', high: 'h', low: 'l', close: 'c', volume: 'v', lastPriceLine: true })
161
+ expect(spec.type).toBe('candlestick')
162
+ expect(spec.xType).toBe('ordinal-time')
163
+ expect(spec.categories).toEqual(days)
164
+ expect(spec.series[0]!.volumes).toEqual(volumes)
165
+ expect(spec.series[0]!.values).toEqual(ohlc.map((b) => b.c))
166
+ expect(spec.lastPriceLine).toBe(true)
167
+ const weekly = rowsToOhlcSpec(rows, { date: 'day', open: 'o', high: 'h', low: 'l', close: 'c', volume: 'v', bucket: 'week', label: 'ACME' })
168
+ expect(weekly.categories).toEqual(['2026-01-05', '2026-01-12'])
169
+ expect(weekly.series[0]!.label).toBe('ACME')
170
+ expect(weekly.series[0]!.volumes).toEqual([15, 13])
171
+ })
172
+ })
173
+
174
+ describe('last price, bands, flags and drawings in the layout', () => {
175
+ const cats = ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05']
176
+ const spec: ChartSpec = {
177
+ type: 'line',
178
+ categories: cats,
179
+ series: [{ label: 'p', values: [10, 12, 11, 15, 13], overlay: 'bb:3:2' }],
180
+ lastPriceLine: true,
181
+ annotations: [{ at: { category: '2026-01-02' }, label: 'E', shape: 'flag', text: 'Earnings call' }],
182
+ drawings: [
183
+ { id: 't', kind: 'trend', points: [{ x: '2026-01-01', y: 10 }, { x: '2026-01-05', y: 13 }] },
184
+ { id: 'r', kind: 'hray', points: [{ x: '2026-01-03', y: 12 }] },
185
+ { id: 'f', kind: 'fib', points: [{ x: '2026-01-01', y: 10 }, { x: '2026-01-04', y: 15 }] },
186
+ { id: 'lost', kind: 'trend', points: [{ x: 'nowhere', y: 1 }, { x: '2026-01-05', y: 13 }] },
187
+ ],
188
+ width: 600,
189
+ height: 300,
190
+ }
191
+ const geo = buildChart(spec, 'light')
192
+
193
+ it('draws the last close as a red pill line (it closed down) at the value', () => {
194
+ const pill = geo.referenceLines.find((r) => r.pill)!
195
+ expect(pill).toBeTruthy()
196
+ expect(pill.color).toBe('#ef4444')
197
+ expect(pill.label).toBe('13')
198
+ const last = geo.lines[0]!.points[4]!
199
+ expect(pill.y).toBe(last.y)
200
+ })
201
+
202
+ it('a Bollinger overlay carries a band path', () => {
203
+ expect(geo.overlays).toHaveLength(1)
204
+ expect(geo.overlays[0]!.bandPath).toMatch(/^M.*Z$/)
205
+ })
206
+
207
+ it('annotations keep their shape and text', () => {
208
+ expect(geo.annotations[0]).toMatchObject({ shape: 'flag', text: 'Earnings call', label: 'E' })
209
+ })
210
+
211
+ it('drawings resolve to pixels, fib gets six levels, a ray a value label, an unplaceable one is dropped', () => {
212
+ expect(geo.drawings.map((d) => d.id)).toEqual(['t', 'r', 'f'])
213
+ const t = geo.drawings[0]!
214
+ expect(t.points[0]).toEqual({ x: geo.lines[0]!.points[0]!.x, y: geo.lines[0]!.points[0]!.y })
215
+ expect(geo.drawings[1]!.label).toBe('12')
216
+ const f = geo.drawings[2]!
217
+ expect(f.levels).toHaveLength(6)
218
+ expect(f.levels![0]!.ratio).toBe(0)
219
+ expect(f.levels![5]!.ratio).toBe(1)
220
+ // 0% is at the second point's value (15), 100% at the first (10).
221
+ expect(f.levels![0]!.y).toBe(geo.lines[0]!.points[3]!.y)
222
+ expect(f.levels![5]!.y).toBe(geo.lines[0]!.points[0]!.y)
223
+ })
224
+
225
+ it('drawings outside a zoom window are dropped rather than drawn at a wrong x', () => {
226
+ const zoomed = buildChart({ ...spec, categories: cats.slice(2), series: [{ label: 'p', values: [11, 15, 13] }] }, 'light')
227
+ expect(zoomed.drawings.map((d) => d.id)).toEqual(['r'])
228
+ })
229
+ })
230
+
231
+ describe('applyChartFormat', () => {
232
+ const base: ChartSpec = {
233
+ type: 'bar',
234
+ categories: ['a', 'b'],
235
+ series: [{ label: 'Plan', values: [1, 2] }, { label: 'Actual', values: [2, 3] }],
236
+ yAxis: { min: 0 },
237
+ }
238
+
239
+ it('merges titles, axes, palette and per-series style into a copy', () => {
240
+ const out = applyChartFormat(base, {
241
+ title: 'Sales', subtitle: '', caption: 'Q1',
242
+ yAxis: { min: null, max: 10, title: 'Units', format: 'compact' },
243
+ xAxis: { gridLines: true, labelRotation: -45 },
244
+ series: { Actual: { color: '#f00', type: 'line', axis: 'right', marker: 'square', strokeWidth: 3, smooth: true }, Missing: { color: '#0f0' } },
245
+ palette: ['#111', '#222'],
246
+ })
247
+ expect(out).not.toBe(base)
248
+ expect(base.title).toBeUndefined()
249
+ expect(out.title).toBe('Sales')
250
+ expect(out.subtitle).toBeUndefined()
251
+ expect(out.caption).toBe('Q1')
252
+ expect(out.yAxis).toEqual({ max: 10, title: 'Units', format: 'compact' })
253
+ expect(out.xAxis).toEqual({ gridLines: true, labelRotation: -45 })
254
+ expect(out.palette).toEqual(['#111', '#222'])
255
+ expect(out.series[0]).toEqual(base.series[0])
256
+ expect(out.series[1]).toMatchObject({ color: '#f00', type: 'line', axis: 'right', marker: 'square', strokeWidth: 3, smooth: true })
257
+ expect(out.series).toHaveLength(2)
258
+ })
259
+
260
+ it('a null or empty format is the same spec', () => {
261
+ expect(applyChartFormat(base, null)).toBe(base)
262
+ expect(applyChartFormat(base, {})).toEqual(base)
263
+ })
264
+
265
+ it('writes series labels, a stack group, the style and a compact rule; crosshair pills stay a component prop', () => {
266
+ const withRule: ChartSpec = { ...base, responsive: [{ maxWidth: 200, spec: { title: 'tiny' } }], style: { gridColor: '#eee' } }
267
+ const out = applyChartFormat(withRule, {
268
+ seriesLabels: true,
269
+ crosshairLabels: false,
270
+ compactBelow: 420,
271
+ series: { Plan: { stack: 'a' }, Actual: { stack: '' } },
272
+ style: { fontSize: 14, background: '#fff' },
273
+ })
274
+ expect(out.seriesLabels).toBe(true)
275
+ expect((out as Record<string, unknown>).crosshairLabels).toBeUndefined()
276
+ // The spec's own rule first, the preset appended.
277
+ expect(out.responsive).toHaveLength(2)
278
+ expect(out.responsive![0]).toEqual(withRule.responsive![0])
279
+ expect(out.responsive![1]).toEqual(CHART_RESPONSIVE_PRESETS.compact(420))
280
+ expect(out.responsive![1]!.legend).toBe(false)
281
+ expect(out.responsive![1]!.spec).toMatchObject({ seriesLabels: false, dataLabels: { show: false }, xAxis: { labelRotation: 90 } })
282
+ expect(withRule.responsive).toHaveLength(1)
283
+ expect(out.series[0]!.stack).toBe('a')
284
+ expect(out.series[1]!.stack).toBeUndefined()
285
+ // Style merges over what the spec carries.
286
+ expect(out.style).toEqual({ gridColor: '#eee', fontSize: 14, background: '#fff' })
287
+ // Off or unset leaves the spec alone.
288
+ expect(applyChartFormat(base, { compactBelow: null, seriesLabels: false }).responsive).toBeUndefined()
289
+ expect(applyChartFormat(base, { compactBelow: 0 }).responsive).toBeUndefined()
290
+ expect(applyChartFormat(base, { seriesLabels: false }).seriesLabels).toBe(false)
291
+ })
292
+ })
293
+
294
+ describe('regression fits', () => {
295
+ const close = (a: number[], b: number[], digits = 6) => a.forEach((v, i) => expect(v).toBeCloseTo(b[i]!, digits))
296
+
297
+ it('poly:2 fits a parabola exactly, with r2 = 1 and an equation', () => {
298
+ const values = Array.from({ length: 12 }, (_, i) => 3 * i * i - 2 * i + 5)
299
+ const fit = polynomialFit(values, 2)
300
+ close(fit.fitted, values, 6)
301
+ expect(fit.r2).toBeCloseTo(1, 9)
302
+ expect(fit.equation).toMatch(/^y = .* u\^2/)
303
+ // Through the overlay dispatch too, with the fit attached.
304
+ const ovl = computeOverlayFit(values, 'poly:2')
305
+ close(ovl.values, values, 6)
306
+ expect(ovl.r2).toBeCloseTo(1, 9)
307
+ expect(ovl.equation).toBe(fit.equation)
308
+ // Plain computeOverlay keeps its old signature.
309
+ close(computeOverlay(values, 'poly:2'), values, 6)
310
+ })
311
+
312
+ it('exp fits 2^i within 1e-9 and skips non-positive values', () => {
313
+ const values = Array.from({ length: 10 }, (_, i) => Math.pow(2, i))
314
+ const fit = exponentialFit(values)
315
+ close(fit.fitted, values, 6)
316
+ expect(fit.r2).toBeCloseTo(1, 9)
317
+ expect(fit.equation).toMatch(/e\^\(0\.693 x\)/)
318
+ const holes = exponentialFit([1, 2, 0, 8, -1, 32])
319
+ expect(Number.isNaN(holes.fitted[2])).toBe(true)
320
+ expect(Number.isNaN(holes.fitted[4])).toBe(true)
321
+ expect(holes.fitted[5]).toBeCloseTo(32, 6)
322
+ })
323
+
324
+ it('log and power fits recover their generating functions', () => {
325
+ const logs = Array.from({ length: 20 }, (_, i) => 4 + 1.5 * Math.log(i + 1))
326
+ const lf = logarithmicFit(logs)
327
+ close(lf.fitted, logs, 6)
328
+ expect(lf.r2).toBeCloseTo(1, 9)
329
+ const pows = Array.from({ length: 20 }, (_, i) => 2 * Math.pow(i + 1, 1.7))
330
+ const pf = powerFit(pows)
331
+ close(pf.fitted, pows, 4)
332
+ expect(pf.r2).toBeCloseTo(1, 9)
333
+ expect(pf.equation).toMatch(/\^1\.7$/)
334
+ const lin = linearFit([1, 3, 5, 7])
335
+ expect(lin.equation).toBe('y = 2 x + 1')
336
+ expect(lin.r2).toBe(1)
337
+ })
338
+
339
+ it('a noisy series gets an r2 under 1, rSquared is bounded, and the degree is clamped to 6', () => {
340
+ const noisy = [1, 4, 2, 8, 5, 7, 9, 6, 11, 10]
341
+ const fit = polynomialFit(noisy, 3)
342
+ expect(fit.r2).toBeGreaterThan(0.5)
343
+ expect(fit.r2).toBeLessThan(1)
344
+ expect(rSquared([1, 1, 1], [1, 1, 1])).toBe(1)
345
+ expect(rSquared([1, 2, 3], [9, 9, 9])).toBe(0)
346
+ expect(polynomialFit(noisy, 12).equation).toContain('u^6')
347
+ expect(polynomialFit(noisy, 12).equation).not.toContain('u^7')
348
+ // Too few points for the degree: all NaN, no equation.
349
+ expect(polynomialFit([1, 2], 3).equation).toBe('')
350
+ expect(computeOverlayFit([1, 2, 3], 'nope' as never).values.every(Number.isNaN)).toBe(true)
351
+ })
352
+
353
+ it('overlayName reads the way a legend would', () => {
354
+ expect(overlayName('sma:20')).toBe('SMA 20')
355
+ expect(overlayName('poly:3')).toBe('poly 3')
356
+ expect(overlayName('bb:20:2')).toBe('BB 20')
357
+ expect(overlayName('linear')).toBe('trend')
358
+ expect(overlayName('exp')).toBe('exp')
359
+ })
360
+
361
+ it('buildChart carries r2 and the equation on a regression overlay line only', () => {
362
+ const spec: ChartSpec = {
363
+ type: 'line',
364
+ categories: ['a', 'b', 'c', 'd', 'e'],
365
+ series: [
366
+ { label: 'fit', values: [1, 2, 3, 4, 5], overlay: 'linear' },
367
+ { label: 'avg', values: [1, 2, 3, 4, 5], overlay: 'sma:2' },
368
+ ],
369
+ width: 400,
370
+ height: 240,
371
+ }
372
+ const geo = buildChart(spec)
373
+ expect(geo.overlays).toHaveLength(2)
374
+ expect(geo.overlays[0]!.r2).toBe(1)
375
+ expect(geo.overlays[0]!.equation).toBe('y = 1 x + 1')
376
+ expect(geo.overlays[1]!.r2).toBeUndefined()
377
+ expect(geo.overlays[1]!.equation).toBeUndefined()
378
+ })
379
+ })
380
+
381
+ describe('regression fits on real x (a scatter\'s y on x)', () => {
382
+ it('fits y on the given x values, predicts anywhere, and keeps the index form when no x is given', () => {
383
+ const xs = [10, 20, 30, 40]
384
+ const ys = xs.map((x) => 2 * x + 1)
385
+ const fit = linearFit(ys, xs)
386
+ expect(fit.r2).toBeCloseTo(1, 9)
387
+ expect(fit.predict(55)).toBeCloseTo(111, 9)
388
+ expect(fit.equation).toBe('y = 2 x + 1')
389
+ // Index form: the same numbers read as (0, 21), (1, 41), ... slope 20.
390
+ const byIndex = linearFit(ys)
391
+ expect(byIndex.predict(4)).toBeCloseTo(101, 9)
392
+ expect(byIndex.equation).toBe('y = 20 x + 21')
393
+ })
394
+
395
+ it('polynomial, exponential, logarithmic and power fits take x and recover their generators', () => {
396
+ const xs = [1, 2, 4, 8, 16, 32, 64]
397
+ const poly = polynomialFit(xs.map((x) => 0.5 * x * x - 3 * x + 7), 2, xs)
398
+ expect(poly.r2).toBeCloseTo(1, 9)
399
+ expect(poly.predict(10)).toBeCloseTo(0.5 * 100 - 30 + 7, 6)
400
+ const exp = exponentialFit(xs.map((x) => 3 * Math.exp(0.05 * x)), xs)
401
+ expect(exp.r2).toBeCloseTo(1, 9)
402
+ expect(exp.predict(20)).toBeCloseTo(3 * Math.exp(1), 6)
403
+ const log = logarithmicFit(xs.map((x) => 2 + 5 * Math.log(x)), xs)
404
+ expect(log.r2).toBeCloseTo(1, 9)
405
+ expect(log.predict(100)).toBeCloseTo(2 + 5 * Math.log(100), 6)
406
+ expect(log.equation).toContain('ln(x)')
407
+ const pow = powerFit(xs.map((x) => 1.5 * Math.pow(x, 1.7)), xs)
408
+ expect(pow.r2).toBeCloseTo(1, 9)
409
+ expect(pow.predict(100)).toBeCloseTo(1.5 * Math.pow(100, 1.7), 4)
410
+ // Non-positive x has no logarithm: skipped, not poisoned.
411
+ const withZero = logarithmicFit([1, 2, 3, 4], [0, 1, 2, 3])
412
+ expect(Number.isNaN(withZero.fitted[0])).toBe(true)
413
+ expect(Number.isFinite(withZero.fitted[1])).toBe(true)
414
+ expect(regressionFit([1, 2], 'sma:2')).toBeNull()
415
+ })
416
+
417
+ it('a scatter series with an overlay gets a fitted curve across the plot, with its R-squared', () => {
418
+ const points = [1, 2, 3, 4, 5, 6, 7, 8].map((x) => ({ x, y: 3 * x + 2 }))
419
+ const spec: ChartSpec = {
420
+ type: 'scatter', categories: [], width: 400, height: 300,
421
+ series: [{ label: 'A', values: [], points, overlay: 'linear' }, { label: 'B', values: [], points: points.map((p) => ({ x: p.x, y: p.y * 2 })) }],
422
+ }
423
+ const geo = buildChart(spec)
424
+ expect(geo.overlays).toHaveLength(1)
425
+ const ovl = geo.overlays[0]!
426
+ expect(ovl.label).toBe('A (linear)')
427
+ expect(ovl.r2).toBeCloseTo(1, 9)
428
+ expect(ovl.equation).toBe('y = 3 x + 2')
429
+ // Sampled across the x domain: the first point sits at the plot's left
430
+ // edge, the last at its right, and the curve is inside the y domain.
431
+ expect(ovl.points.length).toBe(49)
432
+ expect(ovl.points[0]!.x).toBeCloseTo(geo.plot.x, 0)
433
+ expect(ovl.points[48]!.x).toBeCloseTo(geo.plot.x + geo.plot.w, 0)
434
+ expect(ovl.points.every((p) => p.defined)).toBe(true)
435
+ expect(ovl.path.startsWith('M')).toBe(true)
436
+ // On a log x axis the samples are even in log space.
437
+ const logGeo = buildChart({ ...spec, xAxis: { scale: 'log' } })
438
+ const xsLog = logGeo.overlays[0]!.points.map((p) => p.x)
439
+ const gapA = xsLog[1]! - xsLog[0]!
440
+ const gapB = xsLog[48]! - xsLog[47]!
441
+ expect(Math.abs(gapA - gapB)).toBeLessThan(1)
442
+ })
443
+ })