@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,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CHART_TYPES, KNOWN_SERIES_KEYS, KNOWN_SPEC_KEYS, nearestKey, validateChartSpec } from './chart-validate'
|
|
3
|
+
import { sampleChartSpec, sampleChartThumb } from './chart-samples'
|
|
4
|
+
import { PER_CATEGORY_SERIES_KEYS } from './chart-decimate'
|
|
5
|
+
import type { ChartSpec } from './chart-types'
|
|
6
|
+
|
|
7
|
+
const base = (): ChartSpec => ({
|
|
8
|
+
type: 'bar',
|
|
9
|
+
categories: ['a', 'b', 'c'],
|
|
10
|
+
series: [{ label: 's', values: [1, 2, 3] }],
|
|
11
|
+
})
|
|
12
|
+
const codes = (spec: unknown) => validateChartSpec(spec).map((d) => d.code)
|
|
13
|
+
|
|
14
|
+
describe('validateChartSpec', () => {
|
|
15
|
+
it('passes every sample spec of every type, full size and thumbnail', () => {
|
|
16
|
+
for (const type of CHART_TYPES) {
|
|
17
|
+
expect(validateChartSpec(sampleChartSpec(type)), type).toEqual([])
|
|
18
|
+
expect(validateChartSpec(sampleChartThumb(type)), `${type} thumb`).toEqual([])
|
|
19
|
+
}
|
|
20
|
+
expect(validateChartSpec(base())).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('names an unknown top-level key with the nearest known one', () => {
|
|
24
|
+
const d = validateChartSpec({ ...base(), serie: [] } as unknown)
|
|
25
|
+
expect(d).toHaveLength(1)
|
|
26
|
+
expect(d[0]).toMatchObject({ code: 'chart/unknown-key', path: 'serie', severity: 'warning' })
|
|
27
|
+
expect(d[0]!.message).toContain('"series"')
|
|
28
|
+
expect(validateChartSpec({ ...base(), zzzz: 1 } as unknown)[0]!.message).toContain('is ignored')
|
|
29
|
+
expect(nearestKey('valeus', KNOWN_SERIES_KEYS)).toBe('values')
|
|
30
|
+
expect(nearestKey('completely-different', KNOWN_SPEC_KEYS)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('rejects an unknown chart type and suggests the closest', () => {
|
|
34
|
+
const d = validateChartSpec({ ...base(), type: 'lines' })
|
|
35
|
+
expect(d[0]).toMatchObject({ code: 'chart/unknown-type', path: 'type', severity: 'error' })
|
|
36
|
+
expect(d[0]!.message).toContain('"line"')
|
|
37
|
+
expect(codes({ categories: [], series: [] })).toContain('chart/unknown-type')
|
|
38
|
+
expect(codes(null)).toEqual(['chart/not-an-object'])
|
|
39
|
+
expect(codes('bar')).toEqual(['chart/not-an-object'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('checks series length against the categories, and every parallel array', () => {
|
|
43
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2] }] })).toEqual(['chart/series-length'])
|
|
44
|
+
for (const key of PER_CATEGORY_SERIES_KEYS) {
|
|
45
|
+
if (key === 'values') continue
|
|
46
|
+
const spec = { ...base(), series: [{ label: 's', values: [1, 2, 3], [key]: [null] }] }
|
|
47
|
+
expect(codes(spec), key).toContain('chart/series-length')
|
|
48
|
+
}
|
|
49
|
+
// Families that do not index values by category are left alone.
|
|
50
|
+
expect(codes({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: [{ x: 1, y: 2 }] }] })).toEqual([])
|
|
51
|
+
expect(codes({ type: 'gauge', categories: [], series: [], gaugeValue: 3 })).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('checks series keys, type, axis, stack, overlay and the point shapes', () => {
|
|
55
|
+
expect(validateChartSpec({ ...base(), series: [{ label: 's', valeus: [1, 2, 3], values: [1, 2, 3] }] })[0]).toMatchObject({ code: 'chart/unknown-series-key', path: 'series[0].valeus' })
|
|
56
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], type: 'column' }] })).toEqual(['chart/series-type'])
|
|
57
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], axis: 'top' }] })).toEqual(['chart/series-axis'])
|
|
58
|
+
expect(codes({ ...base(), type: 'line', series: [{ label: 's', values: [1, 2, 3], stack: 'x' }] })).toEqual(['chart/stack-kind'])
|
|
59
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], stack: 'x' }] })).toEqual([])
|
|
60
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], overlay: 'sma20' }] })).toEqual(['chart/overlay'])
|
|
61
|
+
for (const ok of ['linear', 'sma:20', 'ema:7', 'wma:5', 'bb:20:2', 'bb:20:2.5', 'vwap', 'poly:3', 'exp', 'log', 'power']) {
|
|
62
|
+
expect(codes({ ...base(), series: [{ label: 's', values: [1, 2, 3], overlay: ok }] }), ok).toEqual([])
|
|
63
|
+
}
|
|
64
|
+
expect(codes({ ...base(), type: 'candlestick', series: [{ label: 's', values: [1, 2, 3], ohlc: [{ o: 1, h: 2, l: 3, c: 2 }, null, { o: 1 }] }] })).toEqual(['chart/ohlc-range', 'chart/ohlc-shape'])
|
|
65
|
+
expect(codes({ ...base(), type: 'boxplot', series: [{ label: 's', values: [1, 2, 3], boxes: [{ min: 0, q1: 5, median: 2, q3: 6, max: 9 }, null, null] }] })).toEqual(['chart/box-order'])
|
|
66
|
+
expect(codes({ type: 'scatter', categories: [], series: [{ label: 'p', values: [], points: [{ x: 'a', y: 2 }] }] })).toEqual(['chart/point-shape'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('checks the axis config', () => {
|
|
70
|
+
expect(codes({ ...base(), yAxis: { min: 10, max: 5 } })).toEqual(['chart/axis-range'])
|
|
71
|
+
expect(codes({ ...base(), yAxis: { scale: 'log', min: 0 } })).toEqual(['chart/axis-log-min'])
|
|
72
|
+
expect(codes({ ...base(), xAxis: { type: 'category', scale: 'log' } })).toEqual(['chart/axis-log-x'])
|
|
73
|
+
expect(codes({ ...base(), xAxis: { type: 'number', scale: 'log' } })).toEqual([])
|
|
74
|
+
expect(codes({ ...base(), y2Axis: { tickCount: 0 } })).toEqual(['chart/axis-ticks'])
|
|
75
|
+
expect(codes({ ...base(), xAxis: { labelRotation: 'tilted' } })).toEqual(['chart/axis-rotation'])
|
|
76
|
+
expect(codes({ ...base(), yAxis: { scale: 'sqrt' } })).toEqual(['chart/axis-scale'])
|
|
77
|
+
expect(codes({ ...base(), yAxis: 'log' })).toEqual(['chart/axis-shape'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('checks the spec-level shapes: stacked100, innerRadius, groups, bins, waterfall flags, responsive rules', () => {
|
|
81
|
+
expect(codes({ ...base(), type: 'pie', stacked100: true })).toEqual(['chart/stacked100-kind'])
|
|
82
|
+
expect(codes({ ...base(), innerRadius: 1.5 })).toEqual(['chart/inner-radius'])
|
|
83
|
+
expect(codes({ ...base(), categoryGroups: [{ label: 'g', span: 2 }] })).toEqual(['chart/category-groups'])
|
|
84
|
+
expect(codes({ ...base(), type: 'histogram', binEdges: [0, 1] })).toEqual(['chart/bin-edges'])
|
|
85
|
+
expect(codes({ ...base(), type: 'waterfall', waterfallTotals: [true] })).toEqual(['chart/waterfall-totals'])
|
|
86
|
+
expect(codes({ ...base(), responsive: [{ spec: { title: 'x' } }] })).toEqual(['chart/responsive-size'])
|
|
87
|
+
expect(codes({ ...base(), responsive: [{ maxWidth: 400, spec: { width: 10 } }] })).toEqual(['chart/responsive-size-key'])
|
|
88
|
+
expect(codes({ ...base(), responsive: ['narrow'] })).toEqual(['chart/responsive-shape'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('warns about x anchors that name no category, and not on a time or number axis', () => {
|
|
92
|
+
expect(codes({ ...base(), referenceLines: [{ axis: 'x', value: 'd' }] })).toEqual(['chart/unknown-category'])
|
|
93
|
+
expect(codes({ ...base(), annotations: [{ at: { category: 'zz' }, label: 'n' }] })).toEqual(['chart/unknown-category'])
|
|
94
|
+
expect(codes({ ...base(), drawings: [{ id: 'd1', kind: 'trend', points: [{ x: 'a', y: 1 }, { x: 'q', y: 2 }] }] })).toEqual(['chart/unknown-category'])
|
|
95
|
+
expect(codes({ ...base(), categories: ['1', '2', '3'], xType: 'number', referenceLines: [{ axis: 'x', value: '2.5' }] })).toEqual([])
|
|
96
|
+
// The drawing tools write a timestamp for every pointer position on a time axis.
|
|
97
|
+
expect(codes({ ...base(), categories: ['2026-01-01', '2026-02-01', '2026-03-01'], xType: 'time', drawings: [{ id: 'd1', kind: 'trend', points: [{ x: '2026-01-12T10:00:00.000Z', y: 1 }, { x: '2026-02-20T08:30:00.000Z', y: 2 }] }] })).toEqual([])
|
|
98
|
+
expect(codes({ ...base(), annotations: [{ at: { category: 'b' }, label: 'n' }] })).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart spec diagnostics. A `ChartSpec` is plain data, and plain data gets
|
|
3
|
+
* typos: a `serie` key, a `values` array one short of the categories, a log
|
|
4
|
+
* axis pinned at zero. The engine draws what it can and stays quiet, which
|
|
5
|
+
* is right for production and wrong for the developer who wonders why a bar
|
|
6
|
+
* is missing. `validateChartSpec` says what is off, with the path of the
|
|
7
|
+
* field and a severity: `error` for what will not draw as intended,
|
|
8
|
+
* `warning` for what is ignored or looks like a slip.
|
|
9
|
+
*
|
|
10
|
+
* Pure and dependency-free (types only), so the same rules run in `SvChart`
|
|
11
|
+
* in development, in the MCP server's code check and in a test. The key and
|
|
12
|
+
* type lists are exported for the JSON schema's drift guard.
|
|
13
|
+
*/
|
|
14
|
+
import type { ChartSeries, ChartSpec, ChartType } from './chart-types'
|
|
15
|
+
|
|
16
|
+
/** One finding of {@link validateChartSpec}. */
|
|
17
|
+
export type ChartDiagnostic = {
|
|
18
|
+
/** A stable rule id, `chart/<rule>`. */
|
|
19
|
+
code: string
|
|
20
|
+
/** The field's path in the spec, like `series[1].values` or `yAxis.min`. */
|
|
21
|
+
path: string
|
|
22
|
+
message: string
|
|
23
|
+
/** `error`: will not draw as intended. `warning`: ignored, or a likely slip. */
|
|
24
|
+
severity: 'error' | 'warning'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every `ChartType` the engine draws. */
|
|
28
|
+
export const CHART_TYPES: ReadonlyArray<ChartType> = [
|
|
29
|
+
'bar', 'line', 'area', 'pie', 'scatter', 'heatmap', 'waterfall', 'funnel', 'radar', 'calendar', 'gauge',
|
|
30
|
+
'treemap', 'sankey', 'candlestick', 'ohlc', 'boxplot', 'histogram', 'range-bar', 'range-area', 'lollipop',
|
|
31
|
+
'dumbbell', 'pareto', 'stream', 'sunburst', 'radial-bar', 'radial-column', 'nightingale', 'chord', 'bullet',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/** Every top-level key a `ChartSpec` may carry. */
|
|
35
|
+
export const KNOWN_SPEC_KEYS: ReadonlyArray<keyof ChartSpec> = [
|
|
36
|
+
'type', 'categories', 'series', 'width', 'height', 'palette', 'categoryColors', 'valueFormat', 'locale',
|
|
37
|
+
'currency', 'categoryGroups', 'stacked', 'stacked100', 'orientation', 'innerRadius', 'referenceLines',
|
|
38
|
+
'xType', 'yAxisTitle', 'y2AxisTitle', 'xAxisTitle', 'yScale', 'y2Scale', 'xAxis', 'yAxis', 'y2Axis',
|
|
39
|
+
'title', 'subtitle', 'caption', 'referenceBands', 'nullAs', 'decimate', 'dataLabels', 'annotations',
|
|
40
|
+
'seriesLabels', 'responsive', 'style', 'drawings', 'lastPriceLine', 'patternFallback', 'calendarValues',
|
|
41
|
+
'calendarStart', 'calendarEnd', 'gaugeValue', 'gaugeMin', 'gaugeMax', 'gaugeTarget', 'gaugeRanges',
|
|
42
|
+
'gaugeUnit', 'treemap', 'sankeyNodes', 'sankeyLinks', 'waterfallTotals', 'waterfallColors', 'candleColors',
|
|
43
|
+
'candleStyle', 'funnelShape', 'stackOffset', 'binEdges', 'tree', 'bulletRanges', 'colorScale',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/** Every key a `ChartSeries` may carry. */
|
|
47
|
+
export const KNOWN_SERIES_KEYS: ReadonlyArray<keyof ChartSeries> = [
|
|
48
|
+
'label', 'values', 'color', 'type', 'ohlc', 'volumes', 'boxes', 'errors', 'axis', 'points', 'rowIds',
|
|
49
|
+
'overlay', 'overlayColor', 'pattern', 'smooth', 'upperValues', 'lowerValues', 'marker', 'markers', 'colors',
|
|
50
|
+
'stack', 'strokeWidth', 'dash', 'opacity', 'visible', 'gradient', 'step', 'connectNulls', 'nullAs',
|
|
51
|
+
'lowValues', 'targets',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const SERIES_TYPES = new Set(['bar', 'line', 'area', 'scatter', 'candlestick', 'ohlc', 'boxplot', 'range-bar', 'range-area', 'lollipop', 'dumbbell'])
|
|
55
|
+
/** The series arrays that must run parallel to `categories`. */
|
|
56
|
+
const PER_CATEGORY = ['values', 'rowIds', 'upperValues', 'lowerValues', 'ohlc', 'boxes', 'errors', 'markers', 'colors', 'lowValues', 'targets', 'volumes'] as const
|
|
57
|
+
/** Families whose `series[].values` are not indexed by `categories`. */
|
|
58
|
+
const FREE_LENGTH_TYPES = new Set<string>(['scatter', 'gauge', 'treemap', 'sankey', 'calendar', 'sunburst', 'chord', 'histogram'])
|
|
59
|
+
const OVERLAY_RE = /^(linear|exp|log|power|vwap|sma:\d+|ema:\d+|wma:\d+|bb:\d+:\d+(?:\.\d+)?|poly:\d+)$/
|
|
60
|
+
|
|
61
|
+
/** Levenshtein distance, for a "did you mean" on a misspelt key. */
|
|
62
|
+
function distance(a: string, b: string): number {
|
|
63
|
+
const dp: number[] = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
64
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
65
|
+
let prev = dp[0]!
|
|
66
|
+
dp[0] = i
|
|
67
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
68
|
+
const tmp = dp[j]!
|
|
69
|
+
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
70
|
+
prev = tmp
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return dp[b.length]!
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The known key closest to `key`, when it is close enough to be a typo. */
|
|
77
|
+
export function nearestKey(key: string, known: ReadonlyArray<string>): string | null {
|
|
78
|
+
let best: string | null = null
|
|
79
|
+
let bestD = Infinity
|
|
80
|
+
for (const k of known) {
|
|
81
|
+
const d = distance(key.toLowerCase(), k.toLowerCase())
|
|
82
|
+
if (d < bestD) { bestD = d; best = k }
|
|
83
|
+
}
|
|
84
|
+
return best !== null && bestD <= Math.max(1, Math.floor(key.length / 3)) ? best : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
88
|
+
const isNum = (v: unknown): v is number => typeof v === 'number'
|
|
89
|
+
|
|
90
|
+
const warnedSpecs = new WeakSet<object>()
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Log every finding of a spec once per spec object, the way `SvChart` does in
|
|
94
|
+
* development, and return them. A spec already reported (by any chart) is
|
|
95
|
+
* skipped, so a spec shared by ten charts warns once.
|
|
96
|
+
*/
|
|
97
|
+
export function warnChartSpec(spec: unknown, log: (line: string) => void = (line) => console.warn(line)): ChartDiagnostic[] {
|
|
98
|
+
if (typeof spec !== 'object' || spec === null || warnedSpecs.has(spec)) return []
|
|
99
|
+
warnedSpecs.add(spec)
|
|
100
|
+
const out = validateChartSpec(spec)
|
|
101
|
+
for (const d of out) log(`[SvChart] ${d.path ? d.path + ': ' : ''}${d.message}`)
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check a spec and return every finding, an empty array for a clean one. Never
|
|
107
|
+
* throws: a spec that is not even an object comes back as one error.
|
|
108
|
+
*/
|
|
109
|
+
export function validateChartSpec(spec: unknown): ChartDiagnostic[] {
|
|
110
|
+
const out: ChartDiagnostic[] = []
|
|
111
|
+
const err = (code: string, path: string, message: string) => out.push({ code: `chart/${code}`, path, message, severity: 'error' })
|
|
112
|
+
const warn = (code: string, path: string, message: string) => out.push({ code: `chart/${code}`, path, message, severity: 'warning' })
|
|
113
|
+
if (!isObj(spec)) {
|
|
114
|
+
err('not-an-object', '', 'a ChartSpec is an object with type, categories and series')
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
const s = spec as Record<string, unknown>
|
|
118
|
+
|
|
119
|
+
// ---- Keys and type -------------------------------------------------------
|
|
120
|
+
for (const k of Object.keys(s)) {
|
|
121
|
+
if ((KNOWN_SPEC_KEYS as ReadonlyArray<string>).includes(k)) continue
|
|
122
|
+
const near = nearestKey(k, KNOWN_SPEC_KEYS)
|
|
123
|
+
warn('unknown-key', k, near ? `unknown field "${k}"; did you mean "${near}"?` : `unknown field "${k}" is ignored`)
|
|
124
|
+
}
|
|
125
|
+
const type = s.type
|
|
126
|
+
if (typeof type !== 'string' || !(CHART_TYPES as ReadonlyArray<string>).includes(type)) {
|
|
127
|
+
const near = typeof type === 'string' ? nearestKey(type, CHART_TYPES) : null
|
|
128
|
+
err('unknown-type', 'type', near ? `unknown chart type "${String(type)}"; did you mean "${near}"?` : `unknown chart type "${String(type)}"`)
|
|
129
|
+
}
|
|
130
|
+
const categories = Array.isArray(s.categories) ? (s.categories as unknown[]) : null
|
|
131
|
+
if (!categories) err('categories', 'categories', 'categories must be an array of labels')
|
|
132
|
+
const n = categories?.length ?? 0
|
|
133
|
+
const series = Array.isArray(s.series) ? (s.series as unknown[]) : null
|
|
134
|
+
if (!series) err('series', 'series', 'series must be an array')
|
|
135
|
+
|
|
136
|
+
// ---- Series ----------------------------------------------------------------
|
|
137
|
+
const fixedLength = typeof type === 'string' && !FREE_LENGTH_TYPES.has(type)
|
|
138
|
+
series?.forEach((sr, i) => {
|
|
139
|
+
const p = `series[${i}]`
|
|
140
|
+
if (!isObj(sr)) { err('series-shape', p, 'a series is an object with label and values'); return }
|
|
141
|
+
for (const k of Object.keys(sr)) {
|
|
142
|
+
if ((KNOWN_SERIES_KEYS as ReadonlyArray<string>).includes(k)) continue
|
|
143
|
+
const near = nearestKey(k, KNOWN_SERIES_KEYS)
|
|
144
|
+
warn('unknown-series-key', `${p}.${k}`, near ? `unknown series field "${k}"; did you mean "${near}"?` : `unknown series field "${k}" is ignored`)
|
|
145
|
+
}
|
|
146
|
+
if (typeof sr.label !== 'string') warn('series-label', `${p}.label`, 'a series wants a string label; the legend and tooltip name it')
|
|
147
|
+
if (!Array.isArray(sr.values)) { err('series-values', `${p}.values`, 'values must be an array of numbers'); return }
|
|
148
|
+
const values = sr.values as unknown[]
|
|
149
|
+
if (fixedLength && categories && values.length !== n && !(sr.points && values.length === 0)) {
|
|
150
|
+
err('series-length', `${p}.values`, `${values.length} values for ${n} categories; they run in parallel`)
|
|
151
|
+
}
|
|
152
|
+
for (const key of PER_CATEGORY) {
|
|
153
|
+
if (key === 'values') continue
|
|
154
|
+
const arr = sr[key]
|
|
155
|
+
if (arr === undefined) continue
|
|
156
|
+
if (!Array.isArray(arr)) { err('series-array', `${p}.${key}`, `${key} must be an array parallel to values`); continue }
|
|
157
|
+
if (fixedLength && arr.length !== values.length) err('series-length', `${p}.${key}`, `${key} has ${arr.length} entries for ${values.length} values; they run in parallel`)
|
|
158
|
+
}
|
|
159
|
+
if (sr.type !== undefined && (typeof sr.type !== 'string' || !SERIES_TYPES.has(sr.type))) err('series-type', `${p}.type`, `unknown series type "${String(sr.type)}"`)
|
|
160
|
+
if (sr.axis !== undefined && sr.axis !== 'left' && sr.axis !== 'right') err('series-axis', `${p}.axis`, `axis is 'left' or 'right', not "${String(sr.axis)}"`)
|
|
161
|
+
if (sr.stack !== undefined) {
|
|
162
|
+
const kind = (sr.type as string | undefined) ?? type
|
|
163
|
+
if (kind !== 'bar' && kind !== 'area') warn('stack-kind', `${p}.stack`, 'stack groups apply to bar and area series; ignored here')
|
|
164
|
+
}
|
|
165
|
+
if (sr.overlay !== undefined && (typeof sr.overlay !== 'string' || !OVERLAY_RE.test(sr.overlay))) err('overlay', `${p}.overlay`, `unknown overlay "${String(sr.overlay)}"; expected linear, poly:N, exp, log, power, sma:N, ema:N, wma:N, bb:N:K or vwap`)
|
|
166
|
+
if (Array.isArray(sr.ohlc)) {
|
|
167
|
+
;(sr.ohlc as unknown[]).forEach((b, j) => {
|
|
168
|
+
if (b === null || b === undefined) return
|
|
169
|
+
if (!isObj(b) || !isNum(b.o) || !isNum(b.h) || !isNum(b.l) || !isNum(b.c)) err('ohlc-shape', `${p}.ohlc[${j}]`, 'an OHLC bar is { o, h, l, c } or null')
|
|
170
|
+
else if (b.l > b.h) err('ohlc-range', `${p}.ohlc[${j}]`, `low ${b.l} is above high ${b.h}`)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(sr.boxes)) {
|
|
174
|
+
;(sr.boxes as unknown[]).forEach((b, j) => {
|
|
175
|
+
if (b === null || b === undefined) return
|
|
176
|
+
if (!isObj(b) || !isNum(b.min) || !isNum(b.q1) || !isNum(b.median) || !isNum(b.q3) || !isNum(b.max)) err('box-shape', `${p}.boxes[${j}]`, 'a box is { min, q1, median, q3, max } or null')
|
|
177
|
+
else if (b.q1 > b.median || b.median > b.q3) err('box-order', `${p}.boxes[${j}]`, 'a box wants q1 <= median <= q3')
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(sr.points)) {
|
|
181
|
+
;(sr.points as unknown[]).forEach((pt, j) => {
|
|
182
|
+
if (!isObj(pt) || !isNum(pt.x) || !isNum(pt.y)) err('point-shape', `${p}.points[${j}]`, 'a scatter point is { x, y, r?, label? }')
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ---- Axes ----------------------------------------------------------------
|
|
188
|
+
for (const key of ['xAxis', 'yAxis', 'y2Axis'] as const) {
|
|
189
|
+
const ax = s[key]
|
|
190
|
+
if (ax === undefined) continue
|
|
191
|
+
if (!isObj(ax)) { err('axis-shape', key, `${key} must be an object`); continue }
|
|
192
|
+
if (isNum(ax.min) && isNum(ax.max) && ax.min >= ax.max) err('axis-range', `${key}.min`, `min ${ax.min} is not below max ${ax.max}`)
|
|
193
|
+
if (ax.scale === 'log' && isNum(ax.min) && ax.min <= 0) err('axis-log-min', `${key}.min`, 'a log axis cannot start at or below zero')
|
|
194
|
+
if (ax.scale === 'log' && key === 'xAxis' && ax.type !== undefined && ax.type !== 'number') warn('axis-log-x', `${key}.scale`, "a log x axis needs type: 'number'")
|
|
195
|
+
if (ax.tickCount !== undefined && (!isNum(ax.tickCount) || ax.tickCount <= 0)) err('axis-ticks', `${key}.tickCount`, 'tickCount must be a positive number')
|
|
196
|
+
if (ax.labelRotation !== undefined && ax.labelRotation !== 'auto' && !isNum(ax.labelRotation)) err('axis-rotation', `${key}.labelRotation`, "labelRotation is a number of degrees or 'auto'")
|
|
197
|
+
if (ax.scale !== undefined && ax.scale !== 'linear' && ax.scale !== 'log') err('axis-scale', `${key}.scale`, `scale is 'linear' or 'log', not "${String(ax.scale)}"`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- Spec-level shapes -----------------------------------------------------
|
|
201
|
+
if (s.stacked100 && typeof type === 'string' && type !== 'bar' && type !== 'area') warn('stacked100-kind', 'stacked100', 'stacked100 applies to bar and area charts; ignored here')
|
|
202
|
+
if (s.innerRadius !== undefined && (!isNum(s.innerRadius) || s.innerRadius < 0 || s.innerRadius > 1)) err('inner-radius', 'innerRadius', 'innerRadius is a fraction 0..1 of the pie radius')
|
|
203
|
+
if (Array.isArray(s.categoryGroups) && categories) {
|
|
204
|
+
const span = (s.categoryGroups as Array<{ span?: unknown }>).reduce((a, g) => a + (isObj(g) && isNum(g.span) ? g.span : 0), 0)
|
|
205
|
+
if (span !== n) err('category-groups', 'categoryGroups', `the group spans add up to ${span}, not the ${n} categories`)
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(s.binEdges) && categories && (s.binEdges as unknown[]).length !== n + 1) err('bin-edges', 'binEdges', `binEdges wants ${n + 1} edges for ${n} bins`)
|
|
208
|
+
if (Array.isArray(s.waterfallTotals) && categories && (s.waterfallTotals as unknown[]).length !== n) err('waterfall-totals', 'waterfallTotals', `waterfallTotals has ${(s.waterfallTotals as unknown[]).length} flags for ${n} categories`)
|
|
209
|
+
if (Array.isArray(s.responsive)) {
|
|
210
|
+
;(s.responsive as unknown[]).forEach((r, i) => {
|
|
211
|
+
const p = `responsive[${i}]`
|
|
212
|
+
if (!isObj(r)) { err('responsive-shape', p, 'a rule is { maxWidth?, minWidth?, maxHeight?, spec?, legend? }'); return }
|
|
213
|
+
if (!isNum(r.maxWidth) && !isNum(r.minWidth) && !isNum(r.maxHeight)) warn('responsive-size', p, 'a rule with no maxWidth, minWidth or maxHeight always applies')
|
|
214
|
+
if (isObj(r.spec)) {
|
|
215
|
+
for (const k of ['width', 'height', 'responsive']) if (k in r.spec) warn('responsive-size-key', `${p}.spec.${k}`, `${k} in a responsive rule is ignored; a rule cannot change the size`)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
// Anchors on the category axis that name no category.
|
|
220
|
+
if (categories && typeof type === 'string' && !FREE_LENGTH_TYPES.has(type)) {
|
|
221
|
+
const known = new Set(categories.map(String))
|
|
222
|
+
const isTimeOrNumber = (s.xType ?? (isObj(s.xAxis) ? s.xAxis.type : undefined)) as string | undefined
|
|
223
|
+
const anchored = isTimeOrNumber === 'time' || isTimeOrNumber === 'number'
|
|
224
|
+
const check = (list: unknown, path: string, valueOf: (item: Record<string, unknown>) => unknown) => {
|
|
225
|
+
if (!Array.isArray(list) || anchored) return
|
|
226
|
+
list.forEach((item, i) => {
|
|
227
|
+
if (!isObj(item)) return
|
|
228
|
+
const v = valueOf(item)
|
|
229
|
+
if (typeof v === 'string' && !known.has(v)) warn('unknown-category', `${path}[${i}]`, `"${v}" is not one of the categories, so it has no place on the axis`)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
check(s.annotations, 'annotations', (a) => (isObj(a.at) ? a.at.category : undefined))
|
|
233
|
+
check(Array.isArray(s.referenceLines) ? (s.referenceLines as unknown[]).filter((r) => isObj(r) && r.axis === 'x') : [], 'referenceLines', (r) => r.value)
|
|
234
|
+
// On a time or number axis a drawing's x is a timestamp or a number the
|
|
235
|
+
// axis places itself; the tools write one for every pointer position, so
|
|
236
|
+
// matching those against the category list warned on every drawing.
|
|
237
|
+
if (Array.isArray(s.drawings) && !anchored) {
|
|
238
|
+
;(s.drawings as unknown[]).forEach((d, i) => {
|
|
239
|
+
if (!isObj(d) || !Array.isArray(d.points)) return
|
|
240
|
+
;(d.points as unknown[]).forEach((pt, j) => {
|
|
241
|
+
if (isObj(pt) && typeof pt.x === 'string' && !known.has(pt.x)) warn('unknown-category', `drawings[${i}].points[${j}].x`, `"${pt.x}" is not one of the categories`)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { nearestIndexByTime, panWindow, pinchWindow, presetWindow, wheelWindow } from './chart-zoom'
|
|
3
|
+
import { drillTree, pathTo } from './chart-hierarchy'
|
|
4
|
+
import { interpolateGeometry } from './chart-motion'
|
|
5
|
+
import { buildChart, type ChartSpec } from './chart'
|
|
6
|
+
|
|
7
|
+
describe('wheelWindow', () => {
|
|
8
|
+
it('zooms in around the anchor and keeps its relative position', () => {
|
|
9
|
+
const n = 100
|
|
10
|
+
const w = wheelWindow(null, n, 50, 0.5)!
|
|
11
|
+
expect(w.i1 - w.i0 + 1).toBe(50)
|
|
12
|
+
// The anchor was at the middle of the full axis, so it stays at the middle.
|
|
13
|
+
expect((50 - w.i0) / (w.i1 - w.i0)).toBeCloseTo(0.5, 1)
|
|
14
|
+
const edge = wheelWindow(null, n, 0, 0.5)!
|
|
15
|
+
expect(edge.i0).toBe(0)
|
|
16
|
+
expect(edge.i1).toBe(49)
|
|
17
|
+
})
|
|
18
|
+
it('zooms out until the whole axis, which is null', () => {
|
|
19
|
+
const w = wheelWindow({ i0: 40, i1: 59 }, 100, 50, 2)!
|
|
20
|
+
expect(w.i1 - w.i0 + 1).toBe(40)
|
|
21
|
+
expect(wheelWindow({ i0: 10, i1: 89 }, 100, 50, 2)).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
it('never goes below two categories', () => {
|
|
24
|
+
expect(wheelWindow({ i0: 10, i1: 11 }, 100, 10, 0.5)).toEqual({ i0: 10, i1: 11 })
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('panWindow', () => {
|
|
29
|
+
it('slides the window and clamps at both ends', () => {
|
|
30
|
+
expect(panWindow({ i0: 10, i1: 19 }, 100, 5)).toEqual({ i0: 15, i1: 24 })
|
|
31
|
+
expect(panWindow({ i0: 10, i1: 19 }, 100, -50)).toEqual({ i0: 0, i1: 9 })
|
|
32
|
+
expect(panWindow({ i0: 85, i1: 94 }, 100, 50)).toEqual({ i0: 90, i1: 99 })
|
|
33
|
+
expect(panWindow(null, 100, 5)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('pinchWindow', () => {
|
|
38
|
+
it('spreading the fingers zooms in', () => {
|
|
39
|
+
const w = pinchWindow(null, 100, 50, 2)!
|
|
40
|
+
expect(w.i1 - w.i0 + 1).toBe(50)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('presetWindow', () => {
|
|
45
|
+
const days = Array.from({ length: 730 }, (_, i) => new Date(Date.UTC(2025, 0, 1) + i * 86_400_000).toISOString().slice(0, 10))
|
|
46
|
+
it('picks the last month, three months and year to date up to the latest category', () => {
|
|
47
|
+
const m = presetWindow(days, '1M')!
|
|
48
|
+
expect(m.i1).toBe(729)
|
|
49
|
+
expect(m.i1 - m.i0).toBeGreaterThanOrEqual(28)
|
|
50
|
+
expect(m.i1 - m.i0).toBeLessThanOrEqual(31)
|
|
51
|
+
const q = presetWindow(days, '3M')!
|
|
52
|
+
expect(q.i1 - q.i0).toBeGreaterThanOrEqual(89)
|
|
53
|
+
const ytd = presetWindow(days, 'YTD')!
|
|
54
|
+
expect(days[ytd.i0]).toBe('2026-01-01')
|
|
55
|
+
expect(ytd.i1).toBe(729)
|
|
56
|
+
expect(presetWindow(days, '1W')!.i1 - presetWindow(days, '1W')!.i0).toBe(7)
|
|
57
|
+
})
|
|
58
|
+
it('All and unparseable categories give the whole axis', () => {
|
|
59
|
+
expect(presetWindow(days, 'All')).toBeNull()
|
|
60
|
+
expect(presetWindow(['a', 'b', 'c'], '1M')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
it('takes explicit from / to and day counts', () => {
|
|
63
|
+
const w = presetWindow(days, { label: 'Q1', from: '2026-01-01', to: '2026-03-31' })!
|
|
64
|
+
expect(days[w.i0]).toBe('2026-01-01')
|
|
65
|
+
expect(days[w.i1]).toBe('2026-03-31')
|
|
66
|
+
const d = presetWindow(days, { label: '10d', days: 10 })!
|
|
67
|
+
expect(d.i1 - d.i0).toBe(10)
|
|
68
|
+
})
|
|
69
|
+
it('honours an explicit now', () => {
|
|
70
|
+
const w = presetWindow(days, '1M', Date.UTC(2025, 5, 30))!
|
|
71
|
+
expect(days[w.i1]).toBe('2025-06-30')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('nearestIndexByTime', () => {
|
|
76
|
+
it('finds the closest date and skips non-dates', () => {
|
|
77
|
+
expect(nearestIndexByTime(['2026-01-01', 'x', '2026-01-10', '2026-01-20'], Date.parse('2026-01-12'))).toBe(2)
|
|
78
|
+
expect(nearestIndexByTime(['a', 'b'], 0)).toBe(-1)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('drillTree / pathTo', () => {
|
|
83
|
+
const tree = { name: 'root', children: [{ name: 'A', children: [{ name: 'A1', value: 1 }] }, { name: 'B', value: 2 }] }
|
|
84
|
+
it('descends by names and reports the path to a node', () => {
|
|
85
|
+
expect(drillTree(tree, [])).toBe(tree)
|
|
86
|
+
expect(drillTree(tree, ['A'])!.name).toBe('A')
|
|
87
|
+
expect(drillTree(tree, ['A', 'A1'])!.value).toBe(1)
|
|
88
|
+
expect(drillTree(tree, ['Z'])).toBeNull()
|
|
89
|
+
expect(pathTo(tree, 'A1')).toEqual(['A', 'A1'])
|
|
90
|
+
expect(pathTo(tree, 'nope')).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('interpolateGeometry', () => {
|
|
95
|
+
const spec = (values: number[], categories = ['a', 'b', 'c']): ChartSpec => ({
|
|
96
|
+
type: 'bar', categories, series: [{ label: 's', values }, { label: 'l', values: values.map((v) => v / 2), type: 'line' }], width: 400, height: 200,
|
|
97
|
+
})
|
|
98
|
+
it('is the source at 0 and the target at 1, and halfway between in between', () => {
|
|
99
|
+
const a = buildChart(spec([10, 20, 30]))
|
|
100
|
+
const b = buildChart(spec([30, 20, 10]))
|
|
101
|
+
expect(interpolateGeometry(a, b, 0)).toBe(a)
|
|
102
|
+
expect(interpolateGeometry(a, b, 1)).toBe(b)
|
|
103
|
+
const mid = interpolateGeometry(a, b, 0.5)
|
|
104
|
+
expect(mid.bars[0]!.h).toBeCloseTo((a.bars[0]!.h + b.bars[0]!.h) / 2, 1)
|
|
105
|
+
expect(mid.lines[0]!.points[0]!.y).toBeCloseTo((a.lines[0]!.points[0]!.y + b.lines[0]!.points[0]!.y) / 2, 1)
|
|
106
|
+
expect(mid.lines[0]!.path).not.toBe(a.lines[0]!.path)
|
|
107
|
+
})
|
|
108
|
+
it('grows a new bar from the baseline and shrinks a removed one', () => {
|
|
109
|
+
const a = buildChart(spec([10, 20], ['a', 'b']))
|
|
110
|
+
const b = buildChart(spec([10, 20, 30]))
|
|
111
|
+
const mid = interpolateGeometry(a, b, 0.5)
|
|
112
|
+
const c = mid.bars.find((x) => x.label === 'c')!
|
|
113
|
+
expect(c.h).toBeCloseTo(b.bars[2]!.h / 2, 1)
|
|
114
|
+
const back = interpolateGeometry(b, a, 0.5)
|
|
115
|
+
const gone = back.bars.find((x) => x.label === 'c')!
|
|
116
|
+
expect(gone.h).toBeCloseTo(b.bars[2]!.h / 2, 1)
|
|
117
|
+
expect(interpolateGeometry(b, a, 1).bars.find((x) => x.label === 'c')).toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
it('tweens pie slices and arcs by angle', () => {
|
|
120
|
+
const pie = (v: number[]): ChartSpec => ({ type: 'pie', categories: ['x', 'y'], series: [{ label: 's', values: v }], width: 300, height: 200 })
|
|
121
|
+
const a = buildChart(pie([1, 1]))
|
|
122
|
+
const b = buildChart(pie([3, 1]))
|
|
123
|
+
const mid = interpolateGeometry(a, b, 0.5)
|
|
124
|
+
expect(mid.slices[0]!.arc!.a1).toBeCloseTo((a.slices[0]!.arc!.a1 + b.slices[0]!.arc!.a1) / 2, 6)
|
|
125
|
+
expect(mid.slices[0]!.path).not.toBe(a.slices[0]!.path)
|
|
126
|
+
const rb = (v: number[]): ChartSpec => ({ type: 'radial-bar', categories: ['x', 'y'], series: [{ label: 's', values: v }], width: 300, height: 200 })
|
|
127
|
+
const ra = buildChart(rb([50, 100]))
|
|
128
|
+
const rbz = buildChart(rb([100, 100]))
|
|
129
|
+
const rm = interpolateGeometry(ra, rbz, 0.5)
|
|
130
|
+
expect(rm.arcs[0]!.a1).toBeCloseTo((ra.arcs[0]!.a1 + rbz.arcs[0]!.a1) / 2, 6)
|
|
131
|
+
})
|
|
132
|
+
it('snaps when the chart type changes', () => {
|
|
133
|
+
const a = buildChart(spec([1, 2, 3]))
|
|
134
|
+
const b = buildChart({ ...spec([1, 2, 3]), type: 'pie' })
|
|
135
|
+
expect(interpolateGeometry(a, b, 0.5)).toBe(b)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoom-window arithmetic for the chart: the category window a wheel, a pinch,
|
|
3
|
+
* a pan or a range preset produces. Pure functions over indices and dates, so
|
|
4
|
+
* the renderer's gestures are thin and the maths is unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
import type { ChartZoomWindow } from './chart-types'
|
|
7
|
+
|
|
8
|
+
/** Clamp a window to [0, n-1] with at least one category in it. */
|
|
9
|
+
function clampWindow(i0: number, i1: number, n: number): ChartZoomWindow | null {
|
|
10
|
+
if (n <= 0) return null
|
|
11
|
+
let a = Math.max(0, Math.min(n - 1, Math.round(i0)))
|
|
12
|
+
let b = Math.max(0, Math.min(n - 1, Math.round(i1)))
|
|
13
|
+
if (b < a) [a, b] = [b, a]
|
|
14
|
+
if (a === 0 && b === n - 1) return null
|
|
15
|
+
return { i0: a, i1: b }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Zoom the window in or out around an anchor category, keeping the anchor at
|
|
20
|
+
* the same relative position. `factor < 1` zooms in, `> 1` out; a null window
|
|
21
|
+
* means the whole axis. Never narrower than 2 categories.
|
|
22
|
+
*/
|
|
23
|
+
export function wheelWindow(win: ChartZoomWindow | null, n: number, anchor: number, factor: number): ChartZoomWindow | null {
|
|
24
|
+
if (n <= 2) return null
|
|
25
|
+
const i0 = win ? win.i0 : 0
|
|
26
|
+
const i1 = win ? win.i1 : n - 1
|
|
27
|
+
const span = i1 - i0 + 1
|
|
28
|
+
const next = Math.max(2, Math.min(n, Math.round(span * factor)))
|
|
29
|
+
if (next === span) return win
|
|
30
|
+
// Where the anchor sits inside the window, 0..1, so it stays put.
|
|
31
|
+
const rel = span > 1 ? (Math.max(i0, Math.min(i1, anchor)) - i0) / (span - 1) : 0.5
|
|
32
|
+
let a = Math.round(anchor - rel * (next - 1))
|
|
33
|
+
a = Math.max(0, Math.min(n - next, a))
|
|
34
|
+
return clampWindow(a, a + next - 1, n)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Slide the window by `delta` categories, clamped to the axis. */
|
|
38
|
+
export function panWindow(win: ChartZoomWindow | null, n: number, delta: number): ChartZoomWindow | null {
|
|
39
|
+
if (!win || !delta) return win
|
|
40
|
+
const span = win.i1 - win.i0
|
|
41
|
+
let a = win.i0 + Math.round(delta)
|
|
42
|
+
a = Math.max(0, Math.min(n - 1 - span, a))
|
|
43
|
+
return clampWindow(a, a + span, n)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resize the window by a pinch: `scale > 1` spreads the fingers (zoom in),
|
|
48
|
+
* around the category under the pinch midpoint.
|
|
49
|
+
*/
|
|
50
|
+
export function pinchWindow(win: ChartZoomWindow | null, n: number, midpoint: number, scale: number): ChartZoomWindow | null {
|
|
51
|
+
if (!Number.isFinite(scale) || scale <= 0) return win
|
|
52
|
+
return wheelWindow(win, n, midpoint, 1 / scale)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The range presets a time axis offers. */
|
|
56
|
+
export type ChartRangePreset = '1W' | '1M' | '3M' | '6M' | 'YTD' | '1Y' | 'All' | { label: string; days?: number; from?: string; to?: string }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The window a preset selects on a time / ordinal-time axis whose categories
|
|
60
|
+
* parse as dates: the last N days up to the latest category, the year to
|
|
61
|
+
* date, or an explicit from / to. `'All'` (and a preset nothing matches)
|
|
62
|
+
* returns null, the whole axis. `now` defaults to the last category, which is
|
|
63
|
+
* what "1M" means on a chart that stops last quarter.
|
|
64
|
+
*/
|
|
65
|
+
export function presetWindow(categories: ReadonlyArray<string>, preset: ChartRangePreset, now?: number): ChartZoomWindow | null {
|
|
66
|
+
const n = categories.length
|
|
67
|
+
if (!n) return null
|
|
68
|
+
const times = categories.map((c) => Date.parse(c))
|
|
69
|
+
const last = [...times].reverse().find(Number.isFinite)
|
|
70
|
+
if (last == null) return null
|
|
71
|
+
const end = now ?? last
|
|
72
|
+
const DAY = 86_400_000
|
|
73
|
+
let from: number
|
|
74
|
+
let to = end
|
|
75
|
+
if (typeof preset === 'object') {
|
|
76
|
+
if (preset.from) from = Date.parse(preset.from)
|
|
77
|
+
else if (preset.days) from = end - preset.days * DAY
|
|
78
|
+
else return null
|
|
79
|
+
if (preset.to) to = Date.parse(preset.to)
|
|
80
|
+
} else {
|
|
81
|
+
switch (preset) {
|
|
82
|
+
case '1W': from = end - 7 * DAY; break
|
|
83
|
+
case '1M': from = shiftMonths(end, -1); break
|
|
84
|
+
case '3M': from = shiftMonths(end, -3); break
|
|
85
|
+
case '6M': from = shiftMonths(end, -6); break
|
|
86
|
+
case '1Y': from = shiftMonths(end, -12); break
|
|
87
|
+
case 'YTD': { const d = new Date(end); from = Date.UTC(d.getUTCFullYear(), 0, 1); break }
|
|
88
|
+
default: return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) return null
|
|
92
|
+
let i0 = -1
|
|
93
|
+
let i1 = -1
|
|
94
|
+
for (let i = 0; i < n; i += 1) {
|
|
95
|
+
const t = times[i]!
|
|
96
|
+
if (!Number.isFinite(t)) continue
|
|
97
|
+
if (t >= from && i0 < 0) i0 = i
|
|
98
|
+
if (t <= to) i1 = i
|
|
99
|
+
}
|
|
100
|
+
if (i0 < 0 || i1 < 0 || i1 < i0) return null
|
|
101
|
+
return clampWindow(i0, i1, n)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shiftMonths(t: number, months: number): number {
|
|
105
|
+
const d = new Date(t)
|
|
106
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The category whose date is nearest to `t`, for syncing a hover between two
|
|
111
|
+
* charts whose categories differ. Categories that do not parse are skipped;
|
|
112
|
+
* returns -1 when none do.
|
|
113
|
+
*/
|
|
114
|
+
export function nearestIndexByTime(categories: ReadonlyArray<string>, t: number): number {
|
|
115
|
+
let best = -1
|
|
116
|
+
let bestD = Infinity
|
|
117
|
+
for (let i = 0; i < categories.length; i += 1) {
|
|
118
|
+
const ct = Date.parse(categories[i]!)
|
|
119
|
+
if (!Number.isFinite(ct)) continue
|
|
120
|
+
const d = Math.abs(ct - t)
|
|
121
|
+
if (d < bestD) { bestD = d; best = i }
|
|
122
|
+
}
|
|
123
|
+
return best
|
|
124
|
+
}
|