@svgrid/grid 3.0.2 → 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.
- package/README.md +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
- package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
- package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
- package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
- package/dist/cdn/chart-CKJRCPOk.js +0 -1652
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistics the chart engine computes from raw values: five-number summaries,
|
|
3
|
+
* regression and moving-average overlays. DOM-free, spec-free.
|
|
4
|
+
*/
|
|
5
|
+
import type { BoxStats, ChartReducer } from './chart-types'
|
|
6
|
+
|
|
7
|
+
// The two trend primitives live in their own leaf (see chart-trend.ts for
|
|
8
|
+
// why) and are re-exported here so every importer of chart-stats keeps working.
|
|
9
|
+
export { linearTrend, pearson } from './chart-trend'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
|
|
13
|
+
*
|
|
14
|
+
* Whiskers stop at the last observation INSIDE the fence rather than at the
|
|
15
|
+
* fence itself, which is what makes them read as real data; anything past them
|
|
16
|
+
* comes back in `outliers`. Quartiles use linear interpolation between the two
|
|
17
|
+
* neighbouring order statistics.
|
|
18
|
+
*
|
|
19
|
+
* Returns `null` for an empty sample, so a category with no observations is a
|
|
20
|
+
* gap rather than a box drawn at zero.
|
|
21
|
+
*/
|
|
22
|
+
export function boxStats(sample: ReadonlyArray<number>, whisker = 1.5): BoxStats | null {
|
|
23
|
+
const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b)
|
|
24
|
+
if (!v.length) return null
|
|
25
|
+
const q = (p: number) => {
|
|
26
|
+
const pos = (v.length - 1) * p
|
|
27
|
+
const lo = Math.floor(pos)
|
|
28
|
+
const hi = Math.ceil(pos)
|
|
29
|
+
return lo === hi ? v[lo]! : v[lo]! + (v[hi]! - v[lo]!) * (pos - lo)
|
|
30
|
+
}
|
|
31
|
+
const q1 = q(0.25)
|
|
32
|
+
const median = q(0.5)
|
|
33
|
+
const q3 = q(0.75)
|
|
34
|
+
const fenceLo = q1 - whisker * (q3 - q1)
|
|
35
|
+
const fenceHi = q3 + whisker * (q3 - q1)
|
|
36
|
+
const inside = v.filter((n) => n >= fenceLo && n <= fenceHi)
|
|
37
|
+
const outliers = v.filter((n) => n < fenceLo || n > fenceHi)
|
|
38
|
+
return {
|
|
39
|
+
// `inside` can only be empty if every point is an outlier, which the fence
|
|
40
|
+
// rule makes impossible (q1 and q3 are always within it) - but a degenerate
|
|
41
|
+
// sample should still produce a box rather than `undefined` coordinates.
|
|
42
|
+
min: inside.length ? inside[0]! : v[0]!,
|
|
43
|
+
q1,
|
|
44
|
+
median,
|
|
45
|
+
q3,
|
|
46
|
+
max: inside.length ? inside[inside.length - 1]! : v[v.length - 1]!,
|
|
47
|
+
...(outliers.length ? { outliers } : {}),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A regression's result: the fitted value at every index (NaN where the
|
|
53
|
+
* source was non-finite or the model cannot take the value), R-squared
|
|
54
|
+
* against the finite source values, the equation, and `predict`, the model
|
|
55
|
+
* at any x. With no `xs` the x of a value is its index, which is what a
|
|
56
|
+
* category chart's overlay wants; a scatter passes its points' x values, so
|
|
57
|
+
* the fit is y on x and `predict` draws the curve across the plot.
|
|
58
|
+
*/
|
|
59
|
+
export type RegressionFit = { fitted: number[]; r2: number; equation: string; predict: (x: number) => number }
|
|
60
|
+
|
|
61
|
+
const NO_FIT = (values: ReadonlyArray<number>): RegressionFit => ({ fitted: values.map(() => NaN), r2: 0, equation: '', predict: () => NaN })
|
|
62
|
+
/** The x of each value: the caller's, else the index. */
|
|
63
|
+
const xsOf = (values: ReadonlyArray<number>, xs?: ReadonlyArray<number>): number[] => (xs ? values.map((_, i) => xs[i] ?? NaN) : values.map((_, i) => i))
|
|
64
|
+
|
|
65
|
+
/** R-squared of a fit: 1 minus residual over total variance across the
|
|
66
|
+
* indices where both the value and the fit are finite. 0 when there is no
|
|
67
|
+
* variance to explain. */
|
|
68
|
+
export function rSquared(values: ReadonlyArray<number>, fitted: ReadonlyArray<number>): number {
|
|
69
|
+
let n = 0
|
|
70
|
+
let mean = 0
|
|
71
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
72
|
+
if (Number.isFinite(values[i]) && Number.isFinite(fitted[i])) { n += 1; mean += values[i]! }
|
|
73
|
+
}
|
|
74
|
+
if (n < 2) return 0
|
|
75
|
+
mean /= n
|
|
76
|
+
let ssRes = 0
|
|
77
|
+
let ssTot = 0
|
|
78
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
79
|
+
if (!Number.isFinite(values[i]) || !Number.isFinite(fitted[i])) continue
|
|
80
|
+
ssRes += (values[i]! - fitted[i]!) ** 2
|
|
81
|
+
ssTot += (values[i]! - mean) ** 2
|
|
82
|
+
}
|
|
83
|
+
if (ssTot === 0) return ssRes === 0 ? 1 : 0
|
|
84
|
+
return Math.max(0, Math.min(1, 1 - ssRes / ssTot))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fmtCoef = (c: number) => (Math.abs(c) >= 1000 || (Math.abs(c) < 0.001 && c !== 0) ? c.toExponential(2) : String(Math.round(c * 1000) / 1000))
|
|
88
|
+
|
|
89
|
+
/** Solve a small linear system by Gaussian elimination with partial pivoting;
|
|
90
|
+
* null when singular. */
|
|
91
|
+
function solve(a: number[][], b: number[]): number[] | null {
|
|
92
|
+
const n = b.length
|
|
93
|
+
const m = a.map((row, i) => [...row, b[i]!])
|
|
94
|
+
for (let c = 0; c < n; c += 1) {
|
|
95
|
+
let p = c
|
|
96
|
+
for (let r = c + 1; r < n; r += 1) if (Math.abs(m[r]![c]!) > Math.abs(m[p]![c]!)) p = r
|
|
97
|
+
if (Math.abs(m[p]![c]!) < 1e-12) return null
|
|
98
|
+
;[m[c], m[p]] = [m[p]!, m[c]!]
|
|
99
|
+
for (let r = 0; r < n; r += 1) {
|
|
100
|
+
if (r === c) continue
|
|
101
|
+
const f = m[r]![c]! / m[c]![c]!
|
|
102
|
+
for (let k = c; k <= n; k += 1) m[r]![k]! -= f * m[c]![k]!
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return m.map((row, i) => row[n]! / row[i]!)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Least-squares polynomial of `degree` (clamped to 1..6) through
|
|
110
|
+
* (x, values[i]), x the index or the caller's `xs`. The x values are
|
|
111
|
+
* normalised to [-1, 1] before the normal equations are solved, so a
|
|
112
|
+
* degree-6 fit over a thousand points does not lose the low coefficients to
|
|
113
|
+
* rounding. Non-finite values are skipped and fitted as NaN.
|
|
114
|
+
*/
|
|
115
|
+
export function polynomialFit(values: ReadonlyArray<number>, degree: number, xs?: ReadonlyArray<number>): RegressionFit {
|
|
116
|
+
const d = Math.max(1, Math.min(6, Math.round(degree)))
|
|
117
|
+
const n = values.length
|
|
118
|
+
const nan = NO_FIT(values)
|
|
119
|
+
const X = xsOf(values, xs)
|
|
120
|
+
const idx: number[] = []
|
|
121
|
+
for (let i = 0; i < n; i += 1) if (Number.isFinite(values[i]) && Number.isFinite(X[i])) idx.push(i)
|
|
122
|
+
if (idx.length <= d) return nan
|
|
123
|
+
let lo = Infinity
|
|
124
|
+
let hi = -Infinity
|
|
125
|
+
for (const i of idx) { lo = Math.min(lo, X[i]!); hi = Math.max(hi, X[i]!) }
|
|
126
|
+
if (!xs) { lo = 0; hi = Math.max(1, n - 1) }
|
|
127
|
+
const scale = Math.max(hi - lo, 1e-12)
|
|
128
|
+
const nx = (x: number) => (2 * (x - lo)) / scale - 1
|
|
129
|
+
// Normal equations: sum x^(j+k) c_k = sum x^j y.
|
|
130
|
+
const a: number[][] = Array.from({ length: d + 1 }, () => new Array(d + 1).fill(0))
|
|
131
|
+
const b: number[] = new Array(d + 1).fill(0)
|
|
132
|
+
for (const i of idx) {
|
|
133
|
+
const x = nx(X[i]!)
|
|
134
|
+
const y = values[i]!
|
|
135
|
+
const pow: number[] = [1]
|
|
136
|
+
for (let k = 1; k <= 2 * d; k += 1) pow.push(pow[k - 1]! * x)
|
|
137
|
+
for (let j = 0; j <= d; j += 1) {
|
|
138
|
+
b[j]! += pow[j]! * y
|
|
139
|
+
for (let k = 0; k <= d; k += 1) a[j]![k]! += pow[j + k]!
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const c = solve(a, b)
|
|
143
|
+
if (!c) return nan
|
|
144
|
+
const predict = (x: number) => {
|
|
145
|
+
const u = nx(x)
|
|
146
|
+
let y = 0
|
|
147
|
+
let p = 1
|
|
148
|
+
for (let k = 0; k <= d; k += 1) { y += c[k]! * p; p *= u }
|
|
149
|
+
return y
|
|
150
|
+
}
|
|
151
|
+
const fitted = values.map((v, i) => (Number.isFinite(v) && Number.isFinite(X[i]) ? predict(X[i]!) : NaN))
|
|
152
|
+
const terms = c.map((ck, k) => (k === 0 ? fmtCoef(ck) : `${fmtCoef(ck)} u^${k}`)).reverse()
|
|
153
|
+
const u = xs ? `u = 2(x - ${fmtCoef(lo)})/${fmtCoef(scale)} - 1` : `u = 2x/${scale} - 1`
|
|
154
|
+
return { fitted, r2: rSquared(values, fitted), equation: `y = ${terms.join(' + ')} (${u})`, predict }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Ordinary least squares of values on their index, or on `xs`, as a {@link RegressionFit}. */
|
|
158
|
+
export function linearFit(values: ReadonlyArray<number>, xs?: ReadonlyArray<number>): RegressionFit {
|
|
159
|
+
const X = xsOf(values, xs)
|
|
160
|
+
const { slope, intercept, ok } = lineThrough(X, values)
|
|
161
|
+
if (!ok) return NO_FIT(values)
|
|
162
|
+
const predict = (x: number) => slope * x + intercept
|
|
163
|
+
const fitted = values.map((v, i) => (Number.isFinite(v) && Number.isFinite(X[i]) ? predict(X[i]!) : NaN))
|
|
164
|
+
return { fitted, r2: rSquared(values, fitted), equation: `y = ${fmtCoef(slope)} x + ${fmtCoef(intercept)}`, predict }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Fit y = a * e^(b x) by least squares on ln y, x the index or `xs`;
|
|
168
|
+
* values at or below zero cannot be logged and are skipped (fitted as NaN). */
|
|
169
|
+
export function exponentialFit(values: ReadonlyArray<number>, xs?: ReadonlyArray<number>): RegressionFit {
|
|
170
|
+
const X = xsOf(values, xs)
|
|
171
|
+
const logs = values.map((v) => (Number.isFinite(v) && v > 0 ? Math.log(v) : NaN))
|
|
172
|
+
const { slope: b, intercept, ok } = lineThrough(X, logs)
|
|
173
|
+
if (!ok) return NO_FIT(values)
|
|
174
|
+
const a = Math.exp(intercept)
|
|
175
|
+
const predict = (x: number) => a * Math.exp(b * x)
|
|
176
|
+
const fitted = values.map((v, i) => (Number.isFinite(v) && v > 0 && Number.isFinite(X[i]) ? predict(X[i]!) : NaN))
|
|
177
|
+
return { fitted, r2: rSquared(values, fitted), equation: `y = ${fmtCoef(a)} e^(${fmtCoef(b)} x)`, predict }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fit y = a + b ln(x + 1) by least squares (x the index, shifted by one so
|
|
181
|
+
* the first point has a logarithm), or y = a + b ln(x) on `xs`, where
|
|
182
|
+
* points with x at or below zero are skipped. */
|
|
183
|
+
export function logarithmicFit(values: ReadonlyArray<number>, xs?: ReadonlyArray<number>): RegressionFit {
|
|
184
|
+
const shift = xs ? 0 : 1
|
|
185
|
+
const lx = xsOf(values, xs).map((x) => (x + shift > 0 ? Math.log(x + shift) : NaN))
|
|
186
|
+
const { slope, intercept, ok } = lineThrough(lx, values)
|
|
187
|
+
if (!ok) return NO_FIT(values)
|
|
188
|
+
const predict = (x: number) => (x + shift > 0 ? intercept + slope * Math.log(x + shift) : NaN)
|
|
189
|
+
const fitted = values.map((v, i) => (Number.isFinite(v) && Number.isFinite(lx[i]) ? intercept + slope * lx[i]! : NaN))
|
|
190
|
+
const arg = xs ? 'x' : 'x + 1'
|
|
191
|
+
return { fitted, r2: rSquared(values, fitted), equation: `y = ${fmtCoef(intercept)} + ${fmtCoef(slope)} ln(${arg})`, predict }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Fit y = a (x + 1)^b by least squares on ln y against ln(x + 1), or
|
|
195
|
+
* y = a x^b on `xs`; values (and x on `xs`) at or below zero are skipped
|
|
196
|
+
* (fitted as NaN). */
|
|
197
|
+
export function powerFit(values: ReadonlyArray<number>, xs?: ReadonlyArray<number>): RegressionFit {
|
|
198
|
+
const shift = xs ? 0 : 1
|
|
199
|
+
const lx = xsOf(values, xs).map((x) => (x + shift > 0 ? Math.log(x + shift) : NaN))
|
|
200
|
+
const logs = values.map((v) => (Number.isFinite(v) && v > 0 ? Math.log(v) : NaN))
|
|
201
|
+
const { slope, intercept, ok } = lineThrough(lx, logs)
|
|
202
|
+
if (!ok) return NO_FIT(values)
|
|
203
|
+
const a = Math.exp(intercept)
|
|
204
|
+
const predict = (x: number) => (x + shift > 0 ? a * Math.pow(x + shift, slope) : NaN)
|
|
205
|
+
const fitted = values.map((v, i) => (Number.isFinite(v) && v > 0 && Number.isFinite(lx[i]) ? a * Math.exp(slope * lx[i]!) : NaN))
|
|
206
|
+
const arg = xs ? 'x' : '(x + 1)'
|
|
207
|
+
return { fitted, r2: rSquared(values, fitted), equation: `y = ${fmtCoef(a)} ${arg}^${fmtCoef(slope)}`, predict }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Least squares of y on arbitrary x, skipping non-finite pairs. */
|
|
211
|
+
function lineThrough(xs: ReadonlyArray<number>, ys: ReadonlyArray<number>): { slope: number; intercept: number; ok: boolean } {
|
|
212
|
+
let n = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
|
|
213
|
+
for (let i = 0; i < ys.length; i += 1) {
|
|
214
|
+
const x = xs[i]!
|
|
215
|
+
const y = ys[i]!
|
|
216
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
217
|
+
n += 1; sx += x; sy += y; sxx += x * x; sxy += x * y
|
|
218
|
+
}
|
|
219
|
+
if (n < 2) return { slope: 0, intercept: 0, ok: false }
|
|
220
|
+
const denom = n * sxx - sx * sx
|
|
221
|
+
const slope = denom === 0 ? 0 : (n * sxy - sx * sy) / denom
|
|
222
|
+
return { slope, intercept: (sy - slope * sx) / n, ok: true }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Simple moving average over a window of `period` values. Window centres
|
|
226
|
+
* trail to the right (typical for time-series). NaN for points before the
|
|
227
|
+
* window is full. */
|
|
228
|
+
export function simpleMovingAverage(values: number[], period: number): number[] {
|
|
229
|
+
if (period < 1) return values.slice()
|
|
230
|
+
const out: number[] = new Array(values.length).fill(NaN)
|
|
231
|
+
let sum = 0, count = 0
|
|
232
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
233
|
+
const v = values[i]!
|
|
234
|
+
if (Number.isFinite(v)) { sum += v; count += 1 }
|
|
235
|
+
if (i >= period) {
|
|
236
|
+
const drop = values[i - period]!
|
|
237
|
+
if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
|
|
238
|
+
}
|
|
239
|
+
if (i >= period - 1 && count > 0) out[i] = sum / count
|
|
240
|
+
}
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
245
|
+
export function exponentialMovingAverage(values: number[], period: number): number[] {
|
|
246
|
+
const alpha = 2 / (Math.max(1, period) + 1)
|
|
247
|
+
const out: number[] = new Array(values.length).fill(NaN)
|
|
248
|
+
let prev: number | null = null
|
|
249
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
250
|
+
const v = values[i]!
|
|
251
|
+
if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
|
|
252
|
+
prev = prev == null ? v : alpha * v + (1 - alpha) * prev
|
|
253
|
+
out[i] = prev
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
/** The reducers that need every observation, not a running sum and count. */
|
|
260
|
+
export function reducerNeedsSamples(reducer: ChartReducer): boolean {
|
|
261
|
+
return !(reducer === 'sum' || reducer === 'avg' || reducer === 'count')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Collapse a group's observations to one number. `sum` / `avg` / `count` are
|
|
266
|
+
* what `rowsToChartSpec` always did; the rest are the ones a pivot table
|
|
267
|
+
* offers and a chart panel was missing: `min`, `max`, `median`, `first`,
|
|
268
|
+
* `last`, `countDistinct`, and `pN` for the Nth percentile (`'p90'`). An
|
|
269
|
+
* empty group reduces to 0 for `sum` / `count` and to NaN otherwise, so a
|
|
270
|
+
* missing bucket draws as a gap rather than as a fake zero.
|
|
271
|
+
*/
|
|
272
|
+
export function reduceValues(samples: ReadonlyArray<number>, reducer: ChartReducer): number {
|
|
273
|
+
const n = samples.length
|
|
274
|
+
switch (reducer) {
|
|
275
|
+
case 'count':
|
|
276
|
+
return n
|
|
277
|
+
case 'sum': {
|
|
278
|
+
let s = 0
|
|
279
|
+
for (const v of samples) s += v
|
|
280
|
+
return s
|
|
281
|
+
}
|
|
282
|
+
case 'avg': {
|
|
283
|
+
if (!n) return 0
|
|
284
|
+
let s = 0
|
|
285
|
+
for (const v of samples) s += v
|
|
286
|
+
return s / n
|
|
287
|
+
}
|
|
288
|
+
case 'min':
|
|
289
|
+
return n ? Math.min(...samples) : Number.NaN
|
|
290
|
+
case 'max':
|
|
291
|
+
return n ? Math.max(...samples) : Number.NaN
|
|
292
|
+
case 'first':
|
|
293
|
+
return n ? samples[0]! : Number.NaN
|
|
294
|
+
case 'last':
|
|
295
|
+
return n ? samples[n - 1]! : Number.NaN
|
|
296
|
+
case 'countDistinct':
|
|
297
|
+
return new Set(samples).size
|
|
298
|
+
case 'median':
|
|
299
|
+
return percentile(samples, 50)
|
|
300
|
+
default: {
|
|
301
|
+
const m = /^p(\d+(?:\.\d+)?)$/.exec(reducer)
|
|
302
|
+
return m ? percentile(samples, Number(m[1])) : Number.NaN
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The p-th percentile (0..100) by linear interpolation between order statistics. */
|
|
308
|
+
export function percentile(samples: ReadonlyArray<number>, p: number): number {
|
|
309
|
+
const v = samples.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b)
|
|
310
|
+
if (!v.length) return Number.NaN
|
|
311
|
+
const pos = (v.length - 1) * Math.max(0, Math.min(100, p)) / 100
|
|
312
|
+
const lo = Math.floor(pos)
|
|
313
|
+
const hi = Math.ceil(pos)
|
|
314
|
+
return lo === hi ? v[lo]! : v[lo]! + (v[hi]! - v[lo]!) * (pos - lo)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** How `rowsToChartSpec` groups dates when `bucket` is set. */
|
|
318
|
+
export type ChartTimeBucket = 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The ISO date (`YYYY-MM-DD`, UTC) that starts the bucket containing `date`:
|
|
322
|
+
* the day itself, the Monday of its week, the first of its month, quarter or
|
|
323
|
+
* year. Returns `''` for a value that is not a date, so the caller can drop
|
|
324
|
+
* the row rather than file it under 1970.
|
|
325
|
+
*/
|
|
326
|
+
export function bucketStart(date: string | number | Date, bucket: ChartTimeBucket): string {
|
|
327
|
+
const t = date instanceof Date ? date.getTime() : typeof date === 'number' ? date : Date.parse(date)
|
|
328
|
+
if (!Number.isFinite(t)) return ''
|
|
329
|
+
const d = new Date(t)
|
|
330
|
+
const y = d.getUTCFullYear()
|
|
331
|
+
const m = d.getUTCMonth()
|
|
332
|
+
let out: Date
|
|
333
|
+
switch (bucket) {
|
|
334
|
+
case 'day':
|
|
335
|
+
out = new Date(Date.UTC(y, m, d.getUTCDate()))
|
|
336
|
+
break
|
|
337
|
+
case 'week': {
|
|
338
|
+
// Monday-based, which is what a business calendar reads.
|
|
339
|
+
const day = (d.getUTCDay() + 6) % 7
|
|
340
|
+
out = new Date(Date.UTC(y, m, d.getUTCDate() - day))
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
case 'month':
|
|
344
|
+
out = new Date(Date.UTC(y, m, 1))
|
|
345
|
+
break
|
|
346
|
+
case 'quarter':
|
|
347
|
+
out = new Date(Date.UTC(y, m - (m % 3), 1))
|
|
348
|
+
break
|
|
349
|
+
default:
|
|
350
|
+
out = new Date(Date.UTC(y, 0, 1))
|
|
351
|
+
}
|
|
352
|
+
return out.toISOString().slice(0, 10)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** What {@link binValues} returns: one category per bin plus the edges. */
|
|
356
|
+
export type ChartBins = {
|
|
357
|
+
/** Bin midpoints as strings, ready to be the categories of a numeric axis. */
|
|
358
|
+
categories: string[]
|
|
359
|
+
/** Observations per bin. */
|
|
360
|
+
counts: number[]
|
|
361
|
+
/** Bin edges, one more than there are bins. */
|
|
362
|
+
edges: number[]
|
|
363
|
+
/** Width of every bin. */
|
|
364
|
+
width: number
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Bin a raw sample for a histogram. The bin count comes from `bins`, from a
|
|
369
|
+
* `binWidth`, or from a rule: `'sturges'` (default, log2 n + 1), `'fd'`
|
|
370
|
+
* (Freedman-Diaconis, 2 IQR / cbrt n) or `'sqrt'`. The range is the data's
|
|
371
|
+
* extent unless `min` / `max` pin it; the top edge is inclusive. Edges are
|
|
372
|
+
* rounded to a sensible precision for the width, so labels read as `10, 20,
|
|
373
|
+
* 30` rather than `10.000000001`.
|
|
374
|
+
*/
|
|
375
|
+
export function binValues(
|
|
376
|
+
values: ReadonlyArray<number>,
|
|
377
|
+
opts: { bins?: number; binWidth?: number; method?: 'sturges' | 'fd' | 'sqrt'; min?: number; max?: number } = {},
|
|
378
|
+
): ChartBins {
|
|
379
|
+
const v = values.filter((n) => Number.isFinite(n))
|
|
380
|
+
if (!v.length) return { categories: [], counts: [], edges: [], width: 0 }
|
|
381
|
+
const lo = opts.min ?? Math.min(...v)
|
|
382
|
+
let hi = opts.max ?? Math.max(...v)
|
|
383
|
+
if (hi <= lo) hi = lo + 1
|
|
384
|
+
let count: number
|
|
385
|
+
if (opts.bins && opts.bins > 0) count = Math.round(opts.bins)
|
|
386
|
+
else if (opts.binWidth && opts.binWidth > 0) count = Math.max(1, Math.ceil((hi - lo) / opts.binWidth))
|
|
387
|
+
else if (opts.method === 'sqrt') count = Math.ceil(Math.sqrt(v.length))
|
|
388
|
+
else if (opts.method === 'fd') {
|
|
389
|
+
const iqr = percentile(v, 75) - percentile(v, 25)
|
|
390
|
+
const w = iqr > 0 ? (2 * iqr) / Math.cbrt(v.length) : 0
|
|
391
|
+
count = w > 0 ? Math.ceil((hi - lo) / w) : Math.ceil(Math.log2(v.length) + 1)
|
|
392
|
+
} else count = Math.ceil(Math.log2(v.length) + 1)
|
|
393
|
+
count = Math.max(1, Math.min(1000, count))
|
|
394
|
+
const width = opts.binWidth && opts.binWidth > 0 ? opts.binWidth : (hi - lo) / count
|
|
395
|
+
// Precision from the width: a 0.25 width keeps two decimals, a 10 width none.
|
|
396
|
+
const decimals = width >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(width)) + 1)
|
|
397
|
+
const fix = (n: number) => Number(n.toFixed(decimals))
|
|
398
|
+
const edges: number[] = []
|
|
399
|
+
for (let i = 0; i <= count; i += 1) edges.push(fix(lo + i * width))
|
|
400
|
+
const counts = new Array<number>(count).fill(0)
|
|
401
|
+
for (const n of v) {
|
|
402
|
+
let i = Math.floor((n - lo) / width)
|
|
403
|
+
if (i >= count) i = count - 1
|
|
404
|
+
if (i < 0) i = 0
|
|
405
|
+
counts[i] = (counts[i] ?? 0) + 1
|
|
406
|
+
}
|
|
407
|
+
const categories = counts.map((_, i) => String(fix(lo + (i + 0.5) * width)))
|
|
408
|
+
return { categories, counts, edges, width }
|
|
409
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { appendPoints } from './chart-stream'
|
|
3
|
+
import { PER_CATEGORY_SERIES_KEYS } from './chart-decimate'
|
|
4
|
+
import type { ChartSpec } from './chart-types'
|
|
5
|
+
|
|
6
|
+
const base = (): ChartSpec => ({
|
|
7
|
+
type: 'line',
|
|
8
|
+
categories: ['t1', 't2'],
|
|
9
|
+
series: [
|
|
10
|
+
{ label: 'a', values: [1, 2] },
|
|
11
|
+
{ label: 'b', values: [10, 20] },
|
|
12
|
+
],
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('appendPoints', () => {
|
|
16
|
+
it('appends one category and one value per series, by index or by label', () => {
|
|
17
|
+
const next = appendPoints(base(), { category: 't3', values: [3, 30] })
|
|
18
|
+
expect(next.categories).toEqual(['t1', 't2', 't3'])
|
|
19
|
+
expect(next.series.map((s) => s.values)).toEqual([[1, 2, 3], [10, 20, 30]])
|
|
20
|
+
const byLabel = appendPoints(base(), { category: 't3', values: { b: 33 } })
|
|
21
|
+
expect(byLabel.series[0]!.values[2]).toBeNaN()
|
|
22
|
+
expect(byLabel.series[1]!.values[2]).toBe(33)
|
|
23
|
+
const many = appendPoints(base(), [{ category: 't3', values: [3, 30] }, { category: 't4', values: [4, 40] }])
|
|
24
|
+
expect(many.categories).toHaveLength(4)
|
|
25
|
+
expect(many.series[1]!.values).toEqual([10, 20, 30, 40])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('keeps every per-category array parallel: bands, row ids, candles, volumes, errors, colours', () => {
|
|
29
|
+
const spec: ChartSpec = {
|
|
30
|
+
type: 'candlestick',
|
|
31
|
+
categories: ['d1'],
|
|
32
|
+
series: [
|
|
33
|
+
{ label: 'px', values: [10], ohlc: [{ o: 9, h: 11, l: 8, c: 10 }], volumes: [100], rowIds: [[1]], colors: ['#f00'], errors: [1], upperValues: [11], lowerValues: [9] },
|
|
34
|
+
{ label: 'sig', values: [5] },
|
|
35
|
+
],
|
|
36
|
+
waterfallTotals: [false],
|
|
37
|
+
}
|
|
38
|
+
const next = appendPoints(spec, { category: 'd2', values: [12, 6], ohlc: { o: 10, h: 13, l: 10, c: 12 }, volume: 120, rowIds: [[2], [9]], upper: [13], lower: [11] })
|
|
39
|
+
const px = next.series[0]!
|
|
40
|
+
for (const key of PER_CATEGORY_SERIES_KEYS) {
|
|
41
|
+
const arr = px[key] as unknown[] | undefined
|
|
42
|
+
if (arr) expect(arr.length, key).toBe(2)
|
|
43
|
+
}
|
|
44
|
+
expect(px.ohlc![1]).toEqual({ o: 10, h: 13, l: 10, c: 12 })
|
|
45
|
+
expect(px.volumes).toEqual([100, 120])
|
|
46
|
+
expect(px.rowIds).toEqual([[1], [2]])
|
|
47
|
+
expect(px.upperValues).toEqual([11, 13])
|
|
48
|
+
expect(px.lowerValues).toEqual([9, 11])
|
|
49
|
+
// Not given for this point: a gap, not a shorter array.
|
|
50
|
+
expect(px.colors).toEqual(['#f00', null])
|
|
51
|
+
expect(px.errors![1]).toBeNull()
|
|
52
|
+
// A series without row ids grows them only because the point brings one.
|
|
53
|
+
expect(next.series[1]!.rowIds).toEqual([[], [9]])
|
|
54
|
+
expect(next.series[1]!.ohlc).toBeUndefined()
|
|
55
|
+
expect(next.waterfallTotals).toEqual([false, false])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('trims to the window from the right and leaves a short spec alone', () => {
|
|
59
|
+
const s = appendPoints(base(), [{ category: 't3', values: [3, 30] }, { category: 't4', values: [4, 40] }], { window: 3 })
|
|
60
|
+
expect(s.categories).toEqual(['t2', 't3', 't4'])
|
|
61
|
+
expect(s.series[0]!.values).toEqual([2, 3, 4])
|
|
62
|
+
const short = appendPoints(base(), { category: 't3', values: [3, 30] }, { window: 10 })
|
|
63
|
+
expect(short.categories).toHaveLength(3)
|
|
64
|
+
expect(appendPoints(base(), [], { window: 1 }).categories).toEqual(['t2'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not mutate the input spec or its arrays', () => {
|
|
68
|
+
const spec = base()
|
|
69
|
+
const before = JSON.stringify(spec)
|
|
70
|
+
const next = appendPoints(spec, { category: 't3', values: [3, 30] }, { window: 2 })
|
|
71
|
+
expect(JSON.stringify(spec)).toBe(before)
|
|
72
|
+
expect(next.series[0]!.values).not.toBe(spec.series[0]!.values)
|
|
73
|
+
expect(next.categories).not.toBe(spec.categories)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live data. A feed hands a chart one new point every few hundred
|
|
3
|
+
* milliseconds; the spec is immutable state, so each tick wants a new spec
|
|
4
|
+
* with the point appended and the oldest dropped, with every per-category
|
|
5
|
+
* array kept in step. Demo 159 rebuilt the whole spec from a rows array on
|
|
6
|
+
* every tick; `appendPoints` does the append in one call and keeps the
|
|
7
|
+
* confidence bands, row ids, candles and volumes parallel by construction,
|
|
8
|
+
* because it walks the same key list the zoom window and decimation use.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChartSpec, ChartSeries, OhlcBar } from './chart-types'
|
|
11
|
+
import { PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS, pickCategories } from './chart-decimate'
|
|
12
|
+
|
|
13
|
+
/** One new category with its values, for {@link appendPoints}. */
|
|
14
|
+
export type ChartPoint = {
|
|
15
|
+
/** The category label (a date string on a time axis). */
|
|
16
|
+
category: string
|
|
17
|
+
/** One value per series in spec order, or values keyed by series label;
|
|
18
|
+
* a series not named gets a gap (NaN). */
|
|
19
|
+
values: ReadonlyArray<number> | Record<string, number>
|
|
20
|
+
/** The bar for a candlestick series (the first series with `ohlc`). */
|
|
21
|
+
ohlc?: OhlcBar | null
|
|
22
|
+
/** The volume for that series. */
|
|
23
|
+
volume?: number
|
|
24
|
+
/** Row ids behind the point, one array per series in spec order. */
|
|
25
|
+
rowIds?: ReadonlyArray<ReadonlyArray<string | number>>
|
|
26
|
+
/** Confidence band ends, one per series in spec order. */
|
|
27
|
+
upper?: ReadonlyArray<number>
|
|
28
|
+
lower?: ReadonlyArray<number>
|
|
29
|
+
/** Range low / bullet target, one per series in spec order. */
|
|
30
|
+
low?: ReadonlyArray<number>
|
|
31
|
+
target?: ReadonlyArray<number>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What {@link appendPoints} takes besides the points. */
|
|
35
|
+
export type AppendPointsOptions = {
|
|
36
|
+
/** Keep only the last `window` categories after appending. */
|
|
37
|
+
window?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The gap value each per-category array takes when a point does not set it. */
|
|
41
|
+
function gapFor(key: (typeof PER_CATEGORY_SERIES_KEYS)[number]): unknown {
|
|
42
|
+
switch (key) {
|
|
43
|
+
case 'rowIds': return []
|
|
44
|
+
case 'ohlc': case 'boxes': case 'markers': case 'colors': case 'errors': return null
|
|
45
|
+
default: return Number.NaN
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Append one or more points to a spec and return the new spec, trimmed to
|
|
51
|
+
* the trailing `window` categories when one is given. Every array a series
|
|
52
|
+
* carries per category (values, row ids, bands, candles, volumes, colours,
|
|
53
|
+
* markers, errors, lows, targets) grows by the same count, with a gap where
|
|
54
|
+
* the point says nothing; so do the spec-level ones (waterfall totals). The
|
|
55
|
+
* input is never mutated: only the arrays that grow are copied, which keeps
|
|
56
|
+
* Svelte's state diffing cheap at 4 Hz.
|
|
57
|
+
*/
|
|
58
|
+
export function appendPoints(spec: ChartSpec, points: ChartPoint | ReadonlyArray<ChartPoint>, opts: AppendPointsOptions = {}): ChartSpec {
|
|
59
|
+
const list = Array.isArray(points) ? (points as ReadonlyArray<ChartPoint>) : [points as ChartPoint]
|
|
60
|
+
if (!list.length) return opts.window ? trim(spec, opts.window) : spec
|
|
61
|
+
const categories = [...spec.categories, ...list.map((p) => p.category)]
|
|
62
|
+
const ohlcOwner = spec.series.findIndex((s) => s.ohlc)
|
|
63
|
+
const series = spec.series.map((s, si) => {
|
|
64
|
+
const next: ChartSeries = { ...s, values: [...s.values] }
|
|
65
|
+
for (const p of list) {
|
|
66
|
+
const v = Array.isArray(p.values) ? (p.values as ReadonlyArray<number>)[si] : (p.values as Record<string, number>)[s.label]
|
|
67
|
+
next.values.push(typeof v === 'number' ? v : Number.NaN)
|
|
68
|
+
}
|
|
69
|
+
for (const key of PER_CATEGORY_SERIES_KEYS) {
|
|
70
|
+
if (key === 'values') continue
|
|
71
|
+
const has = s[key] !== undefined
|
|
72
|
+
// A key the series does not carry is only created when a point brings
|
|
73
|
+
// it (a candle, a volume), so a plain line stays a plain line.
|
|
74
|
+
const brings = list.some((p) =>
|
|
75
|
+
(key === 'ohlc' && p.ohlc !== undefined && si === (ohlcOwner < 0 ? 0 : ohlcOwner)) ||
|
|
76
|
+
(key === 'volumes' && p.volume !== undefined && si === (ohlcOwner < 0 ? 0 : ohlcOwner)) ||
|
|
77
|
+
(key === 'rowIds' && p.rowIds?.[si] !== undefined) ||
|
|
78
|
+
(key === 'upperValues' && p.upper?.[si] !== undefined) ||
|
|
79
|
+
(key === 'lowerValues' && p.lower?.[si] !== undefined) ||
|
|
80
|
+
(key === 'lowValues' && p.low?.[si] !== undefined) ||
|
|
81
|
+
(key === 'targets' && p.target?.[si] !== undefined))
|
|
82
|
+
if (!has && !brings) continue
|
|
83
|
+
const base: unknown[] = has ? [...(s[key] as unknown[])] : new Array(s.values.length).fill(gapFor(key))
|
|
84
|
+
for (const p of list) {
|
|
85
|
+
let v: unknown = undefined
|
|
86
|
+
if (key === 'ohlc' && si === (ohlcOwner < 0 ? 0 : ohlcOwner)) v = p.ohlc
|
|
87
|
+
else if (key === 'volumes' && si === (ohlcOwner < 0 ? 0 : ohlcOwner)) v = p.volume
|
|
88
|
+
else if (key === 'rowIds') v = p.rowIds?.[si]
|
|
89
|
+
else if (key === 'upperValues') v = p.upper?.[si]
|
|
90
|
+
else if (key === 'lowerValues') v = p.lower?.[si]
|
|
91
|
+
else if (key === 'lowValues') v = p.low?.[si]
|
|
92
|
+
else if (key === 'targets') v = p.target?.[si]
|
|
93
|
+
base.push(v === undefined ? gapFor(key) : v)
|
|
94
|
+
}
|
|
95
|
+
;(next as unknown as Record<string, unknown>)[key] = base
|
|
96
|
+
}
|
|
97
|
+
return next
|
|
98
|
+
})
|
|
99
|
+
let out: ChartSpec = { ...spec, categories, series }
|
|
100
|
+
for (const key of PER_CATEGORY_SPEC_KEYS) {
|
|
101
|
+
const arr = spec[key] as unknown[] | undefined
|
|
102
|
+
if (arr) (out as unknown as Record<string, unknown>)[key] = [...arr, ...list.map(() => false)]
|
|
103
|
+
}
|
|
104
|
+
if (opts.window) out = trim(out, opts.window)
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The last `window` categories of a spec (the spec itself when it fits). */
|
|
109
|
+
function trim(spec: ChartSpec, window: number): ChartSpec {
|
|
110
|
+
const n = spec.categories.length
|
|
111
|
+
if (window <= 0 || n <= window) return spec
|
|
112
|
+
const indices = Array.from({ length: window }, (_, k) => n - window + k)
|
|
113
|
+
return pickCategories(spec, indices)
|
|
114
|
+
}
|