@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,249 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildChart, rowsToChartSpec, sliceChartWindow, type ChartSeries, type ChartSpec } from './chart'
|
|
3
|
+
import { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate'
|
|
4
|
+
import { bucketStart, percentile, reduceValues } from './chart-stats'
|
|
5
|
+
|
|
6
|
+
describe('pickCategories', () => {
|
|
7
|
+
it('cuts EVERY per-category array on a series, not just the ones that existed when it was written', () => {
|
|
8
|
+
// A series carrying every key in the list, each of a distinct length-4
|
|
9
|
+
// shape, so a key the picker forgets shows up as a wrong length.
|
|
10
|
+
const full: Required<Pick<ChartSeries, (typeof PER_CATEGORY_SERIES_KEYS)[number]>> = {
|
|
11
|
+
values: [1, 2, 3, 4],
|
|
12
|
+
rowIds: [[1], [2], [3], [4]],
|
|
13
|
+
upperValues: [2, 3, 4, 5],
|
|
14
|
+
lowerValues: [0, 1, 2, 3],
|
|
15
|
+
ohlc: [{ o: 1, h: 2, l: 0, c: 1 }, { o: 2, h: 3, l: 1, c: 2 }, { o: 3, h: 4, l: 2, c: 3 }, { o: 4, h: 5, l: 3, c: 4 }],
|
|
16
|
+
boxes: [null, null, { min: 1, q1: 2, median: 3, q3: 4, max: 5 }, null],
|
|
17
|
+
errors: [1, 2, 3, 4],
|
|
18
|
+
markers: [null, { shape: 'square' }, null, { shape: 'none' }],
|
|
19
|
+
colors: ['a', 'b', 'c', 'd'],
|
|
20
|
+
lowValues: [0, 1, 2, 3],
|
|
21
|
+
targets: [5, 6, 7, 8],
|
|
22
|
+
volumes: [100, 200, 300, 400],
|
|
23
|
+
}
|
|
24
|
+
const spec: ChartSpec = {
|
|
25
|
+
type: 'line',
|
|
26
|
+
categories: ['a', 'b', 'c', 'd'],
|
|
27
|
+
series: [{ label: 's', ...full }],
|
|
28
|
+
waterfallTotals: [false, true, false, true],
|
|
29
|
+
}
|
|
30
|
+
const out = pickCategories(spec, [1, 3])
|
|
31
|
+
expect(out.categories).toEqual(['b', 'd'])
|
|
32
|
+
for (const key of PER_CATEGORY_SERIES_KEYS) {
|
|
33
|
+
expect((out.series[0] as unknown as Record<string, unknown[]>)[key], key).toHaveLength(2)
|
|
34
|
+
}
|
|
35
|
+
for (const key of PER_CATEGORY_SPEC_KEYS) {
|
|
36
|
+
expect((out as unknown as Record<string, unknown[]>)[key], key).toHaveLength(2)
|
|
37
|
+
}
|
|
38
|
+
expect(out.series[0]!.values).toEqual([2, 4])
|
|
39
|
+
expect(out.series[0]!.rowIds).toEqual([[2], [4]])
|
|
40
|
+
expect(out.series[0]!.colors).toEqual(['b', 'd'])
|
|
41
|
+
expect(out.waterfallTotals).toEqual([true, true])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is what sliceChartWindow uses, with identical output for a contiguous range', () => {
|
|
45
|
+
const spec: ChartSpec = {
|
|
46
|
+
type: 'line',
|
|
47
|
+
categories: ['a', 'b', 'c', 'd', 'e'],
|
|
48
|
+
series: [{ label: 's', values: [1, 2, 3, 4, 5], upperValues: [2, 3, 4, 5, 6], lowerValues: [0, 1, 2, 3, 4], rowIds: [[1], [2], [3], [4], [5]] }],
|
|
49
|
+
}
|
|
50
|
+
expect(sliceChartWindow(spec, 1, 3)).toEqual(pickCategories(spec, [1, 2, 3]))
|
|
51
|
+
expect(sliceChartWindow(spec, 1, 3).series[0]!.upperValues).toEqual([3, 4, 5])
|
|
52
|
+
expect(sliceChartWindow(spec, -5, 99).categories).toEqual(spec.categories)
|
|
53
|
+
expect(sliceChartWindow(spec, 4, 2).categories).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not invent arrays a series never had', () => {
|
|
57
|
+
const out = pickCategories({ type: 'bar', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] }, [0])
|
|
58
|
+
expect('rowIds' in out.series[0]!).toBe(false)
|
|
59
|
+
expect('waterfallTotals' in out).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('lttb', () => {
|
|
64
|
+
const n = 1000
|
|
65
|
+
const wave = Array.from({ length: n }, (_, i) => Math.sin(i / 20) * 100)
|
|
66
|
+
it('keeps the first and last points and returns ascending indices of the target length', () => {
|
|
67
|
+
const idx = lttb(wave, 100)
|
|
68
|
+
expect(idx).toHaveLength(100)
|
|
69
|
+
expect(idx[0]).toBe(0)
|
|
70
|
+
expect(idx[idx.length - 1]).toBe(n - 1)
|
|
71
|
+
for (let i = 1; i < idx.length; i += 1) expect(idx[i]).toBeGreaterThan(idx[i - 1]!)
|
|
72
|
+
})
|
|
73
|
+
it('preserves a spike that would vanish under naive striding', () => {
|
|
74
|
+
const flat = new Array(n).fill(10)
|
|
75
|
+
flat[537] = 1000
|
|
76
|
+
const idx = lttb(flat, 50)
|
|
77
|
+
expect(idx).toContain(537)
|
|
78
|
+
})
|
|
79
|
+
it('returns everything when there is nothing to thin', () => {
|
|
80
|
+
expect(lttb([1, 2, 3], 10)).toEqual([0, 1, 2])
|
|
81
|
+
expect(lttb([1, 2, 3, 4, 5], 2)).toEqual([0, 4])
|
|
82
|
+
})
|
|
83
|
+
it('never picks a gap as a representative point', () => {
|
|
84
|
+
const gappy = wave.map((v, i) => (i % 7 === 3 ? Number.NaN : v))
|
|
85
|
+
const idx = lttb(gappy, 120)
|
|
86
|
+
expect(idx.slice(1, -1).every((i) => Number.isFinite(gappy[i]))).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('minMaxIndices', () => {
|
|
91
|
+
it('keeps the extreme of every bucket', () => {
|
|
92
|
+
const v = Array.from({ length: 400 }, (_, i) => (i % 50 === 25 ? 500 : i % 50 === 10 ? -500 : 0))
|
|
93
|
+
const idx = minMaxIndices(v, 8)
|
|
94
|
+
for (let b = 0; b < 8; b += 1) {
|
|
95
|
+
expect(idx).toContain(b * 50 + 25)
|
|
96
|
+
expect(idx).toContain(b * 50 + 10)
|
|
97
|
+
}
|
|
98
|
+
expect(idx[0]).toBe(0)
|
|
99
|
+
expect(idx[idx.length - 1]).toBe(399)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('decimateSpec', () => {
|
|
104
|
+
const long = (type: ChartSpec['type'] = 'line', extra: Partial<ChartSpec> = {}): ChartSpec => ({
|
|
105
|
+
type,
|
|
106
|
+
categories: Array.from({ length: 5000 }, (_, i) => `c${i}`),
|
|
107
|
+
series: [
|
|
108
|
+
{ label: 'a', values: Array.from({ length: 5000 }, (_, i) => Math.sin(i / 90) * 50), rowIds: Array.from({ length: 5000 }, (_, i) => [i]) },
|
|
109
|
+
{ label: 'b', values: Array.from({ length: 5000 }, (_, i) => (i === 4321 ? 900 : Math.cos(i / 70) * 30)) },
|
|
110
|
+
],
|
|
111
|
+
...extra,
|
|
112
|
+
})
|
|
113
|
+
it('thins a long line to about the plot width and keeps rowIds aligned', () => {
|
|
114
|
+
const out = decimateSpec(long(), 400)
|
|
115
|
+
expect(out.categories.length).toBeLessThan(5000)
|
|
116
|
+
expect(out.categories.length).toBeGreaterThanOrEqual(400)
|
|
117
|
+
out.categories.forEach((c, k) => {
|
|
118
|
+
const i = Number(c.slice(1))
|
|
119
|
+
expect(out.series[0]!.rowIds![k]).toEqual([i])
|
|
120
|
+
expect(out.series[0]!.values[k]).toBe(Math.sin(i / 90) * 50)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
it('keeps a spike that only one series has', () => {
|
|
124
|
+
const out = decimateSpec(long(), 300)
|
|
125
|
+
expect(out.categories).toContain('c4321')
|
|
126
|
+
})
|
|
127
|
+
it('does nothing below the threshold, when off, or when bars are involved', () => {
|
|
128
|
+
const short = { ...long(), categories: long().categories.slice(0, 100), series: long().series.map((s) => ({ ...s, values: s.values.slice(0, 100), rowIds: s.rowIds?.slice(0, 100) })) }
|
|
129
|
+
expect(decimateSpec(short, 400)).toBe(short)
|
|
130
|
+
const off = long('line', { decimate: false })
|
|
131
|
+
expect(decimateSpec(off, 400)).toBe(off)
|
|
132
|
+
const bars = long('bar')
|
|
133
|
+
expect(decimateSpec(bars, 400)).toBe(bars)
|
|
134
|
+
const combo = long('line', { series: [{ label: 'v', values: long().series[0]!.values, type: 'bar' }, long().series[1]!] })
|
|
135
|
+
expect(decimateSpec(combo, 400)).toBe(combo)
|
|
136
|
+
})
|
|
137
|
+
it('honours an explicit target and method', () => {
|
|
138
|
+
const a = decimateSpec(long(), 400, { target: 100 })
|
|
139
|
+
expect(a.categories.length).toBeLessThan(220)
|
|
140
|
+
const b = decimateSpec(long(), 400, { method: 'minmax', target: 100 })
|
|
141
|
+
expect(b.categories).toContain('c4321')
|
|
142
|
+
})
|
|
143
|
+
it('lays out through buildChart with the decimated categories', () => {
|
|
144
|
+
const geo = buildChart({ ...decimateSpec(long(), 500), width: 600, height: 300 })
|
|
145
|
+
expect(geo.lines[0]!.points.length).toBeLessThan(5000)
|
|
146
|
+
expect(geo.lines[0]!.points.length).toBeGreaterThan(100)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('reducers', () => {
|
|
151
|
+
const s = [5, 1, 4, 2, 3, 3]
|
|
152
|
+
it('computes every reducer', () => {
|
|
153
|
+
expect(reduceValues(s, 'sum')).toBe(18)
|
|
154
|
+
expect(reduceValues(s, 'avg')).toBe(3)
|
|
155
|
+
expect(reduceValues(s, 'count')).toBe(6)
|
|
156
|
+
expect(reduceValues(s, 'min')).toBe(1)
|
|
157
|
+
expect(reduceValues(s, 'max')).toBe(5)
|
|
158
|
+
expect(reduceValues(s, 'first')).toBe(5)
|
|
159
|
+
expect(reduceValues(s, 'last')).toBe(3)
|
|
160
|
+
expect(reduceValues(s, 'countDistinct')).toBe(5)
|
|
161
|
+
expect(reduceValues(s, 'median')).toBe(3)
|
|
162
|
+
expect(reduceValues(s, 'p90')).toBeCloseTo(4.5, 6)
|
|
163
|
+
expect(reduceValues(s, 'p100')).toBe(5)
|
|
164
|
+
})
|
|
165
|
+
it('an empty group is 0 for sum / count and a gap for the rest', () => {
|
|
166
|
+
expect(reduceValues([], 'sum')).toBe(0)
|
|
167
|
+
expect(reduceValues([], 'count')).toBe(0)
|
|
168
|
+
expect(reduceValues([], 'avg')).toBe(0)
|
|
169
|
+
expect(Number.isNaN(reduceValues([], 'median'))).toBe(true)
|
|
170
|
+
expect(Number.isNaN(reduceValues([], 'min'))).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
it('percentile interpolates between order statistics', () => {
|
|
173
|
+
expect(percentile([10, 20, 30, 40], 50)).toBe(25)
|
|
174
|
+
expect(percentile([10, 20, 30, 40], 0)).toBe(10)
|
|
175
|
+
expect(percentile([10, 20, 30, 40], 100)).toBe(40)
|
|
176
|
+
})
|
|
177
|
+
it('rowsToChartSpec takes the new reducers', () => {
|
|
178
|
+
const rows = [
|
|
179
|
+
{ region: 'N', v: 10 }, { region: 'N', v: 30 }, { region: 'N', v: 20 },
|
|
180
|
+
{ region: 'S', v: 7 }, { region: 'S', v: 7 },
|
|
181
|
+
]
|
|
182
|
+
expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'median' }).series[0]!.values).toEqual([20, 7])
|
|
183
|
+
expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'max' }).series[0]!.values).toEqual([30, 7])
|
|
184
|
+
expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'countDistinct' }).series[0]!.values).toEqual([3, 1])
|
|
185
|
+
expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'last' }).series[0]!.values).toEqual([20, 7])
|
|
186
|
+
// and the original three are untouched
|
|
187
|
+
expect(rowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'v', reduce: 'avg' }).series[0]!.values).toEqual([20, 7])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('time bucketing', () => {
|
|
192
|
+
it('bucketStart lands on the calendar boundary, in UTC', () => {
|
|
193
|
+
expect(bucketStart('2026-03-18T15:00:00Z', 'day')).toBe('2026-03-18')
|
|
194
|
+
expect(bucketStart('2026-03-18', 'week')).toBe('2026-03-16') // a Wednesday -> its Monday
|
|
195
|
+
expect(bucketStart('2026-03-22', 'week')).toBe('2026-03-16') // Sunday belongs to the week before
|
|
196
|
+
expect(bucketStart('2026-03-18', 'month')).toBe('2026-03-01')
|
|
197
|
+
expect(bucketStart('2026-05-18', 'quarter')).toBe('2026-04-01')
|
|
198
|
+
expect(bucketStart('2026-12-31', 'quarter')).toBe('2026-10-01')
|
|
199
|
+
expect(bucketStart('2026-03-18', 'year')).toBe('2026-01-01')
|
|
200
|
+
expect(bucketStart(Date.UTC(2026, 0, 1), 'year')).toBe('2026-01-01')
|
|
201
|
+
expect(bucketStart('not a date', 'month')).toBe('')
|
|
202
|
+
})
|
|
203
|
+
it('rowsToChartSpec buckets a date category and sorts it chronologically', () => {
|
|
204
|
+
const rows = [
|
|
205
|
+
{ day: '2026-03-20', v: 1 },
|
|
206
|
+
{ day: '2026-01-05', v: 2 },
|
|
207
|
+
{ day: '2026-03-02', v: 3 },
|
|
208
|
+
{ day: '2026-01-30', v: 4 },
|
|
209
|
+
{ day: 'garbage', v: 99 },
|
|
210
|
+
]
|
|
211
|
+
const spec = rowsToChartSpec(rows, { type: 'line', category: 'day', value: 'v', bucket: 'month' })
|
|
212
|
+
expect(spec.categories).toEqual(['2026-01-01', '2026-03-01'])
|
|
213
|
+
expect(spec.series[0]!.values).toEqual([6, 4])
|
|
214
|
+
expect(spec.xType).toBe('ordinal-time')
|
|
215
|
+
const weeks = rowsToChartSpec(rows, { type: 'line', category: 'day', value: 'v', bucket: 'week', reduce: 'count' })
|
|
216
|
+
expect(weeks.categories).toEqual(['2026-01-05', '2026-01-26', '2026-03-02', '2026-03-16'])
|
|
217
|
+
expect(weeks.series[0]!.values).toEqual([1, 1, 1, 1])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('lttb with explicit x positions', () => {
|
|
222
|
+
it('measures the triangles in the given positions rather than by index', () => {
|
|
223
|
+
// Points crowd together in x except the last two, which are far apart. A
|
|
224
|
+
// spike at index 3 is small in area when x is the index, but with the
|
|
225
|
+
// real spacing the wide final segment matters more than the spike.
|
|
226
|
+
const values = [0, 0, 0, 5, 0, 0, 0, 10]
|
|
227
|
+
const xs = [0, 1, 2, 3, 4, 5, 6, 1000]
|
|
228
|
+
const byIndex = lttb(values, 4)
|
|
229
|
+
const byX = lttb(values, 4, xs)
|
|
230
|
+
expect(byIndex[0]).toBe(0)
|
|
231
|
+
expect(byIndex[byIndex.length - 1]).toBe(7)
|
|
232
|
+
expect(byX[0]).toBe(0)
|
|
233
|
+
expect(byX[byX.length - 1]).toBe(7)
|
|
234
|
+
expect(byX).toHaveLength(4)
|
|
235
|
+
// Both keep the first and last point and stay strictly increasing.
|
|
236
|
+
for (let k = 1; k < byX.length; k += 1) expect(byX[k]!).toBeGreaterThan(byX[k - 1]!)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('decimateSpec passes log10 positions for a log number axis', () => {
|
|
240
|
+
const n = 2000
|
|
241
|
+
const categories = Array.from({ length: n }, (_, i) => String(Math.pow(10, (i / (n - 1)) * 4)))
|
|
242
|
+
const values = categories.map((c) => Math.log10(Number(c)) * 10 + (Number(c) > 5000 && Number(c) < 5100 ? 50 : 0))
|
|
243
|
+
const spec: ChartSpec = { type: 'line', categories, series: [{ label: 'v', values }], xAxis: { type: 'number', scale: 'log' } }
|
|
244
|
+
const thin = decimateSpec(spec, 300)
|
|
245
|
+
expect(thin.categories.length).toBeLessThan(n)
|
|
246
|
+
// The spike (a band of points 50 above the trend) survives.
|
|
247
|
+
expect(Math.max(...thin.series[0]!.values)).toBeGreaterThan(80)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinning long series before layout, and the one place that knows which
|
|
3
|
+
* arrays on a spec run parallel to `categories`.
|
|
4
|
+
*
|
|
5
|
+
* `buildChart` scales fine (100k line points is ~140 ms of maths); what does
|
|
6
|
+
* not scale is asking the DOM to hold a mark per point, and past one point
|
|
7
|
+
* per pixel the extra marks carry no information anyway. So a long line or
|
|
8
|
+
* area series is reduced to about one point per pixel with an algorithm that
|
|
9
|
+
* keeps its visual shape (Largest-Triangle-Three-Buckets) or its extremes
|
|
10
|
+
* (min / max per bucket), and every category-parallel array is picked with the
|
|
11
|
+
* same indices so tooltips, drill-through and error bars stay aligned.
|
|
12
|
+
*/
|
|
13
|
+
import type { ChartDecimateConfig, ChartSeries, ChartSpec } from './chart-types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every array on a {@link ChartSeries} indexed by category. Anything that
|
|
17
|
+
* narrows a spec to a subset of categories (the zoom window, decimation) picks
|
|
18
|
+
* each of these with the same indices; a key missing here is a mark drawn
|
|
19
|
+
* against the wrong category, which is why the list is one constant rather
|
|
20
|
+
* than a convention. `values` is included for completeness.
|
|
21
|
+
*/
|
|
22
|
+
export const PER_CATEGORY_SERIES_KEYS = [
|
|
23
|
+
'values',
|
|
24
|
+
'rowIds',
|
|
25
|
+
'upperValues',
|
|
26
|
+
'lowerValues',
|
|
27
|
+
'ohlc',
|
|
28
|
+
'boxes',
|
|
29
|
+
'errors',
|
|
30
|
+
'markers',
|
|
31
|
+
'colors',
|
|
32
|
+
'lowValues',
|
|
33
|
+
'targets',
|
|
34
|
+
'volumes',
|
|
35
|
+
] as const satisfies ReadonlyArray<keyof ChartSeries>
|
|
36
|
+
|
|
37
|
+
/** Spec-level arrays indexed by category (see {@link PER_CATEGORY_SERIES_KEYS}). */
|
|
38
|
+
export const PER_CATEGORY_SPEC_KEYS = ['waterfallTotals'] as const satisfies ReadonlyArray<keyof ChartSpec>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Narrow a spec to the categories at `indices` (ascending), keeping every
|
|
42
|
+
* category-parallel array in step. The zoom window and decimation both go
|
|
43
|
+
* through here, so there is exactly one list of arrays to keep in step.
|
|
44
|
+
*/
|
|
45
|
+
export function pickCategories(spec: ChartSpec, indices: ReadonlyArray<number>): ChartSpec {
|
|
46
|
+
const pick = <T,>(arr: T[] | undefined): T[] | undefined => {
|
|
47
|
+
if (!arr) return undefined
|
|
48
|
+
const out: T[] = new Array(indices.length)
|
|
49
|
+
for (let k = 0; k < indices.length; k += 1) out[k] = arr[indices[k]!]!
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
const series = spec.series.map((s) => {
|
|
53
|
+
const next: ChartSeries = { ...s, values: pick(s.values) ?? [] }
|
|
54
|
+
for (const key of PER_CATEGORY_SERIES_KEYS) {
|
|
55
|
+
if (key === 'values') continue
|
|
56
|
+
const arr = s[key] as unknown[] | undefined
|
|
57
|
+
if (arr) (next as unknown as Record<string, unknown>)[key] = pick(arr)
|
|
58
|
+
}
|
|
59
|
+
return next
|
|
60
|
+
})
|
|
61
|
+
const out: ChartSpec = { ...spec, categories: pick(spec.categories) ?? [], series }
|
|
62
|
+
for (const key of PER_CATEGORY_SPEC_KEYS) {
|
|
63
|
+
const arr = spec[key] as unknown[] | undefined
|
|
64
|
+
if (arr) (out as unknown as Record<string, unknown>)[key] = pick(arr)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Largest-Triangle-Three-Buckets: the indices of the `target` points that best
|
|
71
|
+
* preserve the visual shape of `values` plotted against their index. The
|
|
72
|
+
* first and last points are always kept. Non-finite values are skipped as
|
|
73
|
+
* candidates, so a gap cannot be "chosen" as a representative point.
|
|
74
|
+
*/
|
|
75
|
+
export function lttb(values: ReadonlyArray<number>, target: number, xs?: ReadonlyArray<number>): number[] {
|
|
76
|
+
const n = values.length
|
|
77
|
+
// The x of point j: its index unless the caller positions points by value
|
|
78
|
+
// (a numeric or log axis), where the triangles must use the real spacing.
|
|
79
|
+
const xAt = (j: number) => (xs && Number.isFinite(xs[j]) ? xs[j]! : j)
|
|
80
|
+
if (target >= n || n <= 2) return values.map((_, i) => i)
|
|
81
|
+
if (target < 3) return [0, n - 1]
|
|
82
|
+
const out: number[] = [0]
|
|
83
|
+
const every = (n - 2) / (target - 2)
|
|
84
|
+
let a = 0
|
|
85
|
+
for (let i = 0; i < target - 2; i += 1) {
|
|
86
|
+
// The next bucket's centroid is the far corner of every candidate triangle.
|
|
87
|
+
const nextStart = Math.floor((i + 1) * every) + 1
|
|
88
|
+
const nextEnd = Math.min(Math.floor((i + 2) * every) + 1, n)
|
|
89
|
+
let avgX = 0
|
|
90
|
+
let avgY = 0
|
|
91
|
+
let cnt = 0
|
|
92
|
+
for (let j = nextStart; j < nextEnd; j += 1) {
|
|
93
|
+
const v = values[j]!
|
|
94
|
+
if (!Number.isFinite(v)) continue
|
|
95
|
+
avgX += xAt(j)
|
|
96
|
+
avgY += v
|
|
97
|
+
cnt += 1
|
|
98
|
+
}
|
|
99
|
+
if (cnt) { avgX /= cnt; avgY /= cnt } else { avgX = (xAt(nextStart) + xAt(Math.min(nextEnd, n - 1))) / 2; avgY = values[a]! }
|
|
100
|
+
const start = Math.floor(i * every) + 1
|
|
101
|
+
const end = Math.min(Math.floor((i + 1) * every) + 1, n)
|
|
102
|
+
const ax = xAt(a)
|
|
103
|
+
const ay = Number.isFinite(values[a]!) ? values[a]! : 0
|
|
104
|
+
let best = -1
|
|
105
|
+
let bestArea = -1
|
|
106
|
+
for (let j = start; j < end; j += 1) {
|
|
107
|
+
const v = values[j]!
|
|
108
|
+
if (!Number.isFinite(v)) continue
|
|
109
|
+
const area = Math.abs((ax - avgX) * (v - ay) - (ax - xAt(j)) * (avgY - ay))
|
|
110
|
+
if (area > bestArea) { bestArea = area; best = j }
|
|
111
|
+
}
|
|
112
|
+
if (best < 0) best = start
|
|
113
|
+
out.push(best)
|
|
114
|
+
a = best
|
|
115
|
+
}
|
|
116
|
+
out.push(n - 1)
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The indices of the minimum and maximum in each of `buckets` equal slices of
|
|
122
|
+
* `values`, plus the first and last point. Keeps every spike, which is what
|
|
123
|
+
* monitoring data wants and what LTTB will sometimes smooth over.
|
|
124
|
+
*/
|
|
125
|
+
export function minMaxIndices(values: ReadonlyArray<number>, buckets: number): number[] {
|
|
126
|
+
const n = values.length
|
|
127
|
+
if (buckets >= n || n <= 2) return values.map((_, i) => i)
|
|
128
|
+
const keep = new Set<number>([0, n - 1])
|
|
129
|
+
const size = n / Math.max(1, buckets)
|
|
130
|
+
for (let b = 0; b < buckets; b += 1) {
|
|
131
|
+
const start = Math.floor(b * size)
|
|
132
|
+
const end = Math.min(n, Math.floor((b + 1) * size))
|
|
133
|
+
let lo = -1
|
|
134
|
+
let hi = -1
|
|
135
|
+
for (let j = start; j < end; j += 1) {
|
|
136
|
+
const v = values[j]!
|
|
137
|
+
if (!Number.isFinite(v)) continue
|
|
138
|
+
if (lo < 0 || v < values[lo]!) lo = j
|
|
139
|
+
if (hi < 0 || v > values[hi]!) hi = j
|
|
140
|
+
}
|
|
141
|
+
if (lo >= 0) keep.add(lo)
|
|
142
|
+
if (hi >= 0) keep.add(hi)
|
|
143
|
+
}
|
|
144
|
+
return [...keep].sort((x, y) => x - y)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The decimation settings a spec asks for, defaults filled in. */
|
|
148
|
+
function resolveDecimate(cfg: ChartDecimateConfig | undefined, plotW: number) {
|
|
149
|
+
if (cfg === false) return null
|
|
150
|
+
const o = typeof cfg === 'object' ? cfg : {}
|
|
151
|
+
const target = Math.max(8, Math.round(o.target ?? plotW))
|
|
152
|
+
return { method: o.method ?? 'lttb', target, threshold: Math.max(target, Math.round(o.threshold ?? target)) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Thin a spec's line / area series to about one point per pixel of plot
|
|
157
|
+
* width. Nothing happens below the threshold, for a spec that says
|
|
158
|
+
* `decimate: false`, or when any series draws bars, candles or boxes (those
|
|
159
|
+
* marks ARE the data; thinning them would hide categories). Every series is
|
|
160
|
+
* decimated separately and the union of the kept indices is used, so a spike
|
|
161
|
+
* that only one series has still survives.
|
|
162
|
+
*/
|
|
163
|
+
export function decimateSpec(spec: ChartSpec, plotW: number, cfg: ChartDecimateConfig | undefined = spec.decimate): ChartSpec {
|
|
164
|
+
const opt = resolveDecimate(cfg, plotW)
|
|
165
|
+
if (!opt) return spec
|
|
166
|
+
const n = spec.categories.length
|
|
167
|
+
if (n <= opt.threshold || !spec.series.length) return spec
|
|
168
|
+
const lineLike = (t: string | undefined) => t === 'line' || t === 'area'
|
|
169
|
+
if (!lineLike(spec.type) && !spec.series.every((s) => lineLike(s.type))) return spec
|
|
170
|
+
if (spec.series.some((s) => (s.type && !lineLike(s.type)) || s.ohlc || s.boxes)) return spec
|
|
171
|
+
// On a numeric axis the points sit where their values are, so the
|
|
172
|
+
// triangles are measured in those positions (log10 of them on a log axis).
|
|
173
|
+
const numeric = (spec.xAxis?.type ?? spec.xType) === 'number'
|
|
174
|
+
const isLog = numeric && spec.xAxis?.scale === 'log'
|
|
175
|
+
const xs = numeric
|
|
176
|
+
? spec.categories.map((c) => { const v = Number(c); return isLog ? (v > 0 ? Math.log10(v) : Number.NaN) : v })
|
|
177
|
+
: undefined
|
|
178
|
+
const keep = new Set<number>()
|
|
179
|
+
for (const s of spec.series) {
|
|
180
|
+
const idx = opt.method === 'minmax' ? minMaxIndices(s.values, Math.floor(opt.target / 2)) : lttb(s.values, opt.target, xs)
|
|
181
|
+
for (const i of idx) keep.add(i)
|
|
182
|
+
}
|
|
183
|
+
const indices = [...keep].sort((a, b) => a - b)
|
|
184
|
+
if (indices.length >= n) return spec
|
|
185
|
+
return pickCategories(spec, indices)
|
|
186
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { buildChartPdf, printChart } from './chart-export-pdf'
|
|
3
|
+
|
|
4
|
+
const ascii = (bytes: Uint8Array) => Array.from(bytes, (b) => String.fromCharCode(b)).join('')
|
|
5
|
+
|
|
6
|
+
describe('buildChartPdf', () => {
|
|
7
|
+
// A fake JPEG: the SOI marker and a few bytes. The writer never inspects it.
|
|
8
|
+
const jpeg = { bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0xff, 0xd9]), width: 800, height: 400 }
|
|
9
|
+
|
|
10
|
+
it('writes a one-page PDF with the image as a DCT XObject and the title as text', () => {
|
|
11
|
+
const out = buildChartPdf(jpeg, { title: 'Revenue (Q1)', subtitle: 'by region', caption: 'Source: ledger' })
|
|
12
|
+
const text = ascii(out)
|
|
13
|
+
expect(text.startsWith('%PDF-1.4')).toBe(true)
|
|
14
|
+
expect(text).toContain('/Type /Catalog')
|
|
15
|
+
expect(text).toContain('/Count 1')
|
|
16
|
+
expect(text).toContain('/Subtype /Image')
|
|
17
|
+
expect(text).toContain('/Filter /DCTDecode')
|
|
18
|
+
expect(text).toContain(`/Length ${jpeg.bytes.length}`)
|
|
19
|
+
expect(text).toContain('/BaseFont /Helvetica')
|
|
20
|
+
// Parentheses in the title are escaped inside the string literal.
|
|
21
|
+
expect(text).toContain('(Revenue \\(Q1\\)) Tj')
|
|
22
|
+
expect(text).toContain('(by region) Tj')
|
|
23
|
+
expect(text).toContain('(Source: ledger) Tj')
|
|
24
|
+
expect(text).toContain('/Im1 Do')
|
|
25
|
+
expect(text.trimEnd().endsWith('%%EOF')).toBe(true)
|
|
26
|
+
// The JPEG bytes are in there verbatim.
|
|
27
|
+
expect(text).toContain(ascii(jpeg.bytes))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('has an xref table whose offsets point at each object', () => {
|
|
31
|
+
const out = buildChartPdf(jpeg)
|
|
32
|
+
const text = ascii(out)
|
|
33
|
+
const startxref = Number(/startxref\n(\d+)\n%%EOF/.exec(text)![1])
|
|
34
|
+
expect(text.slice(startxref, startxref + 4)).toBe('xref')
|
|
35
|
+
// Line 0 is "xref", line 1 "0 7", then one row per object from 0.
|
|
36
|
+
const rows = text.slice(startxref).split('\n').slice(2, 9)
|
|
37
|
+
for (let n = 1; n <= 6; n += 1) {
|
|
38
|
+
const offset = Number(rows[n]!.slice(0, 10))
|
|
39
|
+
expect(text.slice(offset, offset + `${n} 0 obj`.length)).toBe(`${n} 0 obj`)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('picks landscape for a wide chart and portrait for a tall one, unless told otherwise', () => {
|
|
44
|
+
expect(ascii(buildChartPdf(jpeg))).toContain('/MediaBox [0 0 841.89 595.28]')
|
|
45
|
+
expect(ascii(buildChartPdf({ ...jpeg, width: 400, height: 800 }))).toContain('/MediaBox [0 0 595.28 841.89]')
|
|
46
|
+
expect(ascii(buildChartPdf(jpeg, { orientation: 'portrait', page: 'Letter' }))).toContain('/MediaBox [0 0 612.00 792.00]')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('scales the image to fit inside the margins', () => {
|
|
50
|
+
const text = ascii(buildChartPdf(jpeg, { page: 'A4' }))
|
|
51
|
+
const m = /q ([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm \/Im1 Do Q/.exec(text)!
|
|
52
|
+
const [w, h, x, y] = [1, 2, 3, 4].map((i) => Number(m[i])) as [number, number, number, number]
|
|
53
|
+
expect(w).toBeLessThanOrEqual(841.89 - 80)
|
|
54
|
+
expect(h).toBeCloseTo(w / 2, 1)
|
|
55
|
+
expect(x).toBeGreaterThanOrEqual(40)
|
|
56
|
+
expect(y).toBeGreaterThanOrEqual(40)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('printChart', () => {
|
|
61
|
+
afterEach(() => vi.restoreAllMocks())
|
|
62
|
+
|
|
63
|
+
it('opens a window with the standalone svg and prints it', () => {
|
|
64
|
+
const host = document.createElement('div')
|
|
65
|
+
host.innerHTML = '<svg class="sv-grid-chart-svg" viewBox="0 0 100 50"><rect class="sv-grid-chart-bar" width="10" height="10" fill="#f00"/></svg>'
|
|
66
|
+
document.body.appendChild(host)
|
|
67
|
+
const written: string[] = []
|
|
68
|
+
const fake = {
|
|
69
|
+
document: { write: (s: string) => written.push(s), close: () => {}, readyState: 'complete' },
|
|
70
|
+
focus: vi.fn(),
|
|
71
|
+
print: vi.fn(),
|
|
72
|
+
addEventListener: vi.fn(),
|
|
73
|
+
}
|
|
74
|
+
vi.spyOn(window, 'open').mockReturnValue(fake as unknown as Window)
|
|
75
|
+
vi.useFakeTimers()
|
|
76
|
+
expect(printChart(host, { title: 'Q1 <sales>' })).toBe(true)
|
|
77
|
+
vi.runAllTimers()
|
|
78
|
+
vi.useRealTimers()
|
|
79
|
+
expect(fake.print).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(written.join('')).toContain('<h1>Q1 <sales></h1>')
|
|
81
|
+
expect(written.join('')).toContain('sv-grid-chart-bar')
|
|
82
|
+
host.remove()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('reports a blocked popup instead of throwing', () => {
|
|
86
|
+
const host = document.createElement('div')
|
|
87
|
+
host.innerHTML = '<svg class="sv-grid-chart-svg" viewBox="0 0 100 50"></svg>'
|
|
88
|
+
vi.spyOn(window, 'open').mockReturnValue(null)
|
|
89
|
+
expect(printChart(host)).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|