@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small sample spec per chart type, for a type gallery: the builder's
|
|
3
|
+
* thumbnails, the docs. One tiny seeded dataset, so every card draws the
|
|
4
|
+
* same numbers and the shapes are what differ. Lives in its own module and
|
|
5
|
+
* is imported only by the builder, so a grid that never opens it never pays.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChartSpec, ChartType } from './chart-types'
|
|
8
|
+
import { rowsToBoxSpec, rowsToChartSpec, rowsToHistogramSpec, rowsToRangeSpec, rowsToScatterSpec, specToCalendar, specToSankey, specToTreemap } from './chart'
|
|
9
|
+
|
|
10
|
+
type Order = { day: string; region: string; channel: string; units: number; cost: number; revenue: number }
|
|
11
|
+
|
|
12
|
+
let cached: Order[] | null = null
|
|
13
|
+
function rows(): Order[] {
|
|
14
|
+
if (cached) return cached
|
|
15
|
+
let seed = 7
|
|
16
|
+
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
|
|
17
|
+
const regions = ['North', 'South', 'East', 'West']
|
|
18
|
+
const channels = ['Web', 'Retail', 'Partner']
|
|
19
|
+
const out: Order[] = []
|
|
20
|
+
const start = Date.UTC(2026, 0, 1)
|
|
21
|
+
for (let i = 0; i < 160; i += 1) {
|
|
22
|
+
const day = new Date(start + Math.floor(rnd() * 180) * 86_400_000).toISOString().slice(0, 10)
|
|
23
|
+
const units = 1 + Math.floor(rnd() * 12)
|
|
24
|
+
const price = 40 + rnd() * 160
|
|
25
|
+
const revenue = Math.round(units * price)
|
|
26
|
+
out.push({ day, region: regions[Math.floor(rnd() * 4)]!, channel: channels[Math.floor(rnd() * 3)]!, units, cost: Math.round(revenue * (0.5 + rnd() * 0.3)), revenue })
|
|
27
|
+
}
|
|
28
|
+
cached = out
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The label a type gallery shows for each type. */
|
|
33
|
+
export const CHART_TYPE_LABELS: Record<ChartType, string> = {
|
|
34
|
+
bar: 'Bar', line: 'Line', area: 'Area', pie: 'Pie', scatter: 'Scatter', heatmap: 'Heat map', waterfall: 'Waterfall',
|
|
35
|
+
funnel: 'Funnel', radar: 'Radar', calendar: 'Calendar', gauge: 'Gauge', treemap: 'Tree map', sankey: 'Sankey',
|
|
36
|
+
candlestick: 'Candlestick', ohlc: 'OHLC', boxplot: 'Box plot', histogram: 'Histogram', 'range-bar': 'Range bar',
|
|
37
|
+
'range-area': 'Range area', lollipop: 'Lollipop', dumbbell: 'Dumbbell', pareto: 'Pareto', stream: 'Stream',
|
|
38
|
+
sunburst: 'Sunburst', 'radial-bar': 'Radial bar', 'radial-column': 'Radial column', nightingale: 'Nightingale',
|
|
39
|
+
chord: 'Chord', bullet: 'Bullet',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A complete, small spec of the given type, drawn from the shared sample
|
|
44
|
+
* dataset. Pass a `palette` to colour it like the chart it stands in for.
|
|
45
|
+
*/
|
|
46
|
+
export function sampleChartSpec(type: ChartType, palette?: string[]): ChartSpec {
|
|
47
|
+
const data = rows()
|
|
48
|
+
const byRegion = rowsToChartSpec(data, { type: 'bar', category: 'region', value: 'revenue' })
|
|
49
|
+
const byRegionChannel = rowsToChartSpec(data, { type: 'bar', category: 'region', value: 'revenue', series: 'channel' })
|
|
50
|
+
const byMonth = rowsToChartSpec(data, { type: 'line', category: 'day', value: 'revenue', bucket: 'month' })
|
|
51
|
+
const byMonthChannel = rowsToChartSpec(data, { type: 'area', category: 'day', value: 'revenue', series: 'channel', bucket: 'month' })
|
|
52
|
+
const ranged = () => rowsToRangeSpec(
|
|
53
|
+
byRegion.categories.map((c, i) => ({ region: c, cost: byRegion.series[0]!.values[i]! * 0.6, revenue: byRegion.series[0]!.values[i]! })),
|
|
54
|
+
{ category: 'region', low: 'cost', high: 'revenue' },
|
|
55
|
+
)
|
|
56
|
+
const ohlc = () => {
|
|
57
|
+
const v = byMonth.series[0]!.values
|
|
58
|
+
const bars = v.map((c, i) => { const o = i ? v[i - 1]! : c * 0.95; return { o, c, h: Math.max(o, c) * 1.04, l: Math.min(o, c) * 0.96 } })
|
|
59
|
+
return { categories: byMonth.categories, series: [{ label: 'Revenue', values: v, ohlc: bars }], xType: 'ordinal-time' as const }
|
|
60
|
+
}
|
|
61
|
+
let spec: ChartSpec
|
|
62
|
+
switch (type) {
|
|
63
|
+
case 'line': spec = { ...byMonthChannel, type: 'line' }; break
|
|
64
|
+
case 'area': spec = { ...byMonthChannel, type: 'area', stacked: true }; break
|
|
65
|
+
case 'lollipop': spec = { ...byRegion, type: 'lollipop' }; break
|
|
66
|
+
case 'dumbbell': spec = { ...ranged(), type: 'dumbbell' }; break
|
|
67
|
+
case 'range-bar': spec = ranged(); break
|
|
68
|
+
case 'range-area': spec = { ...byMonth, type: 'range-area', series: [{ label: 'Band', values: byMonth.series[0]!.values.map((v) => v * 1.15), lowValues: byMonth.series[0]!.values.map((v) => v * 0.85) }] }; break
|
|
69
|
+
case 'pareto': spec = { ...rowsToChartSpec(data, { type: 'bar', category: 'channel', value: 'units' }), type: 'pareto' }; break
|
|
70
|
+
case 'radial-column': spec = { ...byRegionChannel, type: 'radial-column', stacked: true }; break
|
|
71
|
+
case 'radial-bar': spec = { ...byRegion, type: 'radial-bar' }; break
|
|
72
|
+
case 'nightingale': spec = { ...byMonth, type: 'nightingale' }; break
|
|
73
|
+
case 'pie': spec = { ...byRegion, type: 'pie', innerRadius: 0.55 }; break
|
|
74
|
+
case 'treemap': spec = { ...byRegionChannel, type: 'treemap', treemap: specToTreemap(byRegionChannel) }; break
|
|
75
|
+
case 'sunburst': spec = { ...byRegionChannel, type: 'sunburst', tree: specToTreemap(byRegionChannel) }; break
|
|
76
|
+
case 'funnel': spec = { type: 'funnel', categories: ['Visits', 'Sign-ups', 'Trials', 'Paid'], series: [{ label: 'Users', values: [12400, 5100, 2300, 880] }] }; break
|
|
77
|
+
case 'waterfall': spec = { type: 'waterfall', categories: ['Start', 'Web', 'Retail', 'Partner', 'Returns', 'End'], series: [{ label: 'P&L', values: [120, 48, 31, 22, -19, 0] }], waterfallTotals: [true, false, false, false, false, true] }; break
|
|
78
|
+
case 'sankey': { const f = specToSankey(byRegionChannel); spec = { ...byRegionChannel, type: 'sankey', sankeyNodes: f.nodes, sankeyLinks: f.links }; break }
|
|
79
|
+
case 'chord': { const f = specToSankey(byRegionChannel); spec = { ...byRegionChannel, type: 'chord', sankeyNodes: f.nodes, sankeyLinks: f.links }; break }
|
|
80
|
+
case 'radar': spec = { ...byRegionChannel, type: 'radar' }; break
|
|
81
|
+
case 'heatmap': spec = { ...byRegionChannel, type: 'heatmap' }; break
|
|
82
|
+
case 'scatter': spec = rowsToScatterSpec(data.slice(0, 80), { x: 'cost', y: 'revenue', series: 'channel', r: 'units' }); break
|
|
83
|
+
case 'boxplot': spec = rowsToBoxSpec(data, { category: 'region', value: 'revenue' }); break
|
|
84
|
+
case 'histogram': spec = rowsToHistogramSpec(data, { value: 'revenue', bins: 10 }); break
|
|
85
|
+
case 'gauge': spec = { type: 'gauge', categories: [], series: [], gaugeValue: 72, gaugeMin: 0, gaugeMax: 100, gaugeTarget: 80, gaugeUnit: '%', gaugeRanges: [{ from: 0, to: 50, color: '#ef4444' }, { from: 50, to: 75, color: '#f59e0b' }, { from: 75, to: 100, color: '#16a34a' }] }; break
|
|
86
|
+
case 'bullet': spec = { type: 'bullet', categories: byRegion.categories, series: [{ label: 'Revenue', values: byRegion.series[0]!.values, targets: byRegion.series[0]!.values.map((v, i) => v * (0.9 + (i % 3) * 0.1)) }] }; break
|
|
87
|
+
case 'calendar': { const daily = rowsToChartSpec(data, { type: 'bar', category: 'day', value: 'units' }); spec = { ...daily, type: 'calendar', calendarValues: specToCalendar(daily) }; break }
|
|
88
|
+
case 'stream': spec = { ...byMonthChannel, type: 'stream', stacked: true, stackOffset: 'wiggle' }; break
|
|
89
|
+
case 'candlestick': spec = { type: 'candlestick', ...ohlc() }; break
|
|
90
|
+
case 'ohlc': spec = { type: 'ohlc', ...ohlc() }; break
|
|
91
|
+
case 'bar':
|
|
92
|
+
default: spec = { ...byRegionChannel, type: 'bar' }
|
|
93
|
+
}
|
|
94
|
+
return palette ? { ...spec, palette } : spec
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A sample spec trimmed for a thumbnail: no titles, no axis labels, small. */
|
|
98
|
+
export function sampleChartThumb(type: ChartType, palette?: string[], width = 150, height = 92): ChartSpec {
|
|
99
|
+
const s = sampleChartSpec(type, palette)
|
|
100
|
+
return {
|
|
101
|
+
...s, width, height, title: undefined, yAxisTitle: undefined, xAxisTitle: undefined, referenceLines: undefined,
|
|
102
|
+
xAxis: { ...(s.xAxis ?? {}), labels: false, title: undefined },
|
|
103
|
+
yAxis: { ...(s.yAxis ?? {}), labels: false, title: undefined },
|
|
104
|
+
y2Axis: { ...(s.y2Axis ?? {}), labels: false, title: undefined },
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scales, ticks, number and date formatting, and the colour helpers the chart
|
|
3
|
+
* engine shares across every chart family. DOM-free.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChartCategoryTick, NiceScale } from './chart-types'
|
|
6
|
+
|
|
7
|
+
// Number formatting lives in its own leaf (see chart-format.ts for why) and
|
|
8
|
+
// is re-exported here so every importer of chart-scale keeps working.
|
|
9
|
+
export { fmtTick, formatChartValue } from './chart-format'
|
|
10
|
+
|
|
11
|
+
/** Series colours used when a {@link ChartSeries} sets none, in order. */
|
|
12
|
+
export const DEFAULT_PALETTE = [
|
|
13
|
+
'#2563eb',
|
|
14
|
+
'#16a34a',
|
|
15
|
+
'#f59e0b',
|
|
16
|
+
'#ef4444',
|
|
17
|
+
'#8b5cf6',
|
|
18
|
+
'#0ea5e9',
|
|
19
|
+
'#ec4899',
|
|
20
|
+
'#14b8a6',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** @internal Two-decimal rounding for SVG coordinates. */
|
|
24
|
+
export function round(n: number): number {
|
|
25
|
+
return Math.round(n * 100) / 100
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @internal Nice-number rounding used by niceScale. */
|
|
29
|
+
export function niceNum(range: number, roundIt: boolean): number {
|
|
30
|
+
if (range <= 0) return 1
|
|
31
|
+
const exp = Math.floor(Math.log10(range))
|
|
32
|
+
const f = range / Math.pow(10, exp)
|
|
33
|
+
let nf: number
|
|
34
|
+
if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
|
|
35
|
+
else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
36
|
+
return nf * Math.pow(10, exp)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
40
|
+
|
|
41
|
+
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
42
|
+
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
|
|
43
|
+
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
44
|
+
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
|
|
45
|
+
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
46
|
+
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
47
|
+
* as "cold" rather than as glaring white rectangles. */
|
|
48
|
+
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
|
|
49
|
+
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
|
|
50
|
+
|
|
51
|
+
/** @internal Pick the colour-ramp stops for a heatmap / calendar. */
|
|
52
|
+
export function resolveColorScale(
|
|
53
|
+
scale: 'sequential' | 'diverging' | string[] | undefined,
|
|
54
|
+
vMin: number,
|
|
55
|
+
vMax: number,
|
|
56
|
+
theme: 'light' | 'dark' = 'light',
|
|
57
|
+
): string[] {
|
|
58
|
+
if (Array.isArray(scale) && scale.length >= 2) return scale
|
|
59
|
+
const dark = theme === 'dark'
|
|
60
|
+
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
61
|
+
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
|
|
62
|
+
}
|
|
63
|
+
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
67
|
+
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
68
|
+
export function sampleGradient(stops: string[], t: number): string {
|
|
69
|
+
if (!stops.length) return '#888'
|
|
70
|
+
const clamped = Math.max(0, Math.min(1, t))
|
|
71
|
+
if (stops.length === 1) return stops[0]!
|
|
72
|
+
const pos = clamped * (stops.length - 1)
|
|
73
|
+
const i = Math.floor(pos)
|
|
74
|
+
const frac = pos - i
|
|
75
|
+
const a = hexToRgb(stops[i]!)
|
|
76
|
+
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
|
|
77
|
+
if (!a || !b) return stops[i] ?? '#888'
|
|
78
|
+
const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
|
|
79
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
|
80
|
+
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
84
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
|
85
|
+
if (!m) return null
|
|
86
|
+
const n = parseInt(m[1]!, 16)
|
|
87
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Pick a black or white text color that has the better contrast against
|
|
91
|
+
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
92
|
+
export function pickContrastText(bgHex: string): string {
|
|
93
|
+
const rgb = hexToRgb(bgHex)
|
|
94
|
+
if (!rgb) return '#0f172a'
|
|
95
|
+
const lin = (c: number) => {
|
|
96
|
+
const s = c / 255
|
|
97
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
98
|
+
}
|
|
99
|
+
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
|
|
100
|
+
return L > 0.5 ? '#0f172a' : '#ffffff'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
104
|
+
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
105
|
+
* by log-scale axes (yScale: 'log'). */
|
|
106
|
+
export function niceLogScale(min: number, max: number): NiceScale {
|
|
107
|
+
// Only positive values are plottable on a log scale; callers should
|
|
108
|
+
// strip non-positive values before passing them in.
|
|
109
|
+
if (!Number.isFinite(min) || min <= 0) min = 1
|
|
110
|
+
if (!Number.isFinite(max) || max <= min) max = min * 10
|
|
111
|
+
const lo = Math.floor(Math.log10(min))
|
|
112
|
+
const hi = Math.ceil(Math.log10(max))
|
|
113
|
+
const ticks: number[] = []
|
|
114
|
+
for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
|
|
115
|
+
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @internal Tick values for a logarithmic axis between two positive bounds:
|
|
120
|
+
* every decade, plus the 2 and 5 of each decade when the range spans fewer
|
|
121
|
+
* than three, so a two-decade axis does not read as three lonely labels.
|
|
122
|
+
*/
|
|
123
|
+
export function logTicks(min: number, max: number): number[] {
|
|
124
|
+
if (!(min > 0) || !(max > min)) return []
|
|
125
|
+
const lo = Math.floor(Math.log10(min))
|
|
126
|
+
const hi = Math.ceil(Math.log10(max))
|
|
127
|
+
const minors = hi - lo < 3 ? [2, 5] : []
|
|
128
|
+
const out: number[] = []
|
|
129
|
+
for (let p = lo; p <= hi; p += 1) {
|
|
130
|
+
const d = Math.pow(10, p)
|
|
131
|
+
for (const m of [1, ...minors]) {
|
|
132
|
+
const v = d * m
|
|
133
|
+
if (v >= min * 0.999 && v <= max * 1.001) out.push(v)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal Map a value to a fractional position [0..1] across the axis domain.
|
|
140
|
+
* Pass the appropriate fn into projection code so linear / log share the
|
|
141
|
+
* same plumbing. Returns null for non-positive values on log. */
|
|
142
|
+
export function project(value: number, min: number, max: number, isLog: boolean): number | null {
|
|
143
|
+
if (!Number.isFinite(value)) return null
|
|
144
|
+
if (isLog) {
|
|
145
|
+
if (value <= 0 || min <= 0) return null
|
|
146
|
+
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
|
|
147
|
+
}
|
|
148
|
+
return (value - min) / (max - min)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
152
|
+
export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
|
|
153
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
154
|
+
min = 0
|
|
155
|
+
max = 1
|
|
156
|
+
}
|
|
157
|
+
if (min === max) {
|
|
158
|
+
if (min === 0) max = 1
|
|
159
|
+
else {
|
|
160
|
+
min = Math.min(0, min)
|
|
161
|
+
max = Math.max(0, max)
|
|
162
|
+
}
|
|
163
|
+
if (min === max) max = min + 1
|
|
164
|
+
}
|
|
165
|
+
const range = niceNum(max - min, false)
|
|
166
|
+
const step = niceNum(range / Math.max(1, tickCount), true)
|
|
167
|
+
const nMin = Math.floor(min / step) * step
|
|
168
|
+
const nMax = Math.ceil(max / step) * step
|
|
169
|
+
const ticks: number[] = []
|
|
170
|
+
for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
|
|
171
|
+
return { min: nMin, max: nMax, step, ticks }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A value-axis domain that honours an axis config on top of the data extent:
|
|
176
|
+
* a pinned `min` / `max` (hard edges - marks past them are clipped), an exact
|
|
177
|
+
* `tickInterval`, a `tickCount`, and `nice: false` for the raw extent with
|
|
178
|
+
* evenly spaced ticks. With no config this is exactly {@link niceScale}.
|
|
179
|
+
*/
|
|
180
|
+
export function axisScale(
|
|
181
|
+
dataMin: number,
|
|
182
|
+
dataMax: number,
|
|
183
|
+
axis: { min?: number; max?: number; nice?: boolean; tickCount?: number; tickInterval?: number } = {},
|
|
184
|
+
): NiceScale {
|
|
185
|
+
let min = axis.min ?? dataMin
|
|
186
|
+
let max = axis.max ?? dataMax
|
|
187
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
188
|
+
min = Number.isFinite(min) ? min : 0
|
|
189
|
+
max = Number.isFinite(max) ? max : min + 1
|
|
190
|
+
}
|
|
191
|
+
const nice = axis.nice !== false
|
|
192
|
+
const interval = axis.tickInterval
|
|
193
|
+
if (interval && interval > 0 && Number.isFinite(interval)) {
|
|
194
|
+
// An explicit interval is the whole axis: the ends land on multiples of it
|
|
195
|
+
// (unless pinned) and every tick is one interval apart.
|
|
196
|
+
if (nice && axis.min == null) min = Math.floor(min / interval) * interval
|
|
197
|
+
if (nice && axis.max == null) max = Math.ceil(max / interval) * interval
|
|
198
|
+
if (max <= min) max = min + interval
|
|
199
|
+
const ticks: number[] = []
|
|
200
|
+
const first = Math.ceil(min / interval - 1e-9) * interval
|
|
201
|
+
// Cap the count so a tiny interval on a huge domain cannot hang the layout.
|
|
202
|
+
// `+ 0` turns the -0 that `Math.ceil` hands back at the origin into a plain 0.
|
|
203
|
+
for (let v = first, n = 0; v <= max + interval * 1e-6 && n < 1000; v += interval, n += 1) ticks.push(round(v) + 0)
|
|
204
|
+
return { min, max, step: interval, ticks }
|
|
205
|
+
}
|
|
206
|
+
if (nice) {
|
|
207
|
+
const ns = niceScale(min, max, axis.tickCount ?? 4)
|
|
208
|
+
if (axis.min == null && axis.max == null) return ns
|
|
209
|
+
// A pinned end wins over the rounding, and ticks past it go.
|
|
210
|
+
const lo = axis.min ?? ns.min
|
|
211
|
+
const hi = axis.max ?? ns.max
|
|
212
|
+
return { min: lo, max: hi, step: ns.step, ticks: ns.ticks.filter((t) => t >= lo - 1e-9 && t <= hi + 1e-9) }
|
|
213
|
+
}
|
|
214
|
+
if (max <= min) max = min + 1
|
|
215
|
+
const n = Math.max(1, Math.round(axis.tickCount ?? 4))
|
|
216
|
+
const step = (max - min) / n
|
|
217
|
+
const ticks = Array.from({ length: n + 1 }, (_, i) => round(min + step * i))
|
|
218
|
+
return { min, max, step, ticks }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @internal One day in milliseconds. */
|
|
222
|
+
export const DAY = 86_400_000
|
|
223
|
+
/**
|
|
224
|
+
* @internal Calendar-aligned tick timestamps across [min, max], at most about
|
|
225
|
+
* `target` of them.
|
|
226
|
+
*
|
|
227
|
+
* The unit is the finest of day, week (Mondays), month, quarter, year and
|
|
228
|
+
* multiples of years whose count fits the target, and every tick sits on a
|
|
229
|
+
* calendar boundary in UTC (the axis parses 'YYYY-MM-DD' categories as UTC
|
|
230
|
+
* midnight). The old version stepped a fixed 30 or 365 days from the epoch,
|
|
231
|
+
* so a tick labelled "2025" stood weeks away from New Year and a thirty-month
|
|
232
|
+
* axis carried two labels on a plot with room for eight.
|
|
233
|
+
*/
|
|
234
|
+
export function dateTicks(tMin: number, tMax: number, target = 6): number[] {
|
|
235
|
+
if (!(tMax > tMin)) return [tMin]
|
|
236
|
+
// A little over the target beats dropping to the next unit, which halves
|
|
237
|
+
// or thirds the count: ten quarters on a plot with room for nine.
|
|
238
|
+
const want = Math.max(2, target) * 1.3
|
|
239
|
+
const span = tMax - tMin
|
|
240
|
+
// Steps as [days, months]: a year is twelve months, so multiples of twelve
|
|
241
|
+
// land on the first of January.
|
|
242
|
+
const steps: Array<[number, number]> = [[1, 0], [2, 0], [7, 0], [14, 0], [0, 1], [0, 3], [0, 6], [0, 12], [0, 24], [0, 60], [0, 120]]
|
|
243
|
+
let [d, m] = steps[steps.length - 1]!
|
|
244
|
+
for (const [sd, sm] of steps) {
|
|
245
|
+
if (span / (sd * DAY + sm * 30.44 * DAY) <= want) { d = sd; m = sm; break }
|
|
246
|
+
}
|
|
247
|
+
const from = new Date(tMin)
|
|
248
|
+
const out: number[] = []
|
|
249
|
+
if (d) {
|
|
250
|
+
let t = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate())
|
|
251
|
+
// Weekly steps sit on Mondays; UTC day 0 is Sunday.
|
|
252
|
+
if (d >= 7) t += ((8 - new Date(t).getUTCDay()) % 7) * DAY
|
|
253
|
+
while (t < tMin) t += d * DAY
|
|
254
|
+
for (; t <= tMax + 1; t += d * DAY) out.push(t)
|
|
255
|
+
} else {
|
|
256
|
+
let months = Math.ceil((from.getUTCFullYear() * 12 + from.getUTCMonth()) / m) * m
|
|
257
|
+
const at = (mo: number) => Date.UTC(Math.floor(mo / 12), mo % 12, 1)
|
|
258
|
+
if (at(months) < tMin) months += m
|
|
259
|
+
for (let t = at(months); t <= tMax + 1; months += m, t = at(months)) out.push(t)
|
|
260
|
+
}
|
|
261
|
+
return out.length < 2 ? [tMin, tMax] : out
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
|
|
265
|
+
* `times`.
|
|
266
|
+
*
|
|
267
|
+
* A time axis can put a tick anywhere, because x is a function of the
|
|
268
|
+
* timestamp. An ordinal axis cannot: x is a function of the index, so a tick
|
|
269
|
+
* has to land on a point that exists. This picks the first point of each
|
|
270
|
+
* calendar unit - day, week, month, year, whichever gets closest to `target`
|
|
271
|
+
* ticks without going over - so labels sit on real sessions and a weekend or a
|
|
272
|
+
* holiday never stretches the spacing.
|
|
273
|
+
*/
|
|
274
|
+
export function ordinalDateTicks(times: number[], target = 6): number[] {
|
|
275
|
+
if (times.length <= 1) return times.length ? [0] : []
|
|
276
|
+
const keyOf: Record<string, (d: Date) => number | string> = {
|
|
277
|
+
day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
|
|
278
|
+
// Weeks start on Monday: day 0 of the epoch was a Thursday, so +3 puts
|
|
279
|
+
// the boundary there (+4 put it on Sunday, and weekly ticks sat on Sundays).
|
|
280
|
+
week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 3) / 7),
|
|
281
|
+
month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
|
|
282
|
+
year: (d) => d.getUTCFullYear(),
|
|
283
|
+
}
|
|
284
|
+
let chosen: number[] | null = null
|
|
285
|
+
for (const unit of ['day', 'week', 'month', 'year'] as const) {
|
|
286
|
+
const at: number[] = []
|
|
287
|
+
let prev: number | string | null = null
|
|
288
|
+
for (let i = 0; i < times.length; i += 1) {
|
|
289
|
+
const t = times[i]!
|
|
290
|
+
if (!Number.isFinite(t)) continue
|
|
291
|
+
const k = keyOf[unit]!(new Date(t))
|
|
292
|
+
if (k !== prev) at.push(i)
|
|
293
|
+
prev = k
|
|
294
|
+
}
|
|
295
|
+
chosen = at
|
|
296
|
+
if (at.length <= target * 2) break
|
|
297
|
+
}
|
|
298
|
+
let out = chosen ?? []
|
|
299
|
+
// Even the coarsest unit can be too dense (a decade of yearly points), and a
|
|
300
|
+
// single trading day yields one boundary. Thin, or fall back to plain strides.
|
|
301
|
+
if (out.length > target) {
|
|
302
|
+
const stride = Math.ceil(out.length / target)
|
|
303
|
+
out = out.filter((_, i) => i % stride === 0)
|
|
304
|
+
}
|
|
305
|
+
if (out.length < 2) {
|
|
306
|
+
const stride = Math.max(1, Math.ceil(times.length / target))
|
|
307
|
+
out = times.map((_, i) => i).filter((i) => i % stride === 0)
|
|
308
|
+
}
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @internal Date label sized to the gap between ticks: days and weeks read
|
|
313
|
+
* "Jan 5", months and quarters "Jun 25", years "2025". Read in UTC: the axis
|
|
314
|
+
* parses 'YYYY-MM-DD' as UTC midnight, and a local-time read of that in the
|
|
315
|
+
* Americas labelled every tick with the day before. Sized to the tick step
|
|
316
|
+
* rather than the axis span, so a thirty-month axis on quarters does not
|
|
317
|
+
* read "2024, 2024, 2024, 2025". */
|
|
318
|
+
export function fmtDate(t: number, step: number): string {
|
|
319
|
+
const d = new Date(t)
|
|
320
|
+
if (step < 28 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
321
|
+
if (step < 360 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit', timeZone: 'UTC' })
|
|
322
|
+
return String(d.getUTCFullYear())
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @internal A category on a date axis, written out for a tooltip or a
|
|
327
|
+
* crosshair pill: "Jun 2025" when every category is the first of a month,
|
|
328
|
+
* "2025" when every one is New Year, otherwise "Jun 1, 2025". `grain` is
|
|
329
|
+
* what {@link dateGrain} found for the axis.
|
|
330
|
+
*/
|
|
331
|
+
export function fmtDateFull(t: number, grain: 'day' | 'month' | 'year' = 'day'): string {
|
|
332
|
+
const d = new Date(t)
|
|
333
|
+
if (grain === 'year') return String(d.getUTCFullYear())
|
|
334
|
+
if (grain === 'month') return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' })
|
|
335
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @internal The calendar grain a list of UTC timestamps sits on. */
|
|
339
|
+
export function dateGrain(times: number[]): 'day' | 'month' | 'year' {
|
|
340
|
+
let month = true, year = true, any = false
|
|
341
|
+
for (const t of times) {
|
|
342
|
+
if (!Number.isFinite(t)) continue
|
|
343
|
+
any = true
|
|
344
|
+
const d = new Date(t)
|
|
345
|
+
if (d.getUTCDate() !== 1 || d.getUTCHours() !== 0) { month = false; year = false; break }
|
|
346
|
+
if (d.getUTCMonth() !== 0) year = false
|
|
347
|
+
}
|
|
348
|
+
return !any ? 'day' : year ? 'year' : month ? 'month' : 'day'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @internal Category-axis labels, thinned so they do not overlap.
|
|
353
|
+
*
|
|
354
|
+
* A category axis used to emit one tick per category however many there were.
|
|
355
|
+
* At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
|
|
356
|
+
* grey band - the labels were the single biggest thing the renderer had to put
|
|
357
|
+
* in the DOM, and none of them could be read. The time and ordinal-time axes
|
|
358
|
+
* already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
|
|
359
|
+
* the plain category axis in line.
|
|
360
|
+
*
|
|
361
|
+
* The first category always gets a label, so a thinned axis still starts where
|
|
362
|
+
* the data does.
|
|
363
|
+
*/
|
|
364
|
+
export function thinCategoryTicks(
|
|
365
|
+
categories: string[],
|
|
366
|
+
xCenter: (i: number) => number,
|
|
367
|
+
slot: number,
|
|
368
|
+
rotated: boolean,
|
|
369
|
+
): ChartCategoryTick[] {
|
|
370
|
+
// Rotated labels run diagonally and pack far tighter than upright ones. 18px
|
|
371
|
+
// is deliberately just under the spacing a 40-category chart at 800px already
|
|
372
|
+
// had, so charts that read fine before are untouched and only genuinely
|
|
373
|
+
// overlapping axes get thinned.
|
|
374
|
+
const minPx = rotated ? 18 : 60
|
|
375
|
+
const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)))
|
|
376
|
+
const out: ChartCategoryTick[] = []
|
|
377
|
+
for (let i = 0; i < categories.length; i += step) out.push({ label: categories[i]!, x: xCenter(i) })
|
|
378
|
+
return out
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @internal Normalize one `errors` entry to an absolute low/high pair around `value`. */
|
|
382
|
+
export function errorSpan(
|
|
383
|
+
e: number | { lo: number; hi: number } | null | undefined,
|
|
384
|
+
value: number,
|
|
385
|
+
): { lo: number; hi: number } | null {
|
|
386
|
+
if (e == null) return null
|
|
387
|
+
if (typeof e === 'number') {
|
|
388
|
+
if (!Number.isFinite(e)) return null
|
|
389
|
+
const m = Math.abs(e)
|
|
390
|
+
return { lo: value - m, hi: value + m }
|
|
391
|
+
}
|
|
392
|
+
if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi)) return null
|
|
393
|
+
return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) }
|
|
394
|
+
}
|