@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,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { chartSummary } from './chart-summary'
|
|
3
|
+
import { CHART_TYPES } from './chart-validate'
|
|
4
|
+
import { sampleChartSpec } from './chart-samples'
|
|
5
|
+
import { pearson } from './chart-stats'
|
|
6
|
+
import type { ChartSpec } from './chart-types'
|
|
7
|
+
|
|
8
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
|
|
9
|
+
|
|
10
|
+
describe('chartSummary', () => {
|
|
11
|
+
it('reads a rising line with its ends, change, peak and trough', () => {
|
|
12
|
+
const s = chartSummary({ type: 'line', categories: months, series: [{ label: 'Revenue', values: [100, 180, 90, 140, 160, 150] }] })
|
|
13
|
+
expect(s).toBe('Revenue rises 50% from 100 at Jan to 150 at Jun, peaking at 180 at Feb, lowest at 90 at Mar.')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reads a falling series, a flat one, and caps the series it lists', () => {
|
|
17
|
+
const spec: ChartSpec = {
|
|
18
|
+
type: 'bar',
|
|
19
|
+
categories: months,
|
|
20
|
+
series: [
|
|
21
|
+
{ label: 'Cost', values: [200, 190, 170, 160, 150, 120] },
|
|
22
|
+
{ label: 'Flat', values: [50, 50.2, 49.9, 50.1, 50, 50] },
|
|
23
|
+
{ label: 'A', values: [1, 2, 3, 4, 5, 6] },
|
|
24
|
+
{ label: 'B', values: [1, 2, 3, 4, 5, 6] },
|
|
25
|
+
{ label: 'C', values: [1, 2, 3, 4, 5, 6] },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
const s = chartSummary(spec)
|
|
29
|
+
expect(s).toContain('Cost falls 40% from 200 at Jan to 120 at Jun.')
|
|
30
|
+
expect(s).toContain('Flat holds at about 50 from Jan to Jun.')
|
|
31
|
+
expect(s).toContain('And 1 more series.')
|
|
32
|
+
expect(chartSummary(spec, { maxSeries: 2 })).toContain('And 3 more series.')
|
|
33
|
+
// Hidden series are left out; the formatter is honoured.
|
|
34
|
+
const hidden = chartSummary({ ...spec, series: [{ ...spec.series[0]!, visible: false }, spec.series[1]!] }, { formatValue: (v) => `$${v}` })
|
|
35
|
+
expect(hidden).toBe('Flat holds at about $50 from Jan to Jun.')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shares the top three slices of a pie and a tree map', () => {
|
|
39
|
+
expect(chartSummary({ type: 'pie', categories: ['A', 'B', 'C', 'D', 'E'], series: [{ label: 'v', values: [50, 25, 15, 6, 4] }] })).toBe(
|
|
40
|
+
'5 slices totalling 100: A 50%, B 25%, C 15%, 2 more share the rest.',
|
|
41
|
+
)
|
|
42
|
+
expect(chartSummary({ type: 'treemap', categories: [], series: [], treemap: { name: 'r', children: [{ name: 'X', value: 3 }, { name: 'Y', children: [{ name: 'y1', value: 1 }] }] } })).toBe(
|
|
43
|
+
'2 nodes totalling 4: X 75%, Y 25%.',
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('reports the correlation of a scatter, the last close of candles, and the gauge against its target', () => {
|
|
48
|
+
const pts = Array.from({ length: 20 }, (_, i) => ({ x: i, y: 2 * i + (i % 2) }))
|
|
49
|
+
expect(chartSummary({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: pts }] })).toMatch(/^20 points in 1 series with a strong positive correlation \(r (?:0\.9\d|1\.00)\)/)
|
|
50
|
+
const anti = pts.map((p) => ({ x: p.x, y: -p.y }))
|
|
51
|
+
expect(chartSummary({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: anti }] })).toContain('strong negative')
|
|
52
|
+
expect(pearson([1, 2, 3], [1, 1, 1])).toBe(0)
|
|
53
|
+
const candles = chartSummary({
|
|
54
|
+
type: 'candlestick',
|
|
55
|
+
categories: ['d1', 'd2', 'd3'],
|
|
56
|
+
series: [{ label: 'ACME', values: [10, 11, 12], ohlc: [{ o: 10, h: 12, l: 9, c: 10 }, { o: 10, h: 13, l: 10, c: 11 }, { o: 11, h: 14, l: 11, c: 12.1 }] }],
|
|
57
|
+
})
|
|
58
|
+
expect(candles).toBe('ACME: last close 12.1, up 10% on the session, ranging 9 to 14 over 3 bars.')
|
|
59
|
+
expect(chartSummary({ type: 'gauge', categories: [], series: [], gaugeValue: 72, gaugeMin: 0, gaugeMax: 100, gaugeTarget: 80, gaugeUnit: '%' })).toBe(
|
|
60
|
+
'72 %, 72% of the way from 0 to 100, below the target of 80.',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reads a heat map, a calendar, a funnel, a box plot and a sankey', () => {
|
|
65
|
+
expect(chartSummary({ type: 'heatmap', categories: ['c1', 'c2'], series: [{ label: 'r1', values: [1, 9] }, { label: 'r2', values: [4, 2] }] })).toBe(
|
|
66
|
+
'2 rows by 2 columns; the highest cell is 9 at r1 and c2.',
|
|
67
|
+
)
|
|
68
|
+
expect(chartSummary({ type: 'calendar', categories: [], series: [], calendarValues: [{ date: '2026-01-01', value: 2 }, { date: '2026-01-02', value: 7 }] })).toBe(
|
|
69
|
+
'2 days totalling 9; the busiest is 2026-01-02 at 7.',
|
|
70
|
+
)
|
|
71
|
+
expect(chartSummary({ type: 'funnel', categories: ['Visit', 'Sign up', 'Pay'], series: [{ label: 'n', values: [1000, 300, 150] }] })).toBe(
|
|
72
|
+
'15% of 1k at Visit reach Pay; the biggest drop is into Sign up (70% lost).',
|
|
73
|
+
)
|
|
74
|
+
expect(chartSummary({ type: 'boxplot', categories: ['a'], series: [{ label: 'X', values: [5], boxes: [{ min: 1, q1: 4, median: 5, q3: 6, max: 9 }] }, { label: 'Y', values: [5], boxes: [{ min: 0, q1: 2, median: 5, q3: 9, max: 12 }] }] })).toBe(
|
|
75
|
+
'2 distributions over 1 categories; Y has the widest interquartile range at 7.',
|
|
76
|
+
)
|
|
77
|
+
expect(chartSummary({ type: 'sankey', categories: [], series: [], sankeyNodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], sankeyLinks: [{ source: 'a', target: 'b', value: 3 }, { source: 'a', target: 'c', value: 5 }] })).toBe(
|
|
78
|
+
'2 flows totalling 8; the largest is a to c at 5.',
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('never throws on an empty spec of any type, and describes every sample spec', () => {
|
|
83
|
+
for (const type of CHART_TYPES) {
|
|
84
|
+
expect(chartSummary({ type, categories: [], series: [] }), type).toBe('No data.')
|
|
85
|
+
const s = chartSummary(sampleChartSpec(type))
|
|
86
|
+
expect(typeof s, type).toBe('string')
|
|
87
|
+
expect(s.length, type).toBeGreaterThan(8)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chart in a sentence or two. `chartSummary` reads a spec the way a
|
|
3
|
+
* colleague would read the picture: which way each series goes and by how
|
|
4
|
+
* much, where it peaks, what the biggest slices are, whether two measures
|
|
5
|
+
* move together, how the last session closed. `SvChart` puts the result in
|
|
6
|
+
* the SVG's `aria-description`, the context menu copies it, and the AI
|
|
7
|
+
* "explain this chart" prompt is grounded on it, so the words a screen
|
|
8
|
+
* reader hears and the words a model reasons from are the same words.
|
|
9
|
+
*
|
|
10
|
+
* Pure and dependency-light (two small leaves: the number formatter and the
|
|
11
|
+
* trend primitives, kept apart from the engine's modules so the chunk a
|
|
12
|
+
* bundler shares between this lazy module and the base stays tiny), so it
|
|
13
|
+
* runs on the server and in a test, and the renderer loads it lazily.
|
|
14
|
+
*/
|
|
15
|
+
import type { ChartSpec, ChartSeries } from './chart-types'
|
|
16
|
+
import { formatChartValue } from './chart-format'
|
|
17
|
+
import { linearTrend, pearson } from './chart-trend'
|
|
18
|
+
|
|
19
|
+
/** What {@link chartSummary} takes. */
|
|
20
|
+
export type ChartSummaryOptions = {
|
|
21
|
+
/** How numbers are written. Default: the spec's `valueFormat` / `locale` / `currency`. */
|
|
22
|
+
formatValue?: (v: number) => string
|
|
23
|
+
/** How many series a cartesian chart describes before it says "and N more". Default 4. */
|
|
24
|
+
maxSeries?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
|
28
|
+
|
|
29
|
+
/** First and last finite value of a series with their indices, or null. */
|
|
30
|
+
function ends(values: ReadonlyArray<number>): { i0: number; v0: number; i1: number; v1: number } | null {
|
|
31
|
+
let i0 = -1
|
|
32
|
+
let i1 = -1
|
|
33
|
+
for (let i = 0; i < values.length; i += 1) if (finite(values[i])) { if (i0 < 0) i0 = i; i1 = i }
|
|
34
|
+
if (i0 < 0) return null
|
|
35
|
+
return { i0, v0: values[i0]!, i1, v1: values[i1]! }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extremes(values: ReadonlyArray<number>): { max: number; iMax: number; min: number; iMin: number } | null {
|
|
39
|
+
let max = -Infinity, min = Infinity, iMax = -1, iMin = -1
|
|
40
|
+
values.forEach((v, i) => {
|
|
41
|
+
if (!finite(v)) return
|
|
42
|
+
if (v > max) { max = v; iMax = i }
|
|
43
|
+
if (v < min) { min = v; iMin = i }
|
|
44
|
+
})
|
|
45
|
+
return iMax < 0 ? null : { max, iMax, min, iMin }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pct = (from: number, to: number): string => {
|
|
49
|
+
if (from === 0) return ''
|
|
50
|
+
const p = Math.abs(((to - from) / Math.abs(from)) * 100)
|
|
51
|
+
return `${p >= 10 ? Math.round(p) : Math.round(p * 10) / 10}%`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function describeSeries(s: ChartSeries, categories: ReadonlyArray<string>, fmt: (v: number) => string): string {
|
|
55
|
+
const values = s.ohlc ? s.ohlc.map((b) => (b ? b.c : Number.NaN)) : s.values
|
|
56
|
+
const e = ends(values)
|
|
57
|
+
if (!e) return `${s.label} has no values.`
|
|
58
|
+
const at = (i: number) => categories[i] ?? `#${i + 1}`
|
|
59
|
+
if (e.i0 === e.i1) return `${s.label} is ${fmt(e.v0)} at ${at(e.i0)}.`
|
|
60
|
+
const slope = linearTrend(values)
|
|
61
|
+
const rise = slope[slope.length - 1]! - slope[0]!
|
|
62
|
+
const span = Math.abs(e.v1 - e.v0)
|
|
63
|
+
const scale = Math.max(Math.abs(e.v0), Math.abs(e.v1), 1e-9)
|
|
64
|
+
const flat = span / scale < 0.02 && Math.abs(rise) / scale < 0.05
|
|
65
|
+
const dir = flat ? 'holds at about' : e.v1 > e.v0 ? 'rises' : 'falls'
|
|
66
|
+
const change = pct(e.v0, e.v1)
|
|
67
|
+
let out = flat
|
|
68
|
+
? `${s.label} holds at about ${fmt(e.v1)} from ${at(e.i0)} to ${at(e.i1)}`
|
|
69
|
+
: `${s.label} ${dir}${change ? ` ${change}` : ''} from ${fmt(e.v0)} at ${at(e.i0)} to ${fmt(e.v1)} at ${at(e.i1)}`
|
|
70
|
+
const x = extremes(values)
|
|
71
|
+
if (x && !flat) {
|
|
72
|
+
const peakInside = x.iMax !== e.i0 && x.iMax !== e.i1
|
|
73
|
+
const troughInside = x.iMin !== e.i0 && x.iMin !== e.i1
|
|
74
|
+
if (peakInside) out += `, peaking at ${fmt(x.max)} at ${at(x.iMax)}`
|
|
75
|
+
if (troughInside) out += `, lowest at ${fmt(x.min)} at ${at(x.iMin)}`
|
|
76
|
+
}
|
|
77
|
+
return out + '.'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shares(labels: ReadonlyArray<string>, values: ReadonlyArray<number>, fmt: (v: number) => string, noun: string): string {
|
|
81
|
+
const total = values.reduce((a, v) => a + (finite(v) ? Math.abs(v) : 0), 0)
|
|
82
|
+
if (!total) return `No ${noun} has a value.`
|
|
83
|
+
const ranked = labels.map((l, i) => ({ l, v: finite(values[i]) ? Math.abs(values[i]!) : 0 })).sort((a, b) => b.v - a.v)
|
|
84
|
+
const top = ranked.slice(0, 3).map((r) => `${r.l} ${Math.round((r.v / total) * 100)}%`)
|
|
85
|
+
const rest = ranked.length > 3 ? `, ${ranked.length - 3} more share the rest` : ''
|
|
86
|
+
return `${labels.length} ${noun}s totalling ${fmt(total)}: ${top.join(', ')}${rest}.`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Describe a spec in plain words. Never throws: an empty spec of any type
|
|
91
|
+
* comes back as "No data." so a live region always has something to say.
|
|
92
|
+
*/
|
|
93
|
+
export function chartSummary(spec: ChartSpec, opts: ChartSummaryOptions = {}): string {
|
|
94
|
+
const fmt = opts.formatValue ?? ((v: number) => formatChartValue(v, spec.valueFormat, spec))
|
|
95
|
+
const max = opts.maxSeries ?? 4
|
|
96
|
+
const cats = spec.categories ?? []
|
|
97
|
+
const series = (spec.series ?? []).filter((s) => s.visible !== false)
|
|
98
|
+
const type = spec.type
|
|
99
|
+
try {
|
|
100
|
+
if (type === 'pie' || type === 'radial-bar' || type === 'nightingale') {
|
|
101
|
+
const s = series[0]
|
|
102
|
+
if (!s || !s.values.some(finite)) return 'No data.'
|
|
103
|
+
return shares(cats, s.values, fmt, 'slice')
|
|
104
|
+
}
|
|
105
|
+
if (type === 'treemap' || type === 'sunburst') {
|
|
106
|
+
const root = spec.treemap ?? spec.tree
|
|
107
|
+
const kids = root?.children ?? []
|
|
108
|
+
if (!kids.length) return 'No data.'
|
|
109
|
+
const total = (n: { value?: number; children?: Array<{ value?: number; children?: unknown[] }> }): number =>
|
|
110
|
+
n.value ?? (n.children ?? []).reduce((a, c) => a + total(c as never), 0)
|
|
111
|
+
return shares(kids.map((k) => k.name), kids.map((k) => total(k)), fmt, 'node')
|
|
112
|
+
}
|
|
113
|
+
if (type === 'gauge') {
|
|
114
|
+
if (!finite(spec.gaugeValue)) return 'No data.'
|
|
115
|
+
const lo = spec.gaugeMin ?? 0
|
|
116
|
+
const hi = spec.gaugeMax ?? 100
|
|
117
|
+
const share = hi > lo ? Math.round(((spec.gaugeValue - lo) / (hi - lo)) * 100) : 0
|
|
118
|
+
const target = finite(spec.gaugeTarget) ? `, ${spec.gaugeValue >= spec.gaugeTarget ? 'above' : 'below'} the target of ${fmt(spec.gaugeTarget)}` : ''
|
|
119
|
+
return `${fmt(spec.gaugeValue)}${spec.gaugeUnit ? ' ' + spec.gaugeUnit : ''}, ${share}% of the way from ${fmt(lo)} to ${fmt(hi)}${target}.`
|
|
120
|
+
}
|
|
121
|
+
if (type === 'scatter') {
|
|
122
|
+
const pts = series.flatMap((s) => s.points ?? []).filter((p) => finite(p.x) && finite(p.y))
|
|
123
|
+
if (!pts.length) return 'No data.'
|
|
124
|
+
const r = pearson(pts.map((p) => p.x), pts.map((p) => p.y))
|
|
125
|
+
const strength = Math.abs(r) >= 0.7 ? 'strong' : Math.abs(r) >= 0.4 ? 'moderate' : Math.abs(r) >= 0.2 ? 'weak' : 'no'
|
|
126
|
+
const sign = r > 0 ? 'positive' : 'negative'
|
|
127
|
+
const rel = strength === 'no' ? 'no clear correlation' : `a ${strength} ${sign} correlation (r ${r.toFixed(2)})`
|
|
128
|
+
return `${pts.length} points in ${series.length} series with ${rel} between x and y.`
|
|
129
|
+
}
|
|
130
|
+
if (type === 'candlestick' || type === 'ohlc') {
|
|
131
|
+
const s = series.find((x) => x.ohlc) ?? series[0]
|
|
132
|
+
const bars = (s?.ohlc ?? []).filter((b): b is NonNullable<typeof b> => !!b)
|
|
133
|
+
if (!s || !bars.length) return 'No data.'
|
|
134
|
+
const last = bars[bars.length - 1]!
|
|
135
|
+
const prev = bars.length > 1 ? bars[bars.length - 2]! : null
|
|
136
|
+
const hi = Math.max(...bars.map((b) => b.h))
|
|
137
|
+
const lo = Math.min(...bars.map((b) => b.l))
|
|
138
|
+
const move = prev ? `, ${last.c >= prev.c ? 'up' : 'down'} ${pct(prev.c, last.c)} on the session` : ''
|
|
139
|
+
return `${s.label}: last close ${fmt(last.c)}${move}, ranging ${fmt(lo)} to ${fmt(hi)} over ${bars.length} bars.`
|
|
140
|
+
}
|
|
141
|
+
if (type === 'heatmap') {
|
|
142
|
+
let best: { v: number; row: string; col: string } | null = null
|
|
143
|
+
for (const s of series) s.values.forEach((v, i) => { if (finite(v) && (!best || v > best.v)) best = { v, row: s.label, col: cats[i] ?? String(i) } })
|
|
144
|
+
if (!best) return 'No data.'
|
|
145
|
+
const b = best as { v: number; row: string; col: string }
|
|
146
|
+
return `${series.length} rows by ${cats.length} columns; the highest cell is ${fmt(b.v)} at ${b.row} and ${b.col}.`
|
|
147
|
+
}
|
|
148
|
+
if (type === 'calendar') {
|
|
149
|
+
const days = (spec.calendarValues ?? []).filter((d) => finite(d.value))
|
|
150
|
+
if (!days.length) return 'No data.'
|
|
151
|
+
const top = days.reduce((a, d) => (d.value > a.value ? d : a), days[0]!)
|
|
152
|
+
const total = days.reduce((a, d) => a + d.value, 0)
|
|
153
|
+
return `${days.length} days totalling ${fmt(total)}; the busiest is ${top.date} at ${fmt(top.value)}.`
|
|
154
|
+
}
|
|
155
|
+
if (type === 'funnel') {
|
|
156
|
+
const s = series[0]
|
|
157
|
+
const vals = (s?.values ?? []).filter(finite)
|
|
158
|
+
if (!s || vals.length < 2) return s && vals.length === 1 ? `${cats[0] ?? s.label}: ${fmt(vals[0]!)}.` : 'No data.'
|
|
159
|
+
const first = vals[0]!
|
|
160
|
+
const last = vals[vals.length - 1]!
|
|
161
|
+
const conv = first ? Math.round((last / first) * 100) : 0
|
|
162
|
+
let worstDrop = 0
|
|
163
|
+
let worstAt = ''
|
|
164
|
+
for (let i = 1; i < vals.length; i += 1) {
|
|
165
|
+
const drop = vals[i - 1]! ? 1 - vals[i]! / vals[i - 1]! : 0
|
|
166
|
+
if (drop > worstDrop) { worstDrop = drop; worstAt = cats[i] ?? `step ${i + 1}` }
|
|
167
|
+
}
|
|
168
|
+
return `${conv}% of ${fmt(first)} at ${cats[0] ?? 'the top'} reach ${cats[vals.length - 1] ?? 'the bottom'}${worstAt ? `; the biggest drop is into ${worstAt} (${Math.round(worstDrop * 100)}% lost)` : ''}.`
|
|
169
|
+
}
|
|
170
|
+
if (type === 'boxplot') {
|
|
171
|
+
let widest: { label: string; spread: number } | null = null
|
|
172
|
+
for (const s of series) for (const b of s.boxes ?? []) {
|
|
173
|
+
if (!b) continue
|
|
174
|
+
const spread = b.q3 - b.q1
|
|
175
|
+
if (!widest || spread > widest.spread) widest = { label: s.label, spread }
|
|
176
|
+
}
|
|
177
|
+
if (!widest) return 'No data.'
|
|
178
|
+
return `${series.length} distributions over ${cats.length} categories; ${widest.label} has the widest interquartile range at ${fmt(widest.spread)}.`
|
|
179
|
+
}
|
|
180
|
+
if (type === 'sankey' || type === 'chord') {
|
|
181
|
+
const links = spec.sankeyLinks ?? []
|
|
182
|
+
if (!links.length) return 'No data.'
|
|
183
|
+
const total = links.reduce((a, l) => a + (finite(l.value) ? l.value : 0), 0)
|
|
184
|
+
const top = [...links].sort((a, b) => b.value - a.value)[0]!
|
|
185
|
+
return `${links.length} flows totalling ${fmt(total)}; the largest is ${top.source} to ${top.target} at ${fmt(top.value)}.`
|
|
186
|
+
}
|
|
187
|
+
// Cartesian families: one sentence per series, up to the cap.
|
|
188
|
+
const usable = series.filter((s) => s.values.some(finite) || s.ohlc)
|
|
189
|
+
if (!usable.length) return 'No data.'
|
|
190
|
+
const shown = usable.slice(0, max)
|
|
191
|
+
const parts = shown.map((s) => describeSeries(s, cats, fmt))
|
|
192
|
+
const more = usable.length > shown.length ? ` And ${usable.length - shown.length} more series.` : ''
|
|
193
|
+
return `${parts.join(' ')}${more}`
|
|
194
|
+
} catch {
|
|
195
|
+
return 'No data.'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared hover and zoom between charts in the same `syncGroup`.
|
|
3
|
+
*
|
|
4
|
+
* A registry of groups whose entries are `$state`, so every chart that reads
|
|
5
|
+
* its group's entry re-renders when another chart in the group writes to it. Each write
|
|
6
|
+
* carries the writer's token so a chart can ignore its own echoes.
|
|
7
|
+
*/
|
|
8
|
+
import type { ChartZoomWindow } from './chart-types'
|
|
9
|
+
|
|
10
|
+
/** What one chart publishes for the others: the hovered category (its label,
|
|
11
|
+
* index and, when the category parses as a date, its time) and the window. */
|
|
12
|
+
export type ChartSyncState = {
|
|
13
|
+
hover: { label: string; index: number; time: number | null; from: symbol } | null
|
|
14
|
+
zoom: { window: ChartZoomWindow | null; count: number; from: symbol; labels: [string, string] | null } | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A plain map of reactive entries: looking a group up (which a chart does
|
|
18
|
+
// inside a $derived) must not itself be a state mutation, so the map is not
|
|
19
|
+
// reactive and each entry is.
|
|
20
|
+
const groups = new Map<string, ChartSyncState>()
|
|
21
|
+
|
|
22
|
+
/** The live entry for a group, created on first use. */
|
|
23
|
+
export function syncGroupState(name: string): ChartSyncState {
|
|
24
|
+
const existing = groups.get(name)
|
|
25
|
+
if (existing) return existing
|
|
26
|
+
const fresh = $state<ChartSyncState>({ hover: null, zoom: null })
|
|
27
|
+
groups.set(name, fresh)
|
|
28
|
+
return fresh
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Publish a hover (or clear it with null). */
|
|
32
|
+
export function publishSyncHover(name: string, hover: ChartSyncState['hover']): void {
|
|
33
|
+
syncGroupState(name).hover = hover
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Publish a zoom window (null = the whole axis) with the axis length it
|
|
37
|
+
* was made against and the category labels at its two ends, so a chart with
|
|
38
|
+
* a different axis can map it by date. */
|
|
39
|
+
export function publishSyncZoom(name: string, window: ChartZoomWindow | null, count: number, from: symbol, labels: [string, string] | null = null): void {
|
|
40
|
+
syncGroupState(name).zoom = { window, count, from, labels }
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { chartSpecToTable } from './chart-table'
|
|
3
|
+
import type { ChartSpec } from './chart-types'
|
|
4
|
+
|
|
5
|
+
describe('chartSpecToTable', () => {
|
|
6
|
+
it('lays a category chart out as one row per category and a column per series, in the chart format', () => {
|
|
7
|
+
const t = chartSpecToTable({
|
|
8
|
+
type: 'bar',
|
|
9
|
+
categories: ['Q1', 'Q2', 'Q1', 'Q2'],
|
|
10
|
+
categoryGroups: [{ label: '2025', span: 2 }, { label: '2026', span: 2 }],
|
|
11
|
+
series: [{ label: 'Americas', values: [1, 2, 3, Number.NaN] }, { label: 'EMEA', values: [4, 5, 6, 7] }],
|
|
12
|
+
valueFormat: 'currency',
|
|
13
|
+
currency: 'EUR',
|
|
14
|
+
})
|
|
15
|
+
expect(t.columns.map((c) => c.header)).toEqual(['Group', 'Category', 'Americas', 'EMEA'])
|
|
16
|
+
expect(t.columns[2]).toMatchObject({ cellDataType: 'number', format: { type: 'currency', currency: 'EUR' } })
|
|
17
|
+
expect(t.rows).toEqual([
|
|
18
|
+
{ group: '2025', category: 'Q1', s0: 1, s1: 4 },
|
|
19
|
+
{ group: '2025', category: 'Q2', s0: 2, s1: 5 },
|
|
20
|
+
{ group: '2026', category: 'Q1', s0: 3, s1: 6 },
|
|
21
|
+
{ group: '2026', category: 'Q2', s0: null, s1: 7 },
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reads a date axis as a date column named after the axis, and a right-axis series in that axis format', () => {
|
|
26
|
+
const t = chartSpecToTable({
|
|
27
|
+
type: 'bar',
|
|
28
|
+
xType: 'time',
|
|
29
|
+
categories: ['2026-01-01', '2026-02-01'],
|
|
30
|
+
series: [{ label: 'Revenue', values: [2.1, 2.3] }, { label: 'Margin', values: [0.29, 0.31], type: 'line', axis: 'right' }],
|
|
31
|
+
valueFormat: 'currency',
|
|
32
|
+
y2Axis: { format: 'percent' },
|
|
33
|
+
xAxisTitle: 'Month',
|
|
34
|
+
})
|
|
35
|
+
expect(t.columns[0]).toEqual({ field: 'category', header: 'Month', cellDataType: 'date' })
|
|
36
|
+
expect(t.columns[1]!.format).toEqual({ type: 'currency', currency: 'USD' })
|
|
37
|
+
expect(t.columns[2]!.format).toEqual({ type: 'percent' })
|
|
38
|
+
expect(chartSpecToTable({ type: 'gauge', categories: [], series: [], gaugeValue: 1, gaugeUnit: 'ms' }).columns[1]!.header).toBe('Value (ms)')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('expands the numbers a series carries: ranges, targets, error bars, candles, boxes, bins', () => {
|
|
42
|
+
const range = chartSpecToTable({ type: 'range-bar', categories: ['a'], series: [{ label: 'Band', values: [9], lowValues: [4] }] })
|
|
43
|
+
expect(range.columns.map((c) => c.header)).toEqual(['Category', 'Band Low', 'Band High'])
|
|
44
|
+
expect(range.rows[0]).toEqual({ category: 'a', s0lo: 4, s0hi: 9 })
|
|
45
|
+
const bullet = chartSpecToTable({ type: 'bullet', categories: ['a'], series: [{ label: 'Actual', values: [82], targets: [90] }] })
|
|
46
|
+
expect(bullet.rows[0]).toEqual({ category: 'a', s0: 82, s0t: 90 })
|
|
47
|
+
const err = chartSpecToTable({ type: 'bar', categories: ['a', 'b'], series: [{ label: 'Mean', values: [10, 20], errors: [2, { lo: 15, hi: 30 }] }] })
|
|
48
|
+
expect(err.rows).toEqual([{ category: 'a', s0: 10, s0lo: 8, s0hi: 12 }, { category: 'b', s0: 20, s0lo: 15, s0hi: 30 }])
|
|
49
|
+
const candles = chartSpecToTable({ type: 'candlestick', categories: ['d1'], series: [{ label: 'ACME', values: [3], ohlc: [{ o: 1, h: 4, l: 0.5, c: 3 }], volumes: [100] }] })
|
|
50
|
+
expect(candles.columns.map((c) => c.header)).toEqual(['Category', 'ACME Open', 'ACME High', 'ACME Low', 'ACME Close', 'ACME Volume'])
|
|
51
|
+
expect(candles.rows[0]).toEqual({ category: 'd1', s0o: 1, s0h: 4, s0l: 0.5, s0c: 3, s0v: 100 })
|
|
52
|
+
const boxes = chartSpecToTable({ type: 'boxplot', categories: ['a'], series: [{ label: 'ms', values: [5], boxes: [{ min: 1, q1: 3, median: 5, q3: 7, max: 9, outliers: [20, 30] }] }] })
|
|
53
|
+
expect(boxes.rows[0]).toEqual({ category: 'a', s0min: 1, s0q1: 3, s0med: 5, s0q3: 7, s0max: 9, s0out: '20 30' })
|
|
54
|
+
const bins = chartSpecToTable({ type: 'histogram', categories: ['5', '15'], series: [{ label: 'n', values: [2, 3] }], binEdges: [0, 10, 20] })
|
|
55
|
+
expect(bins.columns.map((c) => c.header)).toEqual(['From', 'To', 'n'])
|
|
56
|
+
expect(bins.rows).toEqual([{ from: 0, to: 10, s0: 2 }, { from: 10, to: 20, s0: 3 }])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('tabulates the shapes the category grid cannot: points, links, leaves, days, a gauge', () => {
|
|
60
|
+
const scatter = chartSpecToTable({ type: 'scatter', categories: [], series: [{ label: 'S', values: [], points: [{ x: 1, y: 2, r: 3, label: 'p' }] }] })
|
|
61
|
+
expect(scatter.columns.map((c) => c.header)).toEqual(['Series', 'X', 'Y', 'Size', 'Label'])
|
|
62
|
+
expect(scatter.rows).toEqual([{ series: 'S', x: 1, y: 2, size: 3, label: 'p' }])
|
|
63
|
+
const flow = chartSpecToTable({ type: 'sankey', categories: [], series: [], sankeyNodes: [{ id: 'a', label: 'Wind' }, { id: 'b' }], sankeyLinks: [{ source: 'a', target: 'b', value: 27 }] })
|
|
64
|
+
expect(flow.rows).toEqual([{ source: 'Wind', target: 'b', value: 27 }])
|
|
65
|
+
const tree: ChartSpec = { type: 'sunburst', categories: [], series: [], tree: { name: 'All', children: [{ name: 'Blink', children: [{ name: 'Chrome', value: 6 }] }, { name: 'Gecko', value: 3 }] } }
|
|
66
|
+
const leaves = chartSpecToTable(tree)
|
|
67
|
+
expect(leaves.columns.map((c) => c.header)).toEqual(['Level 1', 'Level 2', 'Value'])
|
|
68
|
+
expect(leaves.rows).toEqual([{ level1: 'Blink', level2: 'Chrome', value: 6 }, { level1: 'Gecko', value: 3 }])
|
|
69
|
+
const days = chartSpecToTable({ type: 'calendar', categories: [], series: [], calendarValues: [{ date: '2026-01-05', value: 4 }] })
|
|
70
|
+
expect(days.columns[0]).toMatchObject({ header: 'Date', cellDataType: 'date' })
|
|
71
|
+
expect(days.rows).toEqual([{ date: '2026-01-05', value: 4 }])
|
|
72
|
+
const gauge = chartSpecToTable({ type: 'gauge', categories: [], series: [], title: 'Uptime', gaugeValue: 99.2, gaugeMin: 99, gaugeMax: 100, gaugeTarget: 99.9 })
|
|
73
|
+
expect(gauge.rows).toEqual([{ metric: 'Uptime', value: 99.2, min: 99, max: 100, target: 99.9 }])
|
|
74
|
+
expect(chartSpecToTable({ type: 'bar', categories: [], series: [] })).toEqual({ columns: [], rows: [] })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart spec -> grid table.
|
|
3
|
+
*
|
|
4
|
+
* The rows and columns a `ChartSpec` amounts to, in the shape `SvGrid` takes:
|
|
5
|
+
* one row per category with a column per series for the category types, one
|
|
6
|
+
* row per point for a scatter, per link for a sankey or chord, per leaf for
|
|
7
|
+
* a tree map or sunburst, per day for a calendar, and one row for a gauge.
|
|
8
|
+
* Number columns carry `cellDataType: 'number'` and the chart's value format,
|
|
9
|
+
* so a grid of the chart reads its numbers the way the chart does.
|
|
10
|
+
*
|
|
11
|
+
* This is the grid-shaped sibling of `chartSpecToCsv`: the CSV keeps its
|
|
12
|
+
* header contract byte for byte, this one adds types and formats and covers
|
|
13
|
+
* the shapes the CSV leaves out. A "Chart | Grid" switch on a chart is this
|
|
14
|
+
* function behind an `SvGrid`.
|
|
15
|
+
*/
|
|
16
|
+
import type { CellFormatConfig } from './core'
|
|
17
|
+
import type { ChartSpec, TreeNode } from './chart-types'
|
|
18
|
+
|
|
19
|
+
/** One column of a chart's table: what `SvGrid` needs to draw it. */
|
|
20
|
+
export type ChartTableColumn = {
|
|
21
|
+
field: string
|
|
22
|
+
header: string
|
|
23
|
+
cellDataType?: 'number' | 'text' | 'date'
|
|
24
|
+
format?: CellFormatConfig
|
|
25
|
+
width?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A chart's data as a table: grid columns and plain row objects. */
|
|
29
|
+
export type ChartTable = {
|
|
30
|
+
columns: ChartTableColumn[]
|
|
31
|
+
rows: Array<Record<string, unknown>>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A finite number as itself, anything else as an empty cell. */
|
|
35
|
+
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
36
|
+
|
|
37
|
+
/** The cell format a chart value format maps to, for the number columns. */
|
|
38
|
+
function formatOf(format: ChartSpec['valueFormat'], spec: ChartSpec): CellFormatConfig | undefined {
|
|
39
|
+
const locales: string | string[] | undefined = typeof spec.locale === 'string' ? spec.locale : spec.locale ? [...spec.locale] : undefined
|
|
40
|
+
if (format === 'currency') return { type: 'currency', currency: spec.currency ?? 'USD', ...(locales ? { locales } : {}) }
|
|
41
|
+
if (format === 'percent') return { type: 'percent', ...(locales ? { locales } : {}) }
|
|
42
|
+
if (format === 'number' || format === 'compact') return { type: 'number', ...(locales ? { locales } : {}) }
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The rows and columns a chart spec amounts to. Never throws: a spec with
|
|
48
|
+
* nothing to tabulate comes back with no rows and no columns.
|
|
49
|
+
*/
|
|
50
|
+
export function chartSpecToTable(spec: ChartSpec): ChartTable {
|
|
51
|
+
const fmt = formatOf(spec.valueFormat, spec)
|
|
52
|
+
// A series on the right axis reads in that axis's format: a margin of 0.29
|
|
53
|
+
// beside a revenue in dollars is "29%", not "$0.29".
|
|
54
|
+
const fmt2 = formatOf(spec.y2Axis?.format, spec)
|
|
55
|
+
const numCol = (field: string, header: string, right = false): ChartTableColumn => {
|
|
56
|
+
const f = right ? fmt2 : fmt
|
|
57
|
+
return { field, header, cellDataType: 'number', ...(f ? { format: f } : {}) }
|
|
58
|
+
}
|
|
59
|
+
const textCol = (field: string, header: string): ChartTableColumn => ({ field, header })
|
|
60
|
+
const series = spec.series ?? []
|
|
61
|
+
|
|
62
|
+
// Scatter and bubble: one row per point.
|
|
63
|
+
if (series.some((s) => s.points?.length)) {
|
|
64
|
+
const rows: Array<Record<string, unknown>> = []
|
|
65
|
+
for (const s of series) for (const p of s.points ?? []) rows.push({ series: s.label, x: num(p.x), y: num(p.y), size: num(p.r), label: p.label ?? '' })
|
|
66
|
+
return { columns: [textCol('series', 'Series'), numCol('x', 'X'), numCol('y', 'Y'), numCol('size', 'Size'), textCol('label', 'Label')], rows }
|
|
67
|
+
}
|
|
68
|
+
// Sankey and chord: one row per link.
|
|
69
|
+
if (spec.sankeyLinks?.length) {
|
|
70
|
+
const names = new Map((spec.sankeyNodes ?? []).map((n) => [n.id, n.label ?? n.id]))
|
|
71
|
+
const rows = spec.sankeyLinks.map((l) => ({ source: names.get(l.source) ?? l.source, target: names.get(l.target) ?? l.target, value: num(l.value) }))
|
|
72
|
+
return { columns: [textCol('source', 'Source'), textCol('target', 'Target'), numCol('value', 'Value')], rows }
|
|
73
|
+
}
|
|
74
|
+
// Tree map and sunburst: one row per leaf, a column per level.
|
|
75
|
+
const tree = spec.tree ?? spec.treemap
|
|
76
|
+
if (tree && (spec.type === 'treemap' || spec.type === 'sunburst' || !spec.categories?.length)) {
|
|
77
|
+
const rows: Array<Record<string, unknown>> = []
|
|
78
|
+
let depth = 0
|
|
79
|
+
const walk = (n: TreeNode, path: string[]) => {
|
|
80
|
+
if (n.children?.length) {
|
|
81
|
+
for (const c of n.children) walk(c, [...path, c.name])
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
depth = Math.max(depth, path.length)
|
|
85
|
+
const row: Record<string, unknown> = { value: num(n.value) }
|
|
86
|
+
path.forEach((seg, i) => (row[`level${i + 1}`] = seg))
|
|
87
|
+
rows.push(row)
|
|
88
|
+
}
|
|
89
|
+
walk(tree, [])
|
|
90
|
+
const columns: ChartTableColumn[] = []
|
|
91
|
+
for (let i = 1; i <= depth; i += 1) columns.push(textCol(`level${i}`, depth === 1 ? 'Name' : `Level ${i}`))
|
|
92
|
+
columns.push(numCol('value', 'Value'))
|
|
93
|
+
return { columns, rows }
|
|
94
|
+
}
|
|
95
|
+
// Calendar: one row per day.
|
|
96
|
+
if (spec.calendarValues?.length) {
|
|
97
|
+
return {
|
|
98
|
+
columns: [{ field: 'date', header: 'Date', cellDataType: 'date' }, numCol('value', 'Value')],
|
|
99
|
+
rows: spec.calendarValues.map((d) => ({ date: d.date, value: num(d.value) })),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Gauge: the one reading and its scale.
|
|
103
|
+
if (spec.type === 'gauge') {
|
|
104
|
+
return {
|
|
105
|
+
columns: [textCol('metric', 'Metric'), numCol('value', spec.gaugeUnit ? `Value (${spec.gaugeUnit})` : 'Value'), numCol('min', 'Min'), numCol('max', 'Max'), numCol('target', 'Target')],
|
|
106
|
+
rows: [{ metric: spec.title ?? spec.series[0]?.label ?? 'Value', value: num(spec.gaugeValue), min: num(spec.gaugeMin ?? 0), max: num(spec.gaugeMax ?? 100), target: num(spec.gaugeTarget) }],
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Everything else reads categories x series: one row per category, one
|
|
111
|
+
// column per number a series carries.
|
|
112
|
+
const cats = spec.categories ?? []
|
|
113
|
+
if (!cats.length || !series.length) return { columns: [], rows: [] }
|
|
114
|
+
const columns: ChartTableColumn[] = []
|
|
115
|
+
const groups = spec.categoryGroups
|
|
116
|
+
if (groups?.length) columns.push(textCol('group', 'Group'))
|
|
117
|
+
const edges = spec.binEdges
|
|
118
|
+
const xType = spec.xAxis?.type ?? spec.xType
|
|
119
|
+
const dated = xType === 'time' || xType === 'ordinal-time'
|
|
120
|
+
const catHeader = spec.xAxis?.title ?? spec.xAxisTitle ?? (dated ? 'Date' : 'Category')
|
|
121
|
+
if (edges && edges.length === cats.length + 1) columns.push(numCol('from', 'From'), numCol('to', 'To'))
|
|
122
|
+
else columns.push(dated ? { field: 'category', header: catHeader, cellDataType: 'date' } : textCol('category', catHeader))
|
|
123
|
+
const fields: Array<{ field: string; pick: (i: number) => unknown }> = []
|
|
124
|
+
series.forEach((s, si) => {
|
|
125
|
+
const key = (suffix: string) => `s${si}${suffix}`
|
|
126
|
+
const add = (suffix: string, header: string, pick: (i: number) => unknown) => {
|
|
127
|
+
columns.push(numCol(key(suffix), header, s.axis === 'right'))
|
|
128
|
+
fields.push({ field: key(suffix), pick })
|
|
129
|
+
}
|
|
130
|
+
if (s.ohlc) {
|
|
131
|
+
add('o', `${s.label} Open`, (i) => num(s.ohlc![i]?.o))
|
|
132
|
+
add('h', `${s.label} High`, (i) => num(s.ohlc![i]?.h))
|
|
133
|
+
add('l', `${s.label} Low`, (i) => num(s.ohlc![i]?.l))
|
|
134
|
+
add('c', `${s.label} Close`, (i) => num(s.ohlc![i]?.c))
|
|
135
|
+
if (s.volumes) add('v', `${s.label} Volume`, (i) => num(s.volumes![i]))
|
|
136
|
+
} else if (s.boxes) {
|
|
137
|
+
add('min', `${s.label} Min`, (i) => num(s.boxes![i]?.min))
|
|
138
|
+
add('q1', `${s.label} Q1`, (i) => num(s.boxes![i]?.q1))
|
|
139
|
+
add('med', `${s.label} Median`, (i) => num(s.boxes![i]?.median))
|
|
140
|
+
add('q3', `${s.label} Q3`, (i) => num(s.boxes![i]?.q3))
|
|
141
|
+
add('max', `${s.label} Max`, (i) => num(s.boxes![i]?.max))
|
|
142
|
+
columns.push(textCol(key('out'), `${s.label} Outliers`))
|
|
143
|
+
fields.push({ field: key('out'), pick: (i) => s.boxes![i]?.outliers?.join(' ') ?? '' })
|
|
144
|
+
} else if (s.errors) {
|
|
145
|
+
add('', s.label, (i) => num(s.values[i]))
|
|
146
|
+
add('lo', `${s.label} Low`, (i) => {
|
|
147
|
+
const v = s.values[i], e = s.errors![i]
|
|
148
|
+
return e == null || !Number.isFinite(v) ? null : typeof e === 'number' ? (v as number) - Math.abs(e) : Math.min(e.lo, e.hi)
|
|
149
|
+
})
|
|
150
|
+
add('hi', `${s.label} High`, (i) => {
|
|
151
|
+
const v = s.values[i], e = s.errors![i]
|
|
152
|
+
return e == null || !Number.isFinite(v) ? null : typeof e === 'number' ? (v as number) + Math.abs(e) : Math.max(e.lo, e.hi)
|
|
153
|
+
})
|
|
154
|
+
} else if (s.lowValues) {
|
|
155
|
+
add('lo', `${s.label} Low`, (i) => num(s.lowValues![i]))
|
|
156
|
+
add('hi', `${s.label} High`, (i) => num(s.values[i]))
|
|
157
|
+
} else if (s.targets) {
|
|
158
|
+
add('', s.label, (i) => num(s.values[i]))
|
|
159
|
+
add('t', `${s.label} Target`, (i) => num(s.targets![i]))
|
|
160
|
+
} else {
|
|
161
|
+
add('', s.label, (i) => num(s.values[i]))
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
// A category's group, from the spans.
|
|
165
|
+
const groupOf: string[] = []
|
|
166
|
+
if (groups?.length) for (const g of groups) for (let k = 0; k < g.span; k += 1) groupOf.push(g.label)
|
|
167
|
+
const rows = cats.map((cat, i) => {
|
|
168
|
+
const row: Record<string, unknown> = {}
|
|
169
|
+
if (groups?.length) row.group = groupOf[i] ?? ''
|
|
170
|
+
if (edges && edges.length === cats.length + 1) {
|
|
171
|
+
row.from = num(edges[i])
|
|
172
|
+
row.to = num(edges[i + 1])
|
|
173
|
+
} else row.category = cat
|
|
174
|
+
for (const f of fields) row[f.field] = f.pick(i)
|
|
175
|
+
return row
|
|
176
|
+
})
|
|
177
|
+
return { columns, rows }
|
|
178
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two trend primitives a chart is read with: the least-squares line
|
|
3
|
+
* through a series and Pearson's correlation of two samples.
|
|
4
|
+
*
|
|
5
|
+
* A leaf of its own, and deliberately small, for the same reason as
|
|
6
|
+
* chart-format.ts: the engine and the lazily loaded summary
|
|
7
|
+
* (chart-summary.ts) share these two functions, a bundler hoists what the
|
|
8
|
+
* base chunk and a lazy chunk share into a third chunk, and hoisting all of
|
|
9
|
+
* chart-stats.ts for two functions cost the base bundle gzip locality for no
|
|
10
|
+
* new code. chart-stats.ts re-exports both.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
14
|
+
* Returns the fitted value at each x index, or NaN where the source value
|
|
15
|
+
* was non-finite. */
|
|
16
|
+
export function linearTrend(values: number[]): number[] {
|
|
17
|
+
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
|
|
18
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
19
|
+
const y = values[i]!
|
|
20
|
+
if (!Number.isFinite(y)) continue
|
|
21
|
+
n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
|
|
22
|
+
}
|
|
23
|
+
if (n < 2) return values.map(() => NaN)
|
|
24
|
+
const denom = n * sumXX - sumX * sumX
|
|
25
|
+
if (denom === 0) return values.map(() => sumY / n)
|
|
26
|
+
const slope = (n * sumXY - sumX * sumY) / denom
|
|
27
|
+
const intercept = (sumY - slope * sumX) / n
|
|
28
|
+
return values.map((_, i) => slope * i + intercept)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pearson's correlation coefficient of two equal-length samples, skipping
|
|
32
|
+
* pairs where either side is non-finite; 0 when fewer than two pairs remain
|
|
33
|
+
* or either side has no variance. */
|
|
34
|
+
export function pearson(xs: ReadonlyArray<number>, ys: ReadonlyArray<number>): number {
|
|
35
|
+
let n = 0, sx = 0, sy = 0
|
|
36
|
+
const len = Math.min(xs.length, ys.length)
|
|
37
|
+
for (let i = 0; i < len; i += 1) if (Number.isFinite(xs[i]) && Number.isFinite(ys[i])) { n += 1; sx += xs[i]!; sy += ys[i]! }
|
|
38
|
+
if (n < 2) return 0
|
|
39
|
+
const mx = sx / n, my = sy / n
|
|
40
|
+
let sxy = 0, sxx = 0, syy = 0
|
|
41
|
+
for (let i = 0; i < len; i += 1) {
|
|
42
|
+
if (!Number.isFinite(xs[i]) || !Number.isFinite(ys[i])) continue
|
|
43
|
+
const dx = xs[i]! - mx, dy = ys[i]! - my
|
|
44
|
+
sxy += dx * dy; sxx += dx * dx; syy += dy * dy
|
|
45
|
+
}
|
|
46
|
+
return sxx === 0 || syy === 0 ? 0 : sxy / Math.sqrt(sxx * syy)
|
|
47
|
+
}
|