@svgrid/grid 1.0.0
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/LICENSE +33 -0
- package/README.md +39 -0
- package/dist/FlexRender.svelte +96 -0
- package/dist/FlexRender.svelte.d.ts +49 -0
- package/dist/SvGrid.svelte +8742 -0
- package/dist/SvGrid.svelte.d.ts +381 -0
- package/dist/SvGridChart.svelte +653 -0
- package/dist/SvGridChart.svelte.d.ts +25 -0
- package/dist/SvGridChart.test.d.ts +1 -0
- package/dist/SvGridChart.test.js +62 -0
- package/dist/SvGridDropdown.svelte +675 -0
- package/dist/SvGridDropdown.svelte.d.ts +40 -0
- package/dist/a11y.contract.test.d.ts +1 -0
- package/dist/a11y.contract.test.js +39 -0
- package/dist/a11y.d.ts +38 -0
- package/dist/a11y.js +37 -0
- package/dist/a11y.test.d.ts +1 -0
- package/dist/a11y.test.js +44 -0
- package/dist/cell-formatting.d.ts +17 -0
- package/dist/cell-formatting.js +141 -0
- package/dist/cell-formatting.test.d.ts +1 -0
- package/dist/cell-formatting.test.js +234 -0
- package/dist/chart-export.d.ts +14 -0
- package/dist/chart-export.js +112 -0
- package/dist/chart.d.ts +226 -0
- package/dist/chart.js +732 -0
- package/dist/chart.test.d.ts +1 -0
- package/dist/chart.test.js +289 -0
- package/dist/collaboration.d.ts +74 -0
- package/dist/collaboration.js +98 -0
- package/dist/collaboration.test.d.ts +1 -0
- package/dist/collaboration.test.js +65 -0
- package/dist/conditional-formatting.d.ts +91 -0
- package/dist/conditional-formatting.js +170 -0
- package/dist/conditional-formatting.test.d.ts +1 -0
- package/dist/conditional-formatting.test.js +87 -0
- package/dist/core.coverage.test.d.ts +1 -0
- package/dist/core.coverage.test.js +186 -0
- package/dist/core.d.ts +370 -0
- package/dist/core.js +568 -0
- package/dist/core.performance.test.d.ts +1 -0
- package/dist/core.performance.test.js +29 -0
- package/dist/createGrid.svelte.d.ts +6 -0
- package/dist/createGrid.svelte.js +17 -0
- package/dist/createGrid.test.d.ts +1 -0
- package/dist/createGrid.test.js +9 -0
- package/dist/createGridState.svelte.d.ts +3 -0
- package/dist/createGridState.svelte.js +13 -0
- package/dist/editors/cell-editors.d.ts +23 -0
- package/dist/editors/cell-editors.js +97 -0
- package/dist/editors/cell-editors.test.d.ts +1 -0
- package/dist/editors/cell-editors.test.js +75 -0
- package/dist/fill-patterns.d.ts +30 -0
- package/dist/fill-patterns.js +207 -0
- package/dist/fill-patterns.test.d.ts +1 -0
- package/dist/fill-patterns.test.js +81 -0
- package/dist/filtering/excel-filters.d.ts +26 -0
- package/dist/filtering/excel-filters.js +64 -0
- package/dist/filtering/excel-filters.test.d.ts +1 -0
- package/dist/filtering/excel-filters.test.js +116 -0
- package/dist/filtering/locale-filter.test.d.ts +13 -0
- package/dist/filtering/locale-filter.test.js +189 -0
- package/dist/flex-render.d.ts +1 -0
- package/dist/flex-render.js +2 -0
- package/dist/flex-render.test.d.ts +1 -0
- package/dist/flex-render.test.js +142 -0
- package/dist/group-aggregate.test.d.ts +1 -0
- package/dist/group-aggregate.test.js +32 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +27 -0
- package/dist/keyboard.d.ts +8 -0
- package/dist/keyboard.js +91 -0
- package/dist/keyboard.test.d.ts +1 -0
- package/dist/keyboard.test.js +27 -0
- package/dist/merge-objects.d.ts +9 -0
- package/dist/merge-objects.js +28 -0
- package/dist/named-views.d.ts +42 -0
- package/dist/named-views.js +80 -0
- package/dist/named-views.test.d.ts +1 -0
- package/dist/named-views.test.js +57 -0
- package/dist/new-features.test.d.ts +1 -0
- package/dist/new-features.test.js +183 -0
- package/dist/render-component.d.ts +13 -0
- package/dist/render-component.js +14 -0
- package/dist/render-component.test.d.ts +1 -0
- package/dist/render-component.test.js +41 -0
- package/dist/server-data-source.d.ts +76 -0
- package/dist/server-data-source.js +83 -0
- package/dist/server-data-source.test.d.ts +1 -0
- package/dist/server-data-source.test.js +81 -0
- package/dist/sparkline.d.ts +56 -0
- package/dist/sparkline.js +97 -0
- package/dist/sparkline.test.d.ts +1 -0
- package/dist/sparkline.test.js +52 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +1 -0
- package/dist/subscribe.d.ts +9 -0
- package/dist/subscribe.js +31 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/dist/subscribe.test.js +93 -0
- package/dist/sv-grid-scrollbar.d.ts +1 -0
- package/dist/sv-grid-scrollbar.js +310 -0
- package/dist/svgrid-wrapper.types.d.ts +341 -0
- package/dist/svgrid-wrapper.types.js +1 -0
- package/dist/svgrid.api-extensions.test.d.ts +14 -0
- package/dist/svgrid.api-extensions.test.js +334 -0
- package/dist/svgrid.api.test.d.ts +15 -0
- package/dist/svgrid.api.test.js +169 -0
- package/dist/svgrid.behavior.test.d.ts +15 -0
- package/dist/svgrid.behavior.test.js +605 -0
- package/dist/svgrid.column-reorder.test.d.ts +15 -0
- package/dist/svgrid.column-reorder.test.js +224 -0
- package/dist/svgrid.features.test.d.ts +1 -0
- package/dist/svgrid.features.test.js +129 -0
- package/dist/svgrid.interaction.test.d.ts +9 -0
- package/dist/svgrid.interaction.test.js +318 -0
- package/dist/svgrid.locale-filtering.test.d.ts +15 -0
- package/dist/svgrid.locale-filtering.test.js +261 -0
- package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
- package/dist/svgrid.new-features.wrapper.test.js +136 -0
- package/dist/svgrid.row-pinning.test.d.ts +14 -0
- package/dist/svgrid.row-pinning.test.js +250 -0
- package/dist/svgrid.set-filter.test.d.ts +17 -0
- package/dist/svgrid.set-filter.test.js +268 -0
- package/dist/svgrid.wrapper.test.d.ts +1 -0
- package/dist/svgrid.wrapper.test.js +33 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/transaction.test.d.ts +1 -0
- package/dist/transaction.test.js +89 -0
- package/dist/virtualization/column-virtualizer.d.ts +20 -0
- package/dist/virtualization/column-virtualizer.js +19 -0
- package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
- package/dist/virtualization/column-virtualizer.test.js +25 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
- package/dist/virtualization/types.d.ts +28 -0
- package/dist/virtualization/types.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +13 -0
- package/dist/virtualization/virtualizer.js +232 -0
- package/dist/virtualization/virtualizer.test.d.ts +1 -0
- package/dist/virtualization/virtualizer.test.js +41 -0
- package/package.json +69 -0
- package/src/FlexRender.svelte +96 -0
- package/src/SvGrid.svelte +8742 -0
- package/src/SvGridChart.svelte +653 -0
- package/src/SvGridChart.test.ts +69 -0
- package/src/SvGridDropdown.svelte +675 -0
- package/src/a11y.contract.test.ts +49 -0
- package/src/a11y.test.ts +59 -0
- package/src/a11y.ts +59 -0
- package/src/cell-formatting.test.ts +273 -0
- package/src/cell-formatting.ts +169 -0
- package/src/chart-export.ts +144 -0
- package/src/chart.test.ts +311 -0
- package/src/chart.ts +934 -0
- package/src/collaboration.test.ts +74 -0
- package/src/collaboration.ts +161 -0
- package/src/conditional-formatting.test.ts +107 -0
- package/src/conditional-formatting.ts +239 -0
- package/src/core.coverage.test.ts +230 -0
- package/src/core.performance.test.ts +30 -0
- package/src/core.ts +991 -0
- package/src/createGrid.svelte.ts +42 -0
- package/src/createGrid.test.ts +10 -0
- package/src/createGridState.svelte.ts +17 -0
- package/src/editors/cell-editors.test.ts +80 -0
- package/src/editors/cell-editors.ts +127 -0
- package/src/fill-patterns.test.ts +93 -0
- package/src/fill-patterns.ts +229 -0
- package/src/filtering/excel-filters.test.ts +131 -0
- package/src/filtering/excel-filters.ts +101 -0
- package/src/filtering/locale-filter.test.ts +220 -0
- package/src/flex-render.test.ts +143 -0
- package/src/flex-render.ts +3 -0
- package/src/group-aggregate.test.ts +48 -0
- package/src/index.ts +159 -0
- package/src/keyboard.test.ts +59 -0
- package/src/keyboard.ts +97 -0
- package/src/merge-objects.ts +48 -0
- package/src/named-views.test.ts +66 -0
- package/src/named-views.ts +120 -0
- package/src/new-features.test.ts +217 -0
- package/src/render-component.test.ts +51 -0
- package/src/render-component.ts +28 -0
- package/src/server-data-source.test.ts +88 -0
- package/src/server-data-source.ts +163 -0
- package/src/sparkline.test.ts +59 -0
- package/src/sparkline.ts +164 -0
- package/src/static-functions.ts +11 -0
- package/src/subscribe.test.ts +103 -0
- package/src/subscribe.ts +38 -0
- package/src/sv-grid-scrollbar.ts +347 -0
- package/src/svgrid-wrapper.types.ts +382 -0
- package/src/svgrid.api-extensions.test.ts +362 -0
- package/src/svgrid.api.test.ts +192 -0
- package/src/svgrid.behavior.test.ts +657 -0
- package/src/svgrid.column-reorder.test.ts +234 -0
- package/src/svgrid.features.test.ts +157 -0
- package/src/svgrid.interaction.test.ts +355 -0
- package/src/svgrid.locale-filtering.test.ts +259 -0
- package/src/svgrid.new-features.wrapper.test.ts +164 -0
- package/src/svgrid.row-pinning.test.ts +266 -0
- package/src/svgrid.set-filter.test.ts +270 -0
- package/src/svgrid.wrapper.test.ts +35 -0
- package/src/test-setup.ts +37 -0
- package/src/transaction.test.ts +100 -0
- package/src/virtualization/column-virtualizer.test.ts +27 -0
- package/src/virtualization/column-virtualizer.ts +30 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
- package/src/virtualization/types.ts +30 -0
- package/src/virtualization/virtualizer.test.ts +47 -0
- package/src/virtualization/virtualizer.ts +270 -0
package/src/chart.ts
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrated chart geometry. Pure functions that turn categories + numeric
|
|
3
|
+
* series into SVG primitives - the "chart from a grid range" enterprise
|
|
4
|
+
* feature without bundling a charting library. The `<SvGridChart>` component
|
|
5
|
+
* paints the result; this module has no DOM so it is unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* Supports: grouped + stacked bars, line, area, pie/donut, combo charts
|
|
8
|
+
* (per-series type), a secondary (right) Y axis, signed Y domains (negative
|
|
9
|
+
* values drop below a zero baseline), and nice auto-scaled ticks.
|
|
10
|
+
*/
|
|
11
|
+
export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'scatter'
|
|
12
|
+
|
|
13
|
+
/** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`. */
|
|
14
|
+
export type ChartSelection = { category: string; series: string; value: number }
|
|
15
|
+
|
|
16
|
+
/** A single scatter / bubble point. */
|
|
17
|
+
export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
|
|
18
|
+
|
|
19
|
+
export type ChartSeries = {
|
|
20
|
+
label: string
|
|
21
|
+
values: number[]
|
|
22
|
+
color?: string
|
|
23
|
+
/** Per-series chart type, for combo charts. Defaults to the spec `type`. */
|
|
24
|
+
type?: 'bar' | 'line' | 'area'
|
|
25
|
+
/** Plot against the left (default) or right Y axis. */
|
|
26
|
+
axis?: 'left' | 'right'
|
|
27
|
+
/** Scatter / bubble points (used when `type === 'scatter'`). */
|
|
28
|
+
points?: ScatterPoint[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A horizontal reference / target line drawn across the plot. */
|
|
32
|
+
export type ChartReferenceLine = {
|
|
33
|
+
value: number
|
|
34
|
+
label?: string
|
|
35
|
+
axis?: 'left' | 'right'
|
|
36
|
+
color?: string
|
|
37
|
+
dashed?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ChartSpec = {
|
|
41
|
+
/** Default type for series that don't set their own `type`. */
|
|
42
|
+
type: ChartType
|
|
43
|
+
/** X-axis labels (one per data point). */
|
|
44
|
+
categories: string[]
|
|
45
|
+
series: ChartSeries[]
|
|
46
|
+
width?: number
|
|
47
|
+
height?: number
|
|
48
|
+
/** Palette used when a series has no explicit `color`. */
|
|
49
|
+
palette?: string[]
|
|
50
|
+
/** Stack bar / area series (per axis) instead of grouping them. */
|
|
51
|
+
stacked?: boolean
|
|
52
|
+
/** Stack to 100% (each category normalized to its total). Implies stacked. */
|
|
53
|
+
stacked100?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Bar orientation. `'horizontal'` swaps the axes - categories run down the
|
|
56
|
+
* left, bars grow rightward - which suits long category labels. Only applies
|
|
57
|
+
* when every series is a bar (combo / line / area fall back to vertical).
|
|
58
|
+
*/
|
|
59
|
+
orientation?: 'vertical' | 'horizontal'
|
|
60
|
+
/** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
|
|
61
|
+
innerRadius?: number
|
|
62
|
+
/** Horizontal target / goal / average lines. */
|
|
63
|
+
referenceLines?: ChartReferenceLine[]
|
|
64
|
+
/** Treat `categories` as dates -> time-scaled x positions + date ticks. */
|
|
65
|
+
xType?: 'category' | 'time'
|
|
66
|
+
/** Axis titles (reserve gutter space + render). */
|
|
67
|
+
yAxisTitle?: string
|
|
68
|
+
y2AxisTitle?: string
|
|
69
|
+
xAxisTitle?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ChartBar = {
|
|
73
|
+
x: number
|
|
74
|
+
y: number
|
|
75
|
+
w: number
|
|
76
|
+
h: number
|
|
77
|
+
color: string
|
|
78
|
+
/** Category (x label) this bar belongs to - for tooltips + labels. */
|
|
79
|
+
label: string
|
|
80
|
+
/** Series label this bar belongs to. */
|
|
81
|
+
series: string
|
|
82
|
+
value: number
|
|
83
|
+
}
|
|
84
|
+
export type ChartLinePoint = {
|
|
85
|
+
x: number
|
|
86
|
+
y: number
|
|
87
|
+
label: string
|
|
88
|
+
value: number
|
|
89
|
+
/** False for null / NaN values - the line breaks (gap), no dot is drawn. */
|
|
90
|
+
defined: boolean
|
|
91
|
+
}
|
|
92
|
+
export type ChartLine = {
|
|
93
|
+
path: string
|
|
94
|
+
areaPath: string
|
|
95
|
+
color: string
|
|
96
|
+
label: string
|
|
97
|
+
points: ChartLinePoint[]
|
|
98
|
+
}
|
|
99
|
+
export type ChartPieSlice = {
|
|
100
|
+
path: string
|
|
101
|
+
color: string
|
|
102
|
+
label: string
|
|
103
|
+
value: number
|
|
104
|
+
percent: number
|
|
105
|
+
/** Centroid - anchor point for a data label. */
|
|
106
|
+
cx: number
|
|
107
|
+
cy: number
|
|
108
|
+
}
|
|
109
|
+
export type ChartAxisTick = { value: number; y: number; label: string }
|
|
110
|
+
export type ChartCategoryTick = { label: string; x: number }
|
|
111
|
+
export type ChartLegendItem = { label: string; color: string }
|
|
112
|
+
export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
|
|
113
|
+
/** A vertical reference line (horizontal bar charts) positioned by `x`. */
|
|
114
|
+
export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
|
|
115
|
+
export type ChartScatterDot = {
|
|
116
|
+
cx: number
|
|
117
|
+
cy: number
|
|
118
|
+
r: number
|
|
119
|
+
color: string
|
|
120
|
+
label: string
|
|
121
|
+
series: string
|
|
122
|
+
x: number
|
|
123
|
+
y: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type ChartGeometry = {
|
|
127
|
+
type: ChartType
|
|
128
|
+
width: number
|
|
129
|
+
height: number
|
|
130
|
+
plot: { x: number; y: number; w: number; h: number }
|
|
131
|
+
bars: ChartBar[]
|
|
132
|
+
lines: ChartLine[]
|
|
133
|
+
slices: ChartPieSlice[]
|
|
134
|
+
yTicks: ChartAxisTick[]
|
|
135
|
+
/** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
|
|
136
|
+
y2Ticks: ChartAxisTick[]
|
|
137
|
+
hasRightAxis: boolean
|
|
138
|
+
xTicks: ChartCategoryTick[]
|
|
139
|
+
/** True when x labels are long/many and should be rotated. */
|
|
140
|
+
xLabelRotated: boolean
|
|
141
|
+
legend: ChartLegendItem[]
|
|
142
|
+
/** Donut centre (pie + innerRadius), for a centre total label. */
|
|
143
|
+
donut: { cx: number; cy: number; r: number; total: number } | null
|
|
144
|
+
/** Horizontal reference / target lines. */
|
|
145
|
+
referenceLines: ChartRefLineGeo[]
|
|
146
|
+
/** Scatter / bubble points (type === 'scatter'). */
|
|
147
|
+
scatterPoints: ChartScatterDot[]
|
|
148
|
+
/** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
|
|
149
|
+
orientation: 'vertical' | 'horizontal'
|
|
150
|
+
/** Horizontal bars: value-axis ticks along the bottom (label + x). */
|
|
151
|
+
valueTicks: ChartCategoryTick[]
|
|
152
|
+
/** Horizontal bars: category labels down the left (label + y; value = index). */
|
|
153
|
+
catTicks: ChartAxisTick[]
|
|
154
|
+
/** Horizontal bars: vertical reference / target lines (positioned by x). */
|
|
155
|
+
referenceLinesV: ChartRefLineGeoV[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const DEFAULT_PALETTE = [
|
|
159
|
+
'#2563eb',
|
|
160
|
+
'#16a34a',
|
|
161
|
+
'#f59e0b',
|
|
162
|
+
'#ef4444',
|
|
163
|
+
'#8b5cf6',
|
|
164
|
+
'#0ea5e9',
|
|
165
|
+
'#ec4899',
|
|
166
|
+
'#14b8a6',
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
function round(n: number): number {
|
|
170
|
+
return Math.round(n * 100) / 100
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function niceNum(range: number, roundIt: boolean): number {
|
|
174
|
+
if (range <= 0) return 1
|
|
175
|
+
const exp = Math.floor(Math.log10(range))
|
|
176
|
+
const f = range / Math.pow(10, exp)
|
|
177
|
+
let nf: number
|
|
178
|
+
if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
|
|
179
|
+
else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
180
|
+
return nf * Math.pow(10, exp)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
|
|
184
|
+
|
|
185
|
+
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
186
|
+
export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
|
|
187
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
188
|
+
min = 0
|
|
189
|
+
max = 1
|
|
190
|
+
}
|
|
191
|
+
if (min === max) {
|
|
192
|
+
if (min === 0) max = 1
|
|
193
|
+
else {
|
|
194
|
+
min = Math.min(0, min)
|
|
195
|
+
max = Math.max(0, max)
|
|
196
|
+
}
|
|
197
|
+
if (min === max) max = min + 1
|
|
198
|
+
}
|
|
199
|
+
const range = niceNum(max - min, false)
|
|
200
|
+
const step = niceNum(range / Math.max(1, tickCount), true)
|
|
201
|
+
const nMin = Math.floor(min / step) * step
|
|
202
|
+
const nMax = Math.ceil(max / step) * step
|
|
203
|
+
const ticks: number[] = []
|
|
204
|
+
for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
|
|
205
|
+
return { min: nMin, max: nMax, step, ticks }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function fmtTick(n: number): string {
|
|
209
|
+
const abs = Math.abs(n)
|
|
210
|
+
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
|
|
211
|
+
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
|
|
212
|
+
return String(Math.round(n * 100) / 100)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const DAY = 86_400_000
|
|
216
|
+
/** Nice date-tick timestamps across [min, max]. */
|
|
217
|
+
function dateTicks(tMin: number, tMax: number): number[] {
|
|
218
|
+
const span = tMax - tMin
|
|
219
|
+
const step =
|
|
220
|
+
span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
|
|
221
|
+
const ticks: number[] = []
|
|
222
|
+
for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
|
|
223
|
+
if (!ticks.length) ticks.push(tMin, tMax)
|
|
224
|
+
return ticks
|
|
225
|
+
}
|
|
226
|
+
function fmtDate(t: number, span: number): string {
|
|
227
|
+
const d = new Date(t)
|
|
228
|
+
if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
229
|
+
if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
|
230
|
+
return String(d.getFullYear())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type ResolvedSeries = ChartSeries & {
|
|
234
|
+
color: string
|
|
235
|
+
kind: 'bar' | 'line' | 'area'
|
|
236
|
+
axis: 'left' | 'right'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Data domain for one axis, honoring stacking of its bar/area series. */
|
|
240
|
+
function axisDomain(
|
|
241
|
+
list: ResolvedSeries[],
|
|
242
|
+
categories: string[],
|
|
243
|
+
stacked: boolean,
|
|
244
|
+
extra: number[] = [],
|
|
245
|
+
): NiceScale {
|
|
246
|
+
let dMin = Infinity
|
|
247
|
+
let dMax = -Infinity
|
|
248
|
+
const note = (v: number) => {
|
|
249
|
+
if (!Number.isFinite(v)) return
|
|
250
|
+
if (v < dMin) dMin = v
|
|
251
|
+
if (v > dMax) dMax = v
|
|
252
|
+
}
|
|
253
|
+
for (const v of extra) note(v)
|
|
254
|
+
const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
|
|
255
|
+
const lines = list.filter((s) => s.kind === 'line')
|
|
256
|
+
if (stacked && stackable.length) {
|
|
257
|
+
for (let i = 0; i < categories.length; i += 1) {
|
|
258
|
+
let pos = 0
|
|
259
|
+
let neg = 0
|
|
260
|
+
for (const s of stackable) {
|
|
261
|
+
const v = s.values[i] ?? 0
|
|
262
|
+
if (v >= 0) pos += v
|
|
263
|
+
else neg += v
|
|
264
|
+
}
|
|
265
|
+
note(pos)
|
|
266
|
+
note(neg)
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
for (const s of stackable) for (const v of s.values) note(v)
|
|
270
|
+
}
|
|
271
|
+
for (const s of lines) for (const v of s.values) note(v)
|
|
272
|
+
if (dMin === Infinity) {
|
|
273
|
+
dMin = 0
|
|
274
|
+
dMax = 1
|
|
275
|
+
}
|
|
276
|
+
// Bar / area charts read against a zero baseline, so always include 0.
|
|
277
|
+
if (stackable.length) {
|
|
278
|
+
dMin = Math.min(dMin, 0)
|
|
279
|
+
dMax = Math.max(dMax, 0)
|
|
280
|
+
}
|
|
281
|
+
return niceScale(dMin, dMax)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function buildChart(spec: ChartSpec): ChartGeometry {
|
|
285
|
+
const width = spec.width ?? 520
|
|
286
|
+
const height = spec.height ?? 300
|
|
287
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
288
|
+
|
|
289
|
+
const stacked = !!(spec.stacked || spec.stacked100)
|
|
290
|
+
const series: ResolvedSeries[] = spec.series.map((s, i) => ({
|
|
291
|
+
...s,
|
|
292
|
+
color: s.color ?? palette[i % palette.length]!,
|
|
293
|
+
kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
|
|
294
|
+
| 'bar'
|
|
295
|
+
| 'line'
|
|
296
|
+
| 'area',
|
|
297
|
+
axis: s.axis ?? 'left',
|
|
298
|
+
}))
|
|
299
|
+
const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
|
|
300
|
+
|
|
301
|
+
const empty: ChartGeometry = {
|
|
302
|
+
type: spec.type,
|
|
303
|
+
width,
|
|
304
|
+
height,
|
|
305
|
+
plot: { x: 0, y: 0, w: width, h: height },
|
|
306
|
+
bars: [],
|
|
307
|
+
lines: [],
|
|
308
|
+
slices: [],
|
|
309
|
+
yTicks: [],
|
|
310
|
+
y2Ticks: [],
|
|
311
|
+
hasRightAxis: false,
|
|
312
|
+
xTicks: [],
|
|
313
|
+
xLabelRotated: false,
|
|
314
|
+
legend,
|
|
315
|
+
donut: null,
|
|
316
|
+
referenceLines: [],
|
|
317
|
+
scatterPoints: [],
|
|
318
|
+
orientation: 'vertical',
|
|
319
|
+
valueTicks: [],
|
|
320
|
+
catTicks: [],
|
|
321
|
+
referenceLinesV: [],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (spec.type === 'pie') {
|
|
325
|
+
const s = series[0]
|
|
326
|
+
if (!s) return empty
|
|
327
|
+
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
328
|
+
const cx = width / 2
|
|
329
|
+
const cy = height / 2
|
|
330
|
+
const r = Math.min(width, height) / 2 - 10
|
|
331
|
+
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
|
|
332
|
+
const ir = r * innerFrac
|
|
333
|
+
let angle = -Math.PI / 2
|
|
334
|
+
const slices: ChartPieSlice[] = s.values.map((v, i) => {
|
|
335
|
+
const frac = Math.max(0, v) / total
|
|
336
|
+
const a0 = angle
|
|
337
|
+
const a1 = angle + frac * Math.PI * 2
|
|
338
|
+
angle = a1
|
|
339
|
+
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
340
|
+
const mid = (a0 + a1) / 2
|
|
341
|
+
const labelR = (r + ir) / 2 || r * 0.6
|
|
342
|
+
const ox0 = cx + r * Math.cos(a0)
|
|
343
|
+
const oy0 = cy + r * Math.sin(a0)
|
|
344
|
+
const ox1 = cx + r * Math.cos(a1)
|
|
345
|
+
const oy1 = cy + r * Math.sin(a1)
|
|
346
|
+
let path: string
|
|
347
|
+
if (frac >= 0.999) {
|
|
348
|
+
path = ir
|
|
349
|
+
? `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z` +
|
|
350
|
+
`M${round(cx - ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx + ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx - ir)},${round(cy)} Z`
|
|
351
|
+
: `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z`
|
|
352
|
+
} else if (ir > 0) {
|
|
353
|
+
const ix0 = cx + ir * Math.cos(a0)
|
|
354
|
+
const iy0 = cy + ir * Math.sin(a0)
|
|
355
|
+
const ix1 = cx + ir * Math.cos(a1)
|
|
356
|
+
const iy1 = cy + ir * Math.sin(a1)
|
|
357
|
+
path =
|
|
358
|
+
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
359
|
+
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
|
|
360
|
+
} else {
|
|
361
|
+
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
path,
|
|
365
|
+
color: palette[i % palette.length]!,
|
|
366
|
+
label: spec.categories[i] ?? String(i),
|
|
367
|
+
value: v,
|
|
368
|
+
percent: frac * 100,
|
|
369
|
+
cx: round(cx + labelR * Math.cos(mid)),
|
|
370
|
+
cy: round(cy + labelR * Math.sin(mid)),
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
return {
|
|
374
|
+
...empty,
|
|
375
|
+
slices,
|
|
376
|
+
legend: spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]! })),
|
|
377
|
+
donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (spec.type === 'scatter') {
|
|
382
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
383
|
+
const padR = 12
|
|
384
|
+
const padT = 10
|
|
385
|
+
const padB = 28 + (spec.xAxisTitle ? 16 : 0)
|
|
386
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
387
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
388
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
389
|
+
|
|
390
|
+
let xMin = Infinity
|
|
391
|
+
let xMax = -Infinity
|
|
392
|
+
let yMin = Infinity
|
|
393
|
+
let yMax = -Infinity
|
|
394
|
+
let rMin = Infinity
|
|
395
|
+
let rMax = -Infinity
|
|
396
|
+
for (const s of series) {
|
|
397
|
+
for (const pt of s.points ?? []) {
|
|
398
|
+
if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
|
|
399
|
+
if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
|
|
400
|
+
if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (xMin === Infinity) return { ...empty, plot }
|
|
404
|
+
const xDom = niceScale(xMin, xMax)
|
|
405
|
+
const yDom = niceScale(yMin, yMax)
|
|
406
|
+
const hasR = rMax > rMin
|
|
407
|
+
const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
|
|
408
|
+
const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
|
|
409
|
+
const rOf = (r?: number) =>
|
|
410
|
+
hasR && r != null && Number.isFinite(r)
|
|
411
|
+
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
412
|
+
: 5
|
|
413
|
+
|
|
414
|
+
const scatterPoints: ChartScatterDot[] = []
|
|
415
|
+
for (const s of series) {
|
|
416
|
+
for (const pt of s.points ?? []) {
|
|
417
|
+
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
|
|
418
|
+
scatterPoints.push({
|
|
419
|
+
cx: xOf(pt.x),
|
|
420
|
+
cy: yOf(pt.y),
|
|
421
|
+
r: rOf(pt.r),
|
|
422
|
+
color: s.color,
|
|
423
|
+
label: pt.label ?? '',
|
|
424
|
+
series: s.label,
|
|
425
|
+
x: pt.x,
|
|
426
|
+
y: pt.y,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
431
|
+
y: yOf(ref.value),
|
|
432
|
+
label: ref.label ?? fmtTick(ref.value),
|
|
433
|
+
color: ref.color ?? '#ef4444',
|
|
434
|
+
dashed: ref.dashed !== false,
|
|
435
|
+
}))
|
|
436
|
+
return {
|
|
437
|
+
...empty,
|
|
438
|
+
plot,
|
|
439
|
+
scatterPoints,
|
|
440
|
+
referenceLines,
|
|
441
|
+
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: fmtTick(value) })),
|
|
442
|
+
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---- Horizontal bars ----------------------------------------------------
|
|
447
|
+
// Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
448
|
+
const horizontal =
|
|
449
|
+
spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
|
|
450
|
+
if (horizontal) {
|
|
451
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
452
|
+
const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
|
|
453
|
+
const padR = 16
|
|
454
|
+
const padT = 8
|
|
455
|
+
const padB = 26 + (spec.xAxisTitle ? 16 : 0)
|
|
456
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
457
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
458
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
459
|
+
|
|
460
|
+
const refs = (spec.referenceLines ?? []).map((r) => r.value)
|
|
461
|
+
const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
|
|
462
|
+
const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
|
|
463
|
+
|
|
464
|
+
const n = spec.categories.length
|
|
465
|
+
const slot = plotH / Math.max(1, n)
|
|
466
|
+
const groupPad = slot * 0.2
|
|
467
|
+
const inner = slot - groupPad
|
|
468
|
+
const bandTop = (i: number) => padT + slot * i + groupPad / 2
|
|
469
|
+
const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
|
|
470
|
+
|
|
471
|
+
const bars: ChartBar[] = []
|
|
472
|
+
if (stacked) {
|
|
473
|
+
const totals = spec.stacked100
|
|
474
|
+
? spec.categories.map(
|
|
475
|
+
(_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
476
|
+
)
|
|
477
|
+
: null
|
|
478
|
+
const pos = new Array(n).fill(0)
|
|
479
|
+
const neg = new Array(n).fill(0)
|
|
480
|
+
for (const s of series) {
|
|
481
|
+
s.values.forEach((v, i) => {
|
|
482
|
+
if (!Number.isFinite(v)) return
|
|
483
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
484
|
+
let xL: number
|
|
485
|
+
let xR: number
|
|
486
|
+
if (vp >= 0) {
|
|
487
|
+
xL = xOf(pos[i])
|
|
488
|
+
xR = xOf(pos[i] + vp)
|
|
489
|
+
pos[i] += vp
|
|
490
|
+
} else {
|
|
491
|
+
xL = xOf(neg[i] + vp)
|
|
492
|
+
xR = xOf(neg[i])
|
|
493
|
+
neg[i] += vp
|
|
494
|
+
}
|
|
495
|
+
bars.push({
|
|
496
|
+
x: Math.min(xL, xR),
|
|
497
|
+
y: round(bandTop(i)),
|
|
498
|
+
w: round(Math.abs(xR - xL)),
|
|
499
|
+
h: round(Math.max(1, inner)),
|
|
500
|
+
color: s.color,
|
|
501
|
+
label: spec.categories[i] ?? String(i),
|
|
502
|
+
series: s.label,
|
|
503
|
+
value: v,
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
const barH = inner / series.length
|
|
509
|
+
series.forEach((s, bi) => {
|
|
510
|
+
s.values.forEach((v, i) => {
|
|
511
|
+
if (!Number.isFinite(v)) return
|
|
512
|
+
const xV = xOf(v)
|
|
513
|
+
bars.push({
|
|
514
|
+
x: Math.min(xV, xBase),
|
|
515
|
+
y: round(bandTop(i) + barH * bi),
|
|
516
|
+
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
517
|
+
h: round(Math.max(1, barH - 1)),
|
|
518
|
+
color: s.color,
|
|
519
|
+
label: spec.categories[i] ?? String(i),
|
|
520
|
+
series: s.label,
|
|
521
|
+
value: v,
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
|
|
528
|
+
label: spec.stacked100 ? `${fmtTick(value)}%` : fmtTick(value),
|
|
529
|
+
x: xOf(value),
|
|
530
|
+
}))
|
|
531
|
+
const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
|
|
532
|
+
value: i,
|
|
533
|
+
y: round(bandTop(i) + inner / 2),
|
|
534
|
+
label,
|
|
535
|
+
}))
|
|
536
|
+
const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
537
|
+
x: xOf(ref.value),
|
|
538
|
+
label: ref.label ?? fmtTick(ref.value),
|
|
539
|
+
color: ref.color ?? '#ef4444',
|
|
540
|
+
dashed: ref.dashed !== false,
|
|
541
|
+
}))
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
...empty,
|
|
545
|
+
plot,
|
|
546
|
+
bars,
|
|
547
|
+
orientation: 'horizontal',
|
|
548
|
+
valueTicks,
|
|
549
|
+
catTicks,
|
|
550
|
+
referenceLinesV,
|
|
551
|
+
xLabelRotated: false,
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
|
|
556
|
+
const leftSeries = series.filter((s) => s.axis === 'left')
|
|
557
|
+
const rightSeries = series.filter((s) => s.axis === 'right')
|
|
558
|
+
const hasRightAxis = rightSeries.length > 0
|
|
559
|
+
|
|
560
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
561
|
+
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
562
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
563
|
+
const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
|
|
564
|
+
const padT = 10
|
|
565
|
+
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
|
|
566
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
567
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
568
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
569
|
+
|
|
570
|
+
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
|
|
571
|
+
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
|
|
572
|
+
const leftDom = spec.stacked100
|
|
573
|
+
? niceScale(0, 100)
|
|
574
|
+
: axisDomain(leftSeries, spec.categories, stacked, refsLeft)
|
|
575
|
+
const rightDom = hasRightAxis
|
|
576
|
+
? spec.stacked100
|
|
577
|
+
? niceScale(0, 100)
|
|
578
|
+
: axisDomain(rightSeries, spec.categories, stacked, refsRight)
|
|
579
|
+
: null
|
|
580
|
+
|
|
581
|
+
const yOf = (dom: NiceScale, v: number) =>
|
|
582
|
+
round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
|
|
583
|
+
const yLeft = (v: number) => yOf(leftDom, v)
|
|
584
|
+
const yRight = (v: number) => yOf(rightDom ?? leftDom, v)
|
|
585
|
+
const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
|
|
586
|
+
|
|
587
|
+
const n = spec.categories.length
|
|
588
|
+
const slot = plotW / Math.max(1, n)
|
|
589
|
+
|
|
590
|
+
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
591
|
+
// a category axis is uniform. (Bars stay uniform either way.)
|
|
592
|
+
const timeVals =
|
|
593
|
+
spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
|
|
594
|
+
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
595
|
+
const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
|
|
596
|
+
const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
|
|
597
|
+
const xCenter = (i: number) =>
|
|
598
|
+
timeOk && Number.isFinite(timeVals![i])
|
|
599
|
+
? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
|
|
600
|
+
: round(padL + slot * i + slot / 2)
|
|
601
|
+
const xTicks: ChartCategoryTick[] = timeOk
|
|
602
|
+
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
603
|
+
label: fmtDate(t, tSpan),
|
|
604
|
+
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
605
|
+
}))
|
|
606
|
+
: spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
|
|
607
|
+
|
|
608
|
+
const barSeries = series.filter((s) => s.kind === 'bar')
|
|
609
|
+
const bars: ChartBar[] = []
|
|
610
|
+
if (barSeries.length) {
|
|
611
|
+
const groupPad = slot * 0.2
|
|
612
|
+
if (stacked) {
|
|
613
|
+
const inner = slot - groupPad
|
|
614
|
+
const x0 = (i: number) => padL + slot * i + groupPad / 2
|
|
615
|
+
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
616
|
+
for (const axis of ['left', 'right'] as const) {
|
|
617
|
+
const axisBars = barSeries.filter((s) => s.axis === axis)
|
|
618
|
+
if (!axisBars.length) continue
|
|
619
|
+
const yA = axis === 'right' ? yRight : yLeft
|
|
620
|
+
// 100% mode normalizes each category to its absolute total.
|
|
621
|
+
const totals = spec.stacked100
|
|
622
|
+
? spec.categories.map(
|
|
623
|
+
(_, i) =>
|
|
624
|
+
axisBars.reduce(
|
|
625
|
+
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
626
|
+
0,
|
|
627
|
+
) || 1,
|
|
628
|
+
)
|
|
629
|
+
: null
|
|
630
|
+
const pos = new Array(n).fill(0)
|
|
631
|
+
const neg = new Array(n).fill(0)
|
|
632
|
+
for (const s of axisBars) {
|
|
633
|
+
s.values.forEach((v, i) => {
|
|
634
|
+
if (!Number.isFinite(v)) return
|
|
635
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
636
|
+
let yTop: number
|
|
637
|
+
let yBot: number
|
|
638
|
+
if (vp >= 0) {
|
|
639
|
+
yTop = yA(pos[i] + vp)
|
|
640
|
+
yBot = yA(pos[i])
|
|
641
|
+
pos[i] += vp
|
|
642
|
+
} else {
|
|
643
|
+
yTop = yA(neg[i])
|
|
644
|
+
yBot = yA(neg[i] + vp)
|
|
645
|
+
neg[i] += vp
|
|
646
|
+
}
|
|
647
|
+
bars.push({
|
|
648
|
+
x: round(x0(i)),
|
|
649
|
+
y: Math.min(yTop, yBot),
|
|
650
|
+
w: round(Math.max(1, inner)),
|
|
651
|
+
h: round(Math.abs(yBot - yTop)),
|
|
652
|
+
color: s.color,
|
|
653
|
+
label: spec.categories[i] ?? String(i),
|
|
654
|
+
series: s.label,
|
|
655
|
+
value: v,
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
const inner = slot - groupPad
|
|
662
|
+
const barW = inner / barSeries.length
|
|
663
|
+
barSeries.forEach((s, bi) => {
|
|
664
|
+
const dom = domOf(s)
|
|
665
|
+
const base = yOf(dom, Math.min(Math.max(0, dom.min), dom.max))
|
|
666
|
+
s.values.forEach((v, i) => {
|
|
667
|
+
if (!Number.isFinite(v)) return
|
|
668
|
+
const x = padL + slot * i + groupPad / 2 + barW * bi
|
|
669
|
+
const yV = yOf(dom, v)
|
|
670
|
+
bars.push({
|
|
671
|
+
x: round(x),
|
|
672
|
+
y: Math.min(yV, base),
|
|
673
|
+
w: round(Math.max(1, barW - 1)),
|
|
674
|
+
h: round(Math.max(1, Math.abs(yV - base))),
|
|
675
|
+
color: s.color,
|
|
676
|
+
label: spec.categories[i] ?? String(i),
|
|
677
|
+
series: s.label,
|
|
678
|
+
value: v,
|
|
679
|
+
})
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
686
|
+
const lines: ChartLine[] = []
|
|
687
|
+
const areaCum: Record<'left' | 'right', number[]> = {
|
|
688
|
+
left: new Array(n).fill(0),
|
|
689
|
+
right: new Array(n).fill(0),
|
|
690
|
+
}
|
|
691
|
+
// 100% mode: per-axis per-category totals to normalize stacked areas to 100.
|
|
692
|
+
const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
|
|
693
|
+
if (spec.stacked100) {
|
|
694
|
+
for (const axis of ['left', 'right'] as const) {
|
|
695
|
+
const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
|
|
696
|
+
if (areaSeries.length) {
|
|
697
|
+
areaTotals[axis] = spec.categories.map(
|
|
698
|
+
(_, i) =>
|
|
699
|
+
areaSeries.reduce(
|
|
700
|
+
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
701
|
+
0,
|
|
702
|
+
) || 1,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const s of series) {
|
|
708
|
+
if (s.kind === 'bar') continue
|
|
709
|
+
const dom = domOf(s)
|
|
710
|
+
const yA = (v: number) => yOf(dom, v)
|
|
711
|
+
const isStackedArea = stacked && s.kind === 'area'
|
|
712
|
+
const px = (i: number) => xCenter(i)
|
|
713
|
+
let pts: ChartLinePoint[]
|
|
714
|
+
let baselinePts: Array<{ x: number; y: number }> | null = null
|
|
715
|
+
if (isStackedArea) {
|
|
716
|
+
// Stacked areas treat a gap as 0 so the stack stays continuous.
|
|
717
|
+
const cum = areaCum[s.axis]
|
|
718
|
+
const prev = cum.slice()
|
|
719
|
+
const totals = areaTotals[s.axis]
|
|
720
|
+
pts = s.values.map((v, i) => {
|
|
721
|
+
const vv = Number.isFinite(v) ? v : 0
|
|
722
|
+
// 100% mode positions by share of the category total; value stays original.
|
|
723
|
+
const norm = totals ? (vv / totals[i]!) * 100 : vv
|
|
724
|
+
const c = (cum[i] ?? 0) + norm
|
|
725
|
+
cum[i] = c
|
|
726
|
+
return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
|
|
727
|
+
})
|
|
728
|
+
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
|
|
729
|
+
} else {
|
|
730
|
+
pts = s.values.map((v, i) => {
|
|
731
|
+
const ok = Number.isFinite(v)
|
|
732
|
+
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
// Build the line in segments, breaking at gaps (undefined points).
|
|
736
|
+
let path = ''
|
|
737
|
+
let pen = false
|
|
738
|
+
for (const p of pts) {
|
|
739
|
+
if (!p.defined) {
|
|
740
|
+
pen = false
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
|
|
744
|
+
pen = true
|
|
745
|
+
}
|
|
746
|
+
path = path.trim()
|
|
747
|
+
|
|
748
|
+
let areaPath = ''
|
|
749
|
+
if (s.kind === 'area' && pts.length) {
|
|
750
|
+
if (baselinePts) {
|
|
751
|
+
const top = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
752
|
+
const back = baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
753
|
+
areaPath = `${top} ${back} Z`
|
|
754
|
+
} else {
|
|
755
|
+
// One filled polygon per contiguous run of defined points.
|
|
756
|
+
const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
|
|
757
|
+
const runs: ChartLinePoint[][] = []
|
|
758
|
+
let cur: ChartLinePoint[] = []
|
|
759
|
+
for (const p of pts) {
|
|
760
|
+
if (p.defined) cur.push(p)
|
|
761
|
+
else if (cur.length) {
|
|
762
|
+
runs.push(cur)
|
|
763
|
+
cur = []
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (cur.length) runs.push(cur)
|
|
767
|
+
areaPath = runs
|
|
768
|
+
.map((run) => {
|
|
769
|
+
const top = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
770
|
+
return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
|
|
771
|
+
})
|
|
772
|
+
.join(' ')
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts })
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const tickFor = (dom: NiceScale): ChartAxisTick[] =>
|
|
779
|
+
dom.ticks.map((value) => ({ value, y: yOf(dom, value), label: fmtTick(value) }))
|
|
780
|
+
|
|
781
|
+
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
|
|
782
|
+
const dom = ref.axis === 'right' ? (rightDom ?? leftDom) : leftDom
|
|
783
|
+
return {
|
|
784
|
+
y: yOf(dom, ref.value),
|
|
785
|
+
label: ref.label ?? fmtTick(ref.value),
|
|
786
|
+
color: ref.color ?? '#ef4444',
|
|
787
|
+
dashed: ref.dashed !== false,
|
|
788
|
+
}
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
...empty,
|
|
793
|
+
plot,
|
|
794
|
+
bars,
|
|
795
|
+
lines,
|
|
796
|
+
yTicks: tickFor(leftDom),
|
|
797
|
+
y2Ticks: rightDom ? tickFor(rightDom) : [],
|
|
798
|
+
hasRightAxis,
|
|
799
|
+
xTicks,
|
|
800
|
+
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
801
|
+
referenceLines,
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
807
|
+
* value field per group. Three multi-series shapes:
|
|
808
|
+
* - `value: 'revenue'` -> one series
|
|
809
|
+
* - `value: ['revenue','cost']` -> one series per value field
|
|
810
|
+
* - `value: 'sales', series: 'region'` -> pivot: one series per distinct
|
|
811
|
+
* value of the `series` field
|
|
812
|
+
*/
|
|
813
|
+
export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
814
|
+
rows: ReadonlyArray<T>,
|
|
815
|
+
opts: {
|
|
816
|
+
type: ChartType
|
|
817
|
+
category: keyof T & string
|
|
818
|
+
value: (keyof T & string) | Array<keyof T & string>
|
|
819
|
+
/** Pivot dimension: one series per distinct value of this field. */
|
|
820
|
+
series?: keyof T & string
|
|
821
|
+
reduce?: 'sum' | 'avg' | 'count'
|
|
822
|
+
seriesLabel?: string
|
|
823
|
+
width?: number
|
|
824
|
+
height?: number
|
|
825
|
+
stacked?: boolean
|
|
826
|
+
stacked100?: boolean
|
|
827
|
+
palette?: string[]
|
|
828
|
+
/** Order categories. Defaults to insertion order (or value-desc when topN). */
|
|
829
|
+
sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
|
|
830
|
+
/** Keep only the top N categories; bucket the rest into "Other". */
|
|
831
|
+
topN?: number
|
|
832
|
+
/** Label for the bucketed remainder. Default "Other". */
|
|
833
|
+
otherLabel?: string
|
|
834
|
+
},
|
|
835
|
+
): ChartSpec {
|
|
836
|
+
const reduce = opts.reduce ?? 'sum'
|
|
837
|
+
const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
838
|
+
const reduceCell = (sum: number, count: number) =>
|
|
839
|
+
reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
|
|
840
|
+
|
|
841
|
+
const categories: string[] = []
|
|
842
|
+
const catIndex = new Map<string, number>()
|
|
843
|
+
const ensureCat = (key: string) => {
|
|
844
|
+
let idx = catIndex.get(key)
|
|
845
|
+
if (idx === undefined) {
|
|
846
|
+
idx = categories.length
|
|
847
|
+
catIndex.set(key, idx)
|
|
848
|
+
categories.push(key)
|
|
849
|
+
}
|
|
850
|
+
return idx
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Series keyed by name -> per-category {sum,count}.
|
|
854
|
+
const seriesMap = new Map<string, { sum: number; count: number }[]>()
|
|
855
|
+
const ensureSeries = (name: string) => {
|
|
856
|
+
let arr = seriesMap.get(name)
|
|
857
|
+
if (!arr) {
|
|
858
|
+
arr = []
|
|
859
|
+
seriesMap.set(name, arr)
|
|
860
|
+
}
|
|
861
|
+
return arr
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
for (const row of rows) {
|
|
865
|
+
const cat = String(row[opts.category] ?? '')
|
|
866
|
+
const ci = ensureCat(cat)
|
|
867
|
+
if (opts.series) {
|
|
868
|
+
const sName = String(row[opts.series] ?? '')
|
|
869
|
+
const arr = ensureSeries(sName)
|
|
870
|
+
const num = Number(row[valueFields[0]!])
|
|
871
|
+
const cell = (arr[ci] ??= { sum: 0, count: 0 })
|
|
872
|
+
if (Number.isFinite(num)) {
|
|
873
|
+
cell.sum += num
|
|
874
|
+
cell.count += 1
|
|
875
|
+
}
|
|
876
|
+
} else {
|
|
877
|
+
for (const vf of valueFields) {
|
|
878
|
+
const arr = ensureSeries(vf)
|
|
879
|
+
const num = Number(row[vf])
|
|
880
|
+
const cell = (arr[ci] ??= { sum: 0, count: 0 })
|
|
881
|
+
if (Number.isFinite(num)) {
|
|
882
|
+
cell.sum += num
|
|
883
|
+
cell.count += 1
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
890
|
+
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
891
|
+
values: categories.map((_, i) => {
|
|
892
|
+
const cell = arr[i] ?? { sum: 0, count: 0 }
|
|
893
|
+
return reduceCell(cell.sum, cell.count)
|
|
894
|
+
}),
|
|
895
|
+
}))
|
|
896
|
+
|
|
897
|
+
// ---- Sort + top-N -----------------------------------------------------
|
|
898
|
+
const totals = categories.map((_, i) =>
|
|
899
|
+
entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
|
|
900
|
+
)
|
|
901
|
+
const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
|
|
902
|
+
let order = categories.map((_, i) => i)
|
|
903
|
+
if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
|
|
904
|
+
else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
|
|
905
|
+
else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
|
|
906
|
+
|
|
907
|
+
let finalCategories: string[]
|
|
908
|
+
let finalSeries: ChartSeries[]
|
|
909
|
+
if (opts.topN && order.length > opts.topN) {
|
|
910
|
+
const keep = order.slice(0, opts.topN)
|
|
911
|
+
const rest = order.slice(opts.topN)
|
|
912
|
+
finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
|
|
913
|
+
finalSeries = entries.map((e) => ({
|
|
914
|
+
label: e.label,
|
|
915
|
+
values: keep
|
|
916
|
+
.map((i) => e.values[i]!)
|
|
917
|
+
.concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
|
|
918
|
+
}))
|
|
919
|
+
} else {
|
|
920
|
+
finalCategories = order.map((i) => categories[i]!)
|
|
921
|
+
finalSeries = entries.map((e) => ({ label: e.label, values: order.map((i) => e.values[i]!) }))
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
type: opts.type,
|
|
926
|
+
categories: finalCategories,
|
|
927
|
+
series: finalSeries,
|
|
928
|
+
width: opts.width,
|
|
929
|
+
height: opts.height,
|
|
930
|
+
stacked: opts.stacked,
|
|
931
|
+
stacked100: opts.stacked100,
|
|
932
|
+
palette: opts.palette,
|
|
933
|
+
}
|
|
934
|
+
}
|