@svgrid/grid 1.0.2 → 1.1.1

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.
Files changed (216) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +648 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +472 -0
  8. package/dist/SvGrid.controller.svelte.js +1917 -0
  9. package/dist/SvGrid.css +2012 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.helpers.test.d.ts +1 -0
  13. package/dist/SvGrid.helpers.test.js +298 -0
  14. package/dist/SvGrid.svelte +2346 -8742
  15. package/dist/SvGrid.svelte.d.ts +4 -357
  16. package/dist/SvGrid.types.d.ts +523 -0
  17. package/dist/SvGrid.types.js +1 -0
  18. package/dist/SvGridChart.svelte +1060 -23
  19. package/dist/SvGridChart.svelte.d.ts +17 -0
  20. package/dist/aligned-grids.d.ts +6 -0
  21. package/dist/aligned-grids.js +84 -0
  22. package/dist/aligned-grids.test.d.ts +1 -0
  23. package/dist/aligned-grids.test.js +75 -0
  24. package/dist/build-api.coverage.test.d.ts +20 -0
  25. package/dist/build-api.coverage.test.js +505 -0
  26. package/dist/build-api.d.ts +5 -0
  27. package/dist/build-api.js +557 -0
  28. package/dist/cell-render.d.ts +15 -0
  29. package/dist/cell-render.js +246 -0
  30. package/dist/cell-render.test.d.ts +1 -0
  31. package/dist/cell-render.test.js +338 -0
  32. package/dist/cell-values.d.ts +28 -0
  33. package/dist/cell-values.js +89 -0
  34. package/dist/chart-export.test.d.ts +1 -0
  35. package/dist/chart-export.test.js +302 -0
  36. package/dist/chart.coverage.test.d.ts +1 -0
  37. package/dist/chart.coverage.test.js +748 -0
  38. package/dist/chart.d.ts +370 -3
  39. package/dist/chart.js +1135 -42
  40. package/dist/clipboard.d.ts +15 -0
  41. package/dist/clipboard.js +420 -0
  42. package/dist/clipboard.test.d.ts +1 -0
  43. package/dist/clipboard.test.js +700 -0
  44. package/dist/collaboration.coverage.test.d.ts +1 -0
  45. package/dist/collaboration.coverage.test.js +200 -0
  46. package/dist/column-groups.d.ts +19 -0
  47. package/dist/column-groups.js +62 -0
  48. package/dist/column-groups.test.d.ts +1 -0
  49. package/dist/column-groups.test.js +56 -0
  50. package/dist/column-types.d.ts +10 -0
  51. package/dist/column-types.js +63 -0
  52. package/dist/column-types.test.d.ts +1 -0
  53. package/dist/column-types.test.js +62 -0
  54. package/dist/columns.d.ts +30 -0
  55. package/dist/columns.js +277 -0
  56. package/dist/columns.test.d.ts +1 -0
  57. package/dist/columns.test.js +625 -0
  58. package/dist/core.d.ts +86 -1
  59. package/dist/css.d.ts +3 -0
  60. package/dist/editing.d.ts +31 -0
  61. package/dist/editing.js +529 -0
  62. package/dist/editing.test.d.ts +1 -0
  63. package/dist/editing.test.js +732 -0
  64. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  65. package/dist/editors/cell-editors.coverage.test.js +139 -0
  66. package/dist/editors/cell-editors.d.ts +1 -1
  67. package/dist/facet-buckets.d.ts +13 -0
  68. package/dist/facet-buckets.js +54 -0
  69. package/dist/facet-buckets.test.d.ts +1 -0
  70. package/dist/facet-buckets.test.js +296 -0
  71. package/dist/features.d.ts +5 -0
  72. package/dist/features.js +30 -0
  73. package/dist/filter-operators.d.ts +16 -0
  74. package/dist/filter-operators.js +69 -0
  75. package/dist/filter-operators.test.d.ts +1 -0
  76. package/dist/filter-operators.test.js +135 -0
  77. package/dist/hyperformula-adapter.d.ts +82 -0
  78. package/dist/hyperformula-adapter.js +73 -0
  79. package/dist/hyperformula-adapter.test.d.ts +1 -0
  80. package/dist/hyperformula-adapter.test.js +205 -0
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.js +8 -2
  83. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  84. package/dist/keyboard-handlers.coverage.test.js +495 -0
  85. package/dist/keyboard-handlers.d.ts +7 -0
  86. package/dist/keyboard-handlers.js +202 -0
  87. package/dist/menus.d.ts +40 -0
  88. package/dist/menus.js +390 -0
  89. package/dist/menus.test.d.ts +1 -0
  90. package/dist/menus.test.js +560 -0
  91. package/dist/named-views.coverage.test.d.ts +1 -0
  92. package/dist/named-views.coverage.test.js +180 -0
  93. package/dist/named-views.d.ts +27 -0
  94. package/dist/named-views.js +39 -0
  95. package/dist/row-drag.d.ts +49 -0
  96. package/dist/row-drag.js +221 -0
  97. package/dist/row-drag.test.d.ts +1 -0
  98. package/dist/row-drag.test.js +142 -0
  99. package/dist/row-resize.d.ts +43 -0
  100. package/dist/row-resize.js +158 -0
  101. package/dist/row-resize.test.d.ts +1 -0
  102. package/dist/row-resize.test.js +329 -0
  103. package/dist/scroll-sync.d.ts +9 -0
  104. package/dist/scroll-sync.js +89 -0
  105. package/dist/scroll-sync.test.d.ts +1 -0
  106. package/dist/scroll-sync.test.js +290 -0
  107. package/dist/selection.d.ts +32 -0
  108. package/dist/selection.js +427 -0
  109. package/dist/selection.multi-range.test.d.ts +1 -0
  110. package/dist/selection.multi-range.test.js +55 -0
  111. package/dist/selection.test.d.ts +1 -0
  112. package/dist/selection.test.js +647 -0
  113. package/dist/server-data-source.coverage.test.d.ts +1 -0
  114. package/dist/server-data-source.coverage.test.js +154 -0
  115. package/dist/spreadsheet.d.ts +110 -0
  116. package/dist/spreadsheet.js +242 -0
  117. package/dist/spreadsheet.test.d.ts +1 -0
  118. package/dist/spreadsheet.test.js +446 -0
  119. package/dist/summaries.d.ts +12 -0
  120. package/dist/summaries.js +65 -0
  121. package/dist/sv-grid-scrollbar.js +13 -1
  122. package/dist/svgrid-wrapper.types.d.ts +31 -1
  123. package/dist/svgrid.behavior.test.js +20 -0
  124. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  125. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  126. package/dist/svgrid.context-menu.test.d.ts +1 -0
  127. package/dist/svgrid.context-menu.test.js +102 -0
  128. package/dist/svgrid.interaction.test.js +31 -0
  129. package/dist/svgrid.new-features.wrapper.test.js +64 -6
  130. package/dist/svgrid.wrapper.test.js +27 -1
  131. package/dist/test-setup.js +9 -3
  132. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  133. package/dist/virtualization/scroll-scaling.d.ts +45 -0
  134. package/dist/virtualization/scroll-scaling.js +99 -0
  135. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  136. package/dist/virtualization/scroll-scaling.test.js +127 -0
  137. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  138. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  139. package/dist/virtualization/virtualizer.d.ts +7 -0
  140. package/dist/virtualization/virtualizer.js +30 -0
  141. package/package.json +2 -1
  142. package/src/GridFooter.svelte +164 -0
  143. package/src/GridMenus.svelte +648 -0
  144. package/src/SvGrid.controller.svelte.ts +2352 -0
  145. package/src/SvGrid.css +2012 -0
  146. package/src/SvGrid.helpers.test.ts +415 -0
  147. package/src/SvGrid.helpers.ts +185 -0
  148. package/src/SvGrid.svelte +2346 -8742
  149. package/src/SvGrid.types.ts +537 -0
  150. package/src/SvGridChart.svelte +1060 -23
  151. package/src/aligned-grids.test.ts +80 -0
  152. package/src/aligned-grids.ts +87 -0
  153. package/src/build-api.coverage.test.ts +532 -0
  154. package/src/build-api.ts +683 -0
  155. package/src/cell-render.test.ts +451 -0
  156. package/src/cell-render.ts +426 -0
  157. package/src/cell-values.ts +114 -0
  158. package/src/chart-export.test.ts +370 -0
  159. package/src/chart.coverage.test.ts +814 -0
  160. package/src/chart.ts +1352 -47
  161. package/src/clipboard.test.ts +780 -0
  162. package/src/clipboard.ts +552 -0
  163. package/src/collaboration.coverage.test.ts +220 -0
  164. package/src/column-groups.test.ts +59 -0
  165. package/src/column-groups.ts +80 -0
  166. package/src/column-types.test.ts +68 -0
  167. package/src/column-types.ts +82 -0
  168. package/src/columns.test.ts +702 -0
  169. package/src/columns.ts +419 -0
  170. package/src/core.ts +86 -0
  171. package/src/css.d.ts +3 -0
  172. package/src/editing.test.ts +837 -0
  173. package/src/editing.ts +669 -0
  174. package/src/editors/cell-editors.coverage.test.ts +156 -0
  175. package/src/editors/cell-editors.ts +1 -0
  176. package/src/facet-buckets.test.ts +353 -0
  177. package/src/facet-buckets.ts +67 -0
  178. package/src/features.ts +128 -0
  179. package/src/filter-operators.test.ts +174 -0
  180. package/src/filter-operators.ts +87 -0
  181. package/src/hyperformula-adapter.test.ts +256 -0
  182. package/src/hyperformula-adapter.ts +124 -0
  183. package/src/index.ts +49 -0
  184. package/src/keyboard-handlers.coverage.test.ts +560 -0
  185. package/src/keyboard-handlers.ts +353 -0
  186. package/src/keyboard.ts +97 -97
  187. package/src/menus.test.ts +620 -0
  188. package/src/menus.ts +555 -0
  189. package/src/named-views.coverage.test.ts +210 -0
  190. package/src/named-views.ts +48 -0
  191. package/src/row-drag.test.ts +168 -0
  192. package/src/row-drag.ts +255 -0
  193. package/src/row-resize.test.ts +369 -0
  194. package/src/row-resize.ts +171 -0
  195. package/src/scroll-sync.test.ts +330 -0
  196. package/src/scroll-sync.ts +218 -0
  197. package/src/selection.multi-range.test.ts +61 -0
  198. package/src/selection.test.ts +722 -0
  199. package/src/selection.ts +579 -0
  200. package/src/server-data-source.coverage.test.ts +180 -0
  201. package/src/spreadsheet.test.ts +489 -0
  202. package/src/spreadsheet.ts +304 -0
  203. package/src/summaries.ts +204 -0
  204. package/src/sv-grid-scrollbar.ts +13 -1
  205. package/src/svgrid-wrapper.types.ts +31 -1
  206. package/src/svgrid.behavior.test.ts +22 -0
  207. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  208. package/src/svgrid.context-menu.test.ts +126 -0
  209. package/src/svgrid.interaction.test.ts +30 -0
  210. package/src/svgrid.new-features.wrapper.test.ts +67 -6
  211. package/src/svgrid.wrapper.test.ts +27 -1
  212. package/src/test-setup.ts +9 -6
  213. package/src/virtualization/scroll-scaling.test.ts +148 -0
  214. package/src/virtualization/scroll-scaling.ts +121 -0
  215. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  216. package/src/virtualization/virtualizer.ts +26 -0
package/src/chart.ts CHANGED
@@ -8,14 +8,38 @@
8
8
  * (per-series type), a secondary (right) Y axis, signed Y domains (negative
9
9
  * values drop below a zero baseline), and nice auto-scaled ticks.
10
10
  */
11
- export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'scatter'
11
+ export type ChartType =
12
+ | 'bar' | 'line' | 'area' | 'pie' | 'scatter'
13
+ | 'heatmap' | 'waterfall' | 'funnel' | 'radar'
14
+ | 'calendar' | 'gauge' | 'treemap' | 'sankey'
12
15
 
13
- /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`. */
14
- export type ChartSelection = { category: string; series: string; value: number }
16
+ /** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
17
+ * `rowIds` is populated when the spec was built from grid rows (via
18
+ * `rowsToChartSpec`) and lets a drill handler filter the grid back to the
19
+ * source rows for the clicked category / series cell. */
20
+ export type ChartSelection = {
21
+ category: string
22
+ series: string
23
+ value: number
24
+ rowIds?: Array<string | number>
25
+ }
15
26
 
16
27
  /** A single scatter / bubble point. */
17
28
  export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
18
29
 
30
+ /** A statistical / smoothing line drawn on top of a source series.
31
+ * - `'linear'`: ordinary least-squares regression line
32
+ * - `'sma:N'`: simple moving average over a window of N points
33
+ * - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
34
+ export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
35
+
36
+ /** A texture fill applied in addition to (and on top of) the series color.
37
+ * Helps colorblind readers distinguish series at a glance. */
38
+ export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
39
+ /** Cycle used when `ChartSpec.patternFallback` is true and a series has no
40
+ * explicit `pattern` set. Skips `'solid'` so every series gets a texture. */
41
+ const PATTERN_CYCLE: SeriesPattern[] = ['stripe', 'crosshatch', 'dots', 'diagonal']
42
+
19
43
  export type ChartSeries = {
20
44
  label: string
21
45
  values: number[]
@@ -26,6 +50,41 @@ export type ChartSeries = {
26
50
  axis?: 'left' | 'right'
27
51
  /** Scatter / bubble points (used when `type === 'scatter'`). */
28
52
  points?: ScatterPoint[]
53
+ /** Row IDs contributing to each data point - parallel to `values`. When
54
+ * present, click handlers receive these in `ChartSelection.rowIds` so
55
+ * callers can drill the grid back to the source rows. */
56
+ rowIds?: Array<Array<string | number>>
57
+ /** Draw a smoothing / trend overlay on top of this series. */
58
+ overlay?: SeriesOverlay
59
+ /** Color for the overlay line. Defaults to the series color. */
60
+ overlayColor?: string
61
+ /** Texture fill (e.g. diagonal stripes) layered over the series color.
62
+ * Lets colorblind viewers tell two series apart even at the same hue. */
63
+ pattern?: SeriesPattern
64
+ /** Interpolate the line as a curve instead of polylines. `'monotone'`
65
+ * cubic prevents overshoots between points (best default for data);
66
+ * `true` is an alias for `'monotone'`. Only meaningful for line/area. */
67
+ smooth?: boolean | 'monotone'
68
+ /** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
69
+ * When set alongside `lowerValues`, the chart shades the band between
70
+ * the two as a translucent fill in the series color. */
71
+ upperValues?: number[]
72
+ /** Lower envelope; pair with `upperValues` for a confidence band. */
73
+ lowerValues?: number[]
74
+ }
75
+
76
+ /** A pinned label drawn over the plot, anchored to a data point or to an
77
+ * arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
78
+ export type ChartAnnotation = {
79
+ /** Anchor in data space. Provide either `category` + `axis` for a point on
80
+ * an existing series, OR raw `x` / `y` numeric coordinates in data space. */
81
+ at:
82
+ | { category: string; series?: string }
83
+ | { x: number; y?: number }
84
+ label: string
85
+ color?: string
86
+ /** Where the label sits relative to the marker. Defaults to 'top'. */
87
+ placement?: 'top' | 'bottom' | 'left' | 'right'
29
88
  }
30
89
 
31
90
  /** A horizontal reference / target line drawn across the plot. */
@@ -67,6 +126,195 @@ export type ChartSpec = {
67
126
  yAxisTitle?: string
68
127
  y2AxisTitle?: string
69
128
  xAxisTitle?: string
129
+ /** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
130
+ * treated as missing. Necessary for wide-range data (money, audience
131
+ * size, scientific). Default `'linear'`. */
132
+ yScale?: 'linear' | 'log'
133
+ /** Right (secondary) Y-axis scale. Default `'linear'`. */
134
+ y2Scale?: 'linear' | 'log'
135
+ /** Pinned text labels at fixed data-space positions (callouts). */
136
+ annotations?: ChartAnnotation[]
137
+ /** When true, automatically cycle through pattern fills for every series
138
+ * that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
139
+ * fallback. Default false. */
140
+ patternFallback?: boolean
141
+ /** Calendar heatmap: array of date+value samples (one per day). Date
142
+ * strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
143
+ calendarValues?: Array<{ date: string; value: number }>
144
+ /** Calendar heatmap: year window. Default: span the data. */
145
+ calendarStart?: string
146
+ calendarEnd?: string
147
+
148
+ /** Gauge: the value to display. */
149
+ gaugeValue?: number
150
+ /** Gauge: min/max of the dial scale. Defaults [0, 100]. */
151
+ gaugeMin?: number
152
+ gaugeMax?: number
153
+ /** Gauge: target marker (the line/notch on the arc). */
154
+ gaugeTarget?: number
155
+ /** Gauge: color bands along the arc (e.g. red/amber/green). */
156
+ gaugeRanges?: Array<{ from: number; to: number; color: string }>
157
+ /** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
158
+ gaugeUnit?: string
159
+
160
+ /** Tree-map: hierarchical root. Leaves have `value`; parents are the
161
+ * sum of their children's totals. */
162
+ treemap?: TreeNode
163
+
164
+ /** Sankey: nodes + flow links between them. Link `source` / `target`
165
+ * reference node ids. */
166
+ sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
167
+ sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
168
+
169
+ /** Waterfall: per-category flag marking bars as totals/subtotals that
170
+ * reset the running sum and span from 0. Same length as `categories`. */
171
+ waterfallTotals?: boolean[]
172
+ /** Waterfall: explicit colors for positive/negative/total bars. The
173
+ * series color is ignored when this is set. */
174
+ waterfallColors?: { positive?: string; negative?: string; total?: string }
175
+ /** Heatmap color scale. `'sequential'` maps min->max through one hue,
176
+ * `'diverging'` runs cold->neutral->warm around 0. A custom array
177
+ * (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
178
+ colorScale?: 'sequential' | 'diverging' | string[]
179
+ }
180
+
181
+ /** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
182
+ export type TreeNode = {
183
+ name: string
184
+ value?: number
185
+ color?: string
186
+ children?: TreeNode[]
187
+ }
188
+
189
+ /** A laid-out tree-map rectangle. */
190
+ export type ChartTreemapCell = {
191
+ x: number
192
+ y: number
193
+ w: number
194
+ h: number
195
+ color: string
196
+ textColor: string
197
+ name: string
198
+ value: number
199
+ /** Depth from the root - useful for color cycling per level. */
200
+ depth: number
201
+ }
202
+
203
+ /** A calendar-heatmap cell (one day). */
204
+ export type ChartCalendarCell = {
205
+ x: number
206
+ y: number
207
+ size: number
208
+ date: string
209
+ value: number
210
+ /** Defined when a value was supplied for this day; blank otherwise. */
211
+ defined: boolean
212
+ color: string
213
+ }
214
+
215
+ /** A gauge dial layout. */
216
+ export type ChartGaugeLayout = {
217
+ cx: number
218
+ cy: number
219
+ r: number
220
+ /** Track arc path (background grey). */
221
+ trackPath: string
222
+ /** Value arc path (filled to the current value). */
223
+ valuePath: string
224
+ /** Optional colored range arcs. */
225
+ rangePaths: Array<{ path: string; color: string; from: number; to: number }>
226
+ /** Pixel position of the target marker (when set). */
227
+ target: { x1: number; y1: number; x2: number; y2: number } | null
228
+ /** Tick marks around the dial (major ticks are longer). */
229
+ ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
230
+ /** Pointer needle (a kite shape) + its center hub radius. */
231
+ needle: { path: string; hubR: number }
232
+ /** Status color of the value arc (the band the value falls in), or null to
233
+ * fall back to the theme accent. */
234
+ valueColor: string | null
235
+ /** Scale end labels positioned under the two arc ends. */
236
+ minLabel: { x: number; y: number }
237
+ maxLabel: { x: number; y: number }
238
+ value: number
239
+ min: number
240
+ max: number
241
+ unit: string
242
+ }
243
+
244
+ /** A sankey node + its laid-out rect + total flow. */
245
+ export type ChartSankeyNode = {
246
+ id: string
247
+ label: string
248
+ color: string
249
+ x: number
250
+ y: number
251
+ w: number
252
+ h: number
253
+ /** Column (depth) the node was assigned to. */
254
+ column: number
255
+ totalIn: number
256
+ totalOut: number
257
+ }
258
+
259
+ /** A sankey link rendered as a curved ribbon. */
260
+ export type ChartSankeyLink = {
261
+ path: string
262
+ color: string
263
+ /** Stroke width = link value scaled to pixels. */
264
+ width: number
265
+ source: string
266
+ target: string
267
+ value: number
268
+ }
269
+
270
+ /** A single funnel segment (trapezoid) in pixel space. */
271
+ export type ChartFunnelSegment = {
272
+ /** Pre-built SVG path for the trapezoid. */
273
+ path: string
274
+ color: string
275
+ label: string
276
+ /** Original value (before any percentile normalisation). */
277
+ value: number
278
+ /** Conversion vs. first segment, 0..1. */
279
+ conversion: number
280
+ /** Drop-off from the previous segment, 0..1. */
281
+ dropoff: number
282
+ /** Centre point (label anchor). */
283
+ cx: number
284
+ cy: number
285
+ /** Auto-picked black/white contrast color for in-segment labels. */
286
+ textColor: string
287
+ }
288
+
289
+ /** A radar series' polygon: axis values + the closed polygon path. */
290
+ export type ChartRadarSeries = {
291
+ label: string
292
+ color: string
293
+ path: string
294
+ /** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
295
+ points: Array<{ x: number; y: number; value: number; axis: string }>
296
+ }
297
+
298
+ /** Radar axis spoke + tick info. */
299
+ export type ChartRadarAxis = {
300
+ label: string
301
+ /** Outermost endpoint of the spoke. */
302
+ x: number
303
+ y: number
304
+ }
305
+
306
+ /** A single heatmap rectangle in pixel space. */
307
+ export type ChartHeatmapCell = {
308
+ x: number
309
+ y: number
310
+ w: number
311
+ h: number
312
+ color: string
313
+ /** Text color picked for contrast against `color`. */
314
+ textColor: string
315
+ value: number
316
+ rowLabel: string
317
+ colLabel: string
70
318
  }
71
319
 
72
320
  export type ChartBar = {
@@ -95,6 +343,9 @@ export type ChartLine = {
95
343
  color: string
96
344
  label: string
97
345
  points: ChartLinePoint[]
346
+ /** Confidence-band path (between upperValues + lowerValues) for this
347
+ * series, when both arrays are supplied. Empty otherwise. */
348
+ bandPath?: string
98
349
  }
99
350
  export type ChartPieSlice = {
100
351
  path: string
@@ -153,6 +404,43 @@ export type ChartGeometry = {
153
404
  catTicks: ChartAxisTick[]
154
405
  /** Horizontal bars: vertical reference / target lines (positioned by x). */
155
406
  referenceLinesV: ChartRefLineGeoV[]
407
+ /** Trend / moving-average overlay lines (parallel to `lines`). Drawn
408
+ * dashed on top of their source series. */
409
+ overlays: ChartLine[]
410
+ /** Pinned annotation labels with pre-resolved screen coordinates. */
411
+ annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
412
+ /** Heatmap cells (type === 'heatmap'). */
413
+ heatmapCells: ChartHeatmapCell[]
414
+ /** Heatmap row labels with pre-resolved y positions (left gutter). */
415
+ heatmapRowTicks: ChartAxisTick[]
416
+ /** Heatmap column labels (bottom of plot). */
417
+ heatmapColTicks: ChartCategoryTick[]
418
+ /** Heatmap color-scale legend: ordered stops with value + color. */
419
+ heatmapLegend: Array<{ value: number; color: string; label: string }>
420
+ /** Funnel segments (type === 'funnel'). */
421
+ funnelSegments: ChartFunnelSegment[]
422
+ /** Radar concentric grid rings (centred at `radarCenter`). */
423
+ radarRings: number[]
424
+ /** Radar axis labels + spoke endpoints. */
425
+ radarAxes: ChartRadarAxis[]
426
+ /** Radar series polygons. */
427
+ radarSeries: ChartRadarSeries[]
428
+ /** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
429
+ radarCenter: { cx: number; cy: number; r: number } | null
430
+ /** Tree-map cells (type === 'treemap'). */
431
+ treemapCells: ChartTreemapCell[]
432
+ /** Calendar heatmap (type === 'calendar'). */
433
+ calendarCells: ChartCalendarCell[]
434
+ /** Calendar month labels along the top. */
435
+ calendarMonthTicks: ChartCategoryTick[]
436
+ /** Calendar legend stops (sequential ramp). */
437
+ calendarLegend: Array<{ value: number; color: string; label: string }>
438
+ /** Gauge layout (type === 'gauge'). Null when not a gauge. */
439
+ gauge: ChartGaugeLayout | null
440
+ /** Sankey nodes (type === 'sankey'). */
441
+ sankeyNodes: ChartSankeyNode[]
442
+ /** Sankey links (type === 'sankey'). */
443
+ sankeyLinks: ChartSankeyLink[]
156
444
  }
157
445
 
158
446
  export const DEFAULT_PALETTE = [
@@ -182,6 +470,236 @@ function niceNum(range: number, roundIt: boolean): number {
182
470
 
183
471
  export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
184
472
 
473
+ // ---- Color helpers for heatmap / pattern fills ----------------------
474
+
475
+ /** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
476
+ const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
477
+ /** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
478
+ const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
479
+ /** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
480
+ * above the dark grid surface instead of near-white, so empty / low cells read
481
+ * as "cold" rather than as glaring white rectangles. */
482
+ const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
483
+ const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
484
+
485
+ function resolveColorScale(
486
+ scale: 'sequential' | 'diverging' | string[] | undefined,
487
+ vMin: number,
488
+ vMax: number,
489
+ theme: 'light' | 'dark' = 'light',
490
+ ): string[] {
491
+ if (Array.isArray(scale) && scale.length >= 2) return scale
492
+ const dark = theme === 'dark'
493
+ if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
494
+ return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
495
+ }
496
+ return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
497
+ }
498
+
499
+ /** Sample a hex color from an array of hex stops at fractional position t.
500
+ * Linearly interpolates between the two nearest stops in RGB space. */
501
+ export function sampleGradient(stops: string[], t: number): string {
502
+ if (!stops.length) return '#888'
503
+ const clamped = Math.max(0, Math.min(1, t))
504
+ if (stops.length === 1) return stops[0]!
505
+ const pos = clamped * (stops.length - 1)
506
+ const i = Math.floor(pos)
507
+ const frac = pos - i
508
+ const a = hexToRgb(stops[i]!)
509
+ const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
510
+ if (!a || !b) return stops[i] ?? '#888'
511
+ const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
512
+ const toHex = (n: number) => n.toString(16).padStart(2, '0')
513
+ return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
514
+ }
515
+
516
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
517
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
518
+ if (!m) return null
519
+ const n = parseInt(m[1]!, 16)
520
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
521
+ }
522
+
523
+ /** Pick a black or white text color that has the better contrast against
524
+ * the given background. Uses the WCAG relative-luminance heuristic. */
525
+ export function pickContrastText(bgHex: string): string {
526
+ const rgb = hexToRgb(bgHex)
527
+ if (!rgb) return '#0f172a'
528
+ const lin = (c: number) => {
529
+ const s = c / 255
530
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
531
+ }
532
+ const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
533
+ return L > 0.5 ? '#0f172a' : '#ffffff'
534
+ }
535
+
536
+ /** Pick the largest power of 10 that fits at the bottom of [min,max], and
537
+ * the smallest that covers the top, then enumerate decade boundaries. Used
538
+ * by log-scale axes (yScale: 'log'). */
539
+ export function niceLogScale(min: number, max: number): NiceScale {
540
+ // Only positive values are plottable on a log scale; callers should
541
+ // strip non-positive values before passing them in.
542
+ if (!Number.isFinite(min) || min <= 0) min = 1
543
+ if (!Number.isFinite(max) || max <= min) max = min * 10
544
+ const lo = Math.floor(Math.log10(min))
545
+ const hi = Math.ceil(Math.log10(max))
546
+ const ticks: number[] = []
547
+ for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
548
+ return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
549
+ }
550
+
551
+ /** Map a value to a fractional position [0..1] across the axis domain.
552
+ * Pass the appropriate fn into projection code so linear / log share the
553
+ * same plumbing. Returns null for non-positive values on log. */
554
+ function project(value: number, min: number, max: number, isLog: boolean): number | null {
555
+ if (!Number.isFinite(value)) return null
556
+ if (isLog) {
557
+ if (value <= 0 || min <= 0) return null
558
+ return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
559
+ }
560
+ return (value - min) / (max - min)
561
+ }
562
+
563
+ // ---- Overlay math: trendline + moving averages -----------------------
564
+
565
+ /** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
566
+ * Returns the fitted value at each x index, or NaN where the source value
567
+ * was non-finite. */
568
+ /** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
569
+ * monotone cubic interpolation (preserves local extrema - no overshoots).
570
+ * Breaks the path at `defined === false` gaps. */
571
+ export function buildLinePath(
572
+ pts: Array<{ x: number; y: number; defined: boolean }>,
573
+ smooth: boolean,
574
+ ): string {
575
+ if (!smooth) {
576
+ let path = ''
577
+ let pen = false
578
+ for (const p of pts) {
579
+ if (!p.defined) { pen = false; continue }
580
+ path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
581
+ pen = true
582
+ }
583
+ return path.trim()
584
+ }
585
+ // Group defined-only runs; each run is smoothed independently.
586
+ const runs: Array<Array<{ x: number; y: number }>> = []
587
+ let cur: Array<{ x: number; y: number }> = []
588
+ for (const p of pts) {
589
+ if (p.defined) cur.push({ x: p.x, y: p.y })
590
+ else if (cur.length) { runs.push(cur); cur = [] }
591
+ }
592
+ if (cur.length) runs.push(cur)
593
+ return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
594
+ }
595
+
596
+ /** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
597
+ * Slope at each point chosen so the curve passes through every (xi, yi)
598
+ * AND stays monotonic between them; control points sit 1/3 of the way
599
+ * to the neighbours along that tangent. */
600
+ function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
601
+ const n = pts.length
602
+ if (n === 0) return ''
603
+ if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
604
+ if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
605
+ // Secant slopes between adjacent points.
606
+ const dx: number[] = new Array(n - 1)
607
+ const m: number[] = new Array(n - 1)
608
+ for (let i = 0; i < n - 1; i += 1) {
609
+ const d = pts[i + 1]!.x - pts[i]!.x
610
+ dx[i] = d
611
+ m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
612
+ }
613
+ // Tangent at each point: average of neighbouring slopes, with sign
614
+ // checks that flatten the tangent when slopes change sign.
615
+ const tan: number[] = new Array(n)
616
+ tan[0] = m[0]!
617
+ tan[n - 1] = m[n - 2]!
618
+ for (let i = 1; i < n - 1; i += 1) {
619
+ if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
620
+ else tan[i] = (m[i - 1]! + m[i]!) / 2
621
+ }
622
+ // Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
623
+ for (let i = 0; i < n - 1; i += 1) {
624
+ if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
625
+ const a = tan[i]! / m[i]!
626
+ const b = tan[i + 1]! / m[i]!
627
+ const h = Math.hypot(a, b)
628
+ if (h > 3) {
629
+ tan[i] = (3 / h) * a * m[i]!
630
+ tan[i + 1] = (3 / h) * b * m[i]!
631
+ }
632
+ }
633
+ // Build the Bezier path. Each segment: control points at 1/3 of dx.
634
+ let path = `M${pts[0]!.x},${pts[0]!.y}`
635
+ for (let i = 0; i < n - 1; i += 1) {
636
+ const h = dx[i]!
637
+ const c1x = pts[i]!.x + h / 3
638
+ const c1y = pts[i]!.y + (tan[i]! * h) / 3
639
+ const c2x = pts[i + 1]!.x - h / 3
640
+ const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
641
+ path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
642
+ }
643
+ return path
644
+ }
645
+
646
+ export function linearTrend(values: number[]): number[] {
647
+ let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
648
+ for (let i = 0; i < values.length; i += 1) {
649
+ const y = values[i]!
650
+ if (!Number.isFinite(y)) continue
651
+ n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
652
+ }
653
+ if (n < 2) return values.map(() => NaN)
654
+ const denom = n * sumXX - sumX * sumX
655
+ if (denom === 0) return values.map(() => sumY / n)
656
+ const slope = (n * sumXY - sumX * sumY) / denom
657
+ const intercept = (sumY - slope * sumX) / n
658
+ return values.map((_, i) => slope * i + intercept)
659
+ }
660
+
661
+ /** Simple moving average over a window of `period` values. Window centres
662
+ * trail to the right (typical for time-series). NaN for points before the
663
+ * window is full. */
664
+ export function simpleMovingAverage(values: number[], period: number): number[] {
665
+ if (period < 1) return values.slice()
666
+ const out: number[] = new Array(values.length).fill(NaN)
667
+ let sum = 0, count = 0
668
+ for (let i = 0; i < values.length; i += 1) {
669
+ const v = values[i]!
670
+ if (Number.isFinite(v)) { sum += v; count += 1 }
671
+ if (i >= period) {
672
+ const drop = values[i - period]!
673
+ if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
674
+ }
675
+ if (i >= period - 1 && count > 0) out[i] = sum / count
676
+ }
677
+ return out
678
+ }
679
+
680
+ /** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
681
+ export function exponentialMovingAverage(values: number[], period: number): number[] {
682
+ const alpha = 2 / (Math.max(1, period) + 1)
683
+ const out: number[] = new Array(values.length).fill(NaN)
684
+ let prev: number | null = null
685
+ for (let i = 0; i < values.length; i += 1) {
686
+ const v = values[i]!
687
+ if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
688
+ prev = prev == null ? v : alpha * v + (1 - alpha) * prev
689
+ out[i] = prev
690
+ }
691
+ return out
692
+ }
693
+
694
+ /** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
695
+ export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
696
+ if (spec === 'linear') return linearTrend(values)
697
+ const m = /^(sma|ema):(\d+)$/.exec(spec)
698
+ if (!m) return values.map(() => NaN)
699
+ const period = Number(m[2])
700
+ return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
701
+ }
702
+
185
703
  /** Round a [min,max] domain out to nice tick boundaries. */
186
704
  export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
187
705
  if (!Number.isFinite(min) || !Number.isFinite(max)) {
@@ -236,17 +754,21 @@ type ResolvedSeries = ChartSeries & {
236
754
  axis: 'left' | 'right'
237
755
  }
238
756
 
239
- /** Data domain for one axis, honoring stacking of its bar/area series. */
757
+ /** Data domain for one axis, honoring stacking of its bar/area series.
758
+ * When `isLog` is true, non-positive values are discarded (log undefined)
759
+ * and the domain is rounded to decade boundaries instead of nice steps. */
240
760
  function axisDomain(
241
761
  list: ResolvedSeries[],
242
762
  categories: string[],
243
763
  stacked: boolean,
244
764
  extra: number[] = [],
765
+ isLog = false,
245
766
  ): NiceScale {
246
767
  let dMin = Infinity
247
768
  let dMax = -Infinity
248
769
  const note = (v: number) => {
249
770
  if (!Number.isFinite(v)) return
771
+ if (isLog && v <= 0) return
250
772
  if (v < dMin) dMin = v
251
773
  if (v > dMax) dMax = v
252
774
  }
@@ -270,18 +792,19 @@ function axisDomain(
270
792
  }
271
793
  for (const s of lines) for (const v of s.values) note(v)
272
794
  if (dMin === Infinity) {
273
- dMin = 0
274
- dMax = 1
795
+ dMin = isLog ? 1 : 0
796
+ dMax = isLog ? 10 : 1
275
797
  }
276
- // Bar / area charts read against a zero baseline, so always include 0.
277
- if (stackable.length) {
798
+ // Bar / area charts read against a zero baseline, so always include 0
799
+ // - but only on linear axes (0 is invalid in log).
800
+ if (stackable.length && !isLog) {
278
801
  dMin = Math.min(dMin, 0)
279
802
  dMax = Math.max(dMax, 0)
280
803
  }
281
- return niceScale(dMin, dMax)
804
+ return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
282
805
  }
283
806
 
284
- export function buildChart(spec: ChartSpec): ChartGeometry {
807
+ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
285
808
  const width = spec.width ?? 520
286
809
  const height = spec.height ?? 300
287
810
  const palette = spec.palette ?? DEFAULT_PALETTE
@@ -319,6 +842,660 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
319
842
  valueTicks: [],
320
843
  catTicks: [],
321
844
  referenceLinesV: [],
845
+ overlays: [],
846
+ annotations: [],
847
+ heatmapCells: [],
848
+ heatmapRowTicks: [],
849
+ heatmapColTicks: [],
850
+ heatmapLegend: [],
851
+ funnelSegments: [],
852
+ radarRings: [],
853
+ radarAxes: [],
854
+ radarSeries: [],
855
+ radarCenter: null,
856
+ treemapCells: [],
857
+ calendarCells: [],
858
+ calendarMonthTicks: [],
859
+ calendarLegend: [],
860
+ gauge: null,
861
+ sankeyNodes: [],
862
+ sankeyLinks: [],
863
+ }
864
+
865
+ // ---- Waterfall ----------------------------------------------------
866
+ // First series provides the values. Each non-total bar starts at the
867
+ // running cumulative sum; total bars (waterfallTotals[i]) reset and
868
+ // span from 0 to that sum. Color is derived from sign + total flag, with
869
+ // optional palette overrides via spec.waterfallColors.
870
+ if (spec.type === 'waterfall') {
871
+ const src = series[0]
872
+ if (!src) return { ...empty }
873
+ const colors = spec.waterfallColors ?? {}
874
+ const positive = colors.positive ?? '#16a34a'
875
+ const negative = colors.negative ?? '#ef4444'
876
+ const total = colors.total ?? '#475569'
877
+
878
+ const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
879
+ const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
880
+ const padL = 48 + (spec.yAxisTitle ? 16 : 0)
881
+ const padR = 12
882
+ const padT = 10
883
+ const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
884
+ const plotW = Math.max(1, width - padL - padR)
885
+ const plotH = Math.max(1, height - padT - padB)
886
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
887
+
888
+ // Compute the running cumulative + per-bar (from, to) pairs.
889
+ const totals = spec.waterfallTotals ?? []
890
+ const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
891
+ let cum = 0
892
+ spec.categories.forEach((_, i) => {
893
+ const v = src.values[i] ?? 0
894
+ const isTotal = !!totals[i]
895
+ if (isTotal) {
896
+ pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
897
+ } else {
898
+ pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
899
+ cum += v
900
+ }
901
+ })
902
+ // Y-axis domain spans every visited level (including 0).
903
+ let dMin = 0, dMax = 0
904
+ for (const p of pairs) {
905
+ if (p.from < dMin) dMin = p.from
906
+ if (p.to < dMin) dMin = p.to
907
+ if (p.from > dMax) dMax = p.from
908
+ if (p.to > dMax) dMax = p.to
909
+ }
910
+ const dom = niceScale(dMin, dMax)
911
+ const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
912
+
913
+ const slotW = plotW / Math.max(1, spec.categories.length)
914
+ const barPad = slotW * 0.2
915
+ const barW = Math.max(1, slotW - barPad)
916
+ const bars: ChartBar[] = pairs.map((p, i) => {
917
+ const yTop = yOfW(Math.max(p.from, p.to))
918
+ const yBot = yOfW(Math.min(p.from, p.to))
919
+ const x = padL + slotW * i + barPad / 2
920
+ const color = p.isTotal ? total : p.value >= 0 ? positive : negative
921
+ return {
922
+ x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
923
+ color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
924
+ }
925
+ })
926
+ // Thin connector lines between bar tops -> running total reads cleanly.
927
+ const connectors: ChartLine[] = [{
928
+ path: pairs
929
+ .map((p, i) => {
930
+ const x0 = padL + slotW * i + barPad / 2 + barW
931
+ const y = yOfW(p.to)
932
+ const x1 = padL + slotW * (i + 1) + barPad / 2
933
+ // Skip the final connector beyond the last bar.
934
+ return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
935
+ })
936
+ .filter(Boolean)
937
+ .join(' '),
938
+ areaPath: '',
939
+ color: 'var(--sg-muted, #94a3b8)',
940
+ label: '',
941
+ points: [],
942
+ }]
943
+ const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
944
+ label,
945
+ x: round(padL + slotW * i + slotW / 2),
946
+ }))
947
+ const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
948
+ value, y: yOfW(value), label: fmtTick(value),
949
+ }))
950
+ return {
951
+ ...empty,
952
+ plot,
953
+ bars,
954
+ lines: connectors,
955
+ yTicks,
956
+ xTicks,
957
+ xLabelRotated,
958
+ }
959
+ }
960
+
961
+ // ---- Funnel -------------------------------------------------------
962
+ // One series of strictly-decreasing values gets rendered as a stack
963
+ // of horizontal trapezoids: each level's width is proportional to its
964
+ // value relative to the largest, slope automatically links level N+1
965
+ // narrower than level N. Labels show value, conversion vs. top, and
966
+ // step drop-off.
967
+ if (spec.type === 'funnel') {
968
+ const src = series[0]
969
+ if (!src || !src.values.length) return { ...empty }
970
+ const padL = 20, padR = 20, padT = 16, padB = 16
971
+ const plotW = Math.max(1, width - padL - padR)
972
+ const plotH = Math.max(1, height - padT - padB)
973
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
974
+ const n = src.values.length
975
+ const stepH = plotH / n
976
+ const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
977
+ const top = src.values[0] ?? 0
978
+ const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
979
+ const cx = padL + plotW / 2
980
+ const palette = spec.palette ?? DEFAULT_PALETTE
981
+ const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
982
+ const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
983
+ const w0 = widthAt(v)
984
+ const w1 = widthAt(next)
985
+ const y0 = padT + stepH * i
986
+ const y1 = y0 + stepH
987
+ const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
988
+ const color = src.color ?? palette[i % palette.length]!
989
+ return {
990
+ path, color,
991
+ label: spec.categories[i] ?? src.label,
992
+ value: v,
993
+ conversion: top > 0 ? v / top : 0,
994
+ dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
995
+ cx,
996
+ cy: (y0 + y1) / 2,
997
+ textColor: pickContrastText(color),
998
+ }
999
+ })
1000
+ return {
1001
+ ...empty,
1002
+ plot,
1003
+ funnelSegments: segments,
1004
+ }
1005
+ }
1006
+
1007
+ // ---- Radar --------------------------------------------------------
1008
+ // Polar coordinates: each `category` is a spoke (axis); each `series`
1009
+ // contributes a polygon connecting its values across the spokes. All
1010
+ // series share the same scale (max across every value). Concentric
1011
+ // ring count derived from data, capped at 5 for legibility.
1012
+ if (spec.type === 'radar') {
1013
+ if (!series.length || !spec.categories.length) return { ...empty }
1014
+ const padL = 30, padR = 30, padT = 24, padB = 24
1015
+ const plotW = Math.max(1, width - padL - padR)
1016
+ const plotH = Math.max(1, height - padT - padB)
1017
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1018
+ const cx = padL + plotW / 2
1019
+ const cy = padT + plotH / 2
1020
+ const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
1021
+ const axes = spec.categories
1022
+ const k = axes.length
1023
+ let vMax = 0
1024
+ for (const s of series) for (const v of s.values) {
1025
+ if (Number.isFinite(v) && v > vMax) vMax = v
1026
+ }
1027
+ if (vMax === 0) vMax = 1
1028
+ const ringCount = 5
1029
+ const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
1030
+ /** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
1031
+ * proceed clockwise so axes lay out left-to-right when k <= 4. */
1032
+ const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
1033
+ const pointAt = (i: number, v: number) => {
1034
+ const t = v / vMax
1035
+ const a = angleAt(i)
1036
+ return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
1037
+ }
1038
+ const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
1039
+ const p = pointAt(i, vMax)
1040
+ return { label, x: p.x, y: p.y }
1041
+ })
1042
+ const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
1043
+ const pts = s.values.map((v, i) => {
1044
+ const safe = Number.isFinite(v) ? v : 0
1045
+ const p = pointAt(i, safe)
1046
+ return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
1047
+ })
1048
+ const path = pts.length
1049
+ ? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
1050
+ : ''
1051
+ const palette = spec.palette ?? DEFAULT_PALETTE
1052
+ return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
1053
+ })
1054
+ return {
1055
+ ...empty,
1056
+ plot,
1057
+ radarRings: ringValues,
1058
+ radarAxes,
1059
+ radarSeries: radarSeriesGeo,
1060
+ radarCenter: { cx, cy, r },
1061
+ }
1062
+ }
1063
+
1064
+ // ---- Calendar heatmap --------------------------------------------
1065
+ // GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
1066
+ // cell is a small square shaded by `calendarValues[i].value` via the
1067
+ // sequential color scale. Days with no value render blank (border only)
1068
+ // so missing data is visually obvious.
1069
+ if (spec.type === 'calendar') {
1070
+ const values = spec.calendarValues ?? []
1071
+ if (!values.length && !spec.calendarStart) return { ...empty }
1072
+ // Build a value lookup + figure out the date range.
1073
+ const valueByDate = new Map<string, number>()
1074
+ let vMin = Infinity, vMax = -Infinity
1075
+ for (const v of values) {
1076
+ valueByDate.set(v.date, v.value)
1077
+ if (Number.isFinite(v.value)) {
1078
+ if (v.value < vMin) vMin = v.value
1079
+ if (v.value > vMax) vMax = v.value
1080
+ }
1081
+ }
1082
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
1083
+ if (vMin === vMax) vMax = vMin + 1
1084
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1085
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1086
+ // Determine date range. If calendarStart/End set, use them, otherwise
1087
+ // span the data + round to whole weeks (Sun..Sat).
1088
+ const sorted = values.map((v) => v.date).sort()
1089
+ const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
1090
+ const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
1091
+ const start = new Date(startStr + 'T00:00:00Z')
1092
+ const end = new Date(endStr + 'T00:00:00Z')
1093
+ // Roll start back to its Sunday, end forward to its Saturday.
1094
+ start.setUTCDate(start.getUTCDate() - start.getUTCDay())
1095
+ end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
1096
+ const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
1097
+ const weeks = Math.ceil(totalDays / 7)
1098
+ const padL = 36, padR = 80, padT = 26, padB = 16
1099
+ const plotW = Math.max(1, width - padL - padR)
1100
+ const plotH = Math.max(1, height - padT - padB)
1101
+ // Cell size: fit weeks across width, 7 rows down height.
1102
+ const cellW = Math.floor(plotW / weeks)
1103
+ const cellH = Math.floor(plotH / 7)
1104
+ const cellSize = Math.max(6, Math.min(cellW, cellH))
1105
+ const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
1106
+ const cells: ChartCalendarCell[] = []
1107
+ let lastMonth = -1
1108
+ const monthTicks: ChartCategoryTick[] = []
1109
+ for (let i = 0; i < totalDays; i += 1) {
1110
+ const day = new Date(start.getTime() + i * 86_400_000)
1111
+ const col = Math.floor(i / 7)
1112
+ const row = i % 7
1113
+ const date = day.toISOString().slice(0, 10)
1114
+ const has = valueByDate.has(date)
1115
+ const v = valueByDate.get(date) ?? 0
1116
+ cells.push({
1117
+ x: padL + col * cellSize,
1118
+ y: padT + row * cellSize,
1119
+ size: cellSize,
1120
+ date, value: v,
1121
+ defined: has,
1122
+ color: has ? colorAt(v) : 'transparent',
1123
+ })
1124
+ if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
1125
+ lastMonth = day.getUTCMonth()
1126
+ monthTicks.push({
1127
+ label: day.toLocaleDateString(undefined, { month: 'short' }),
1128
+ x: padL + col * cellSize,
1129
+ })
1130
+ }
1131
+ }
1132
+ const legend = Array.from({ length: 5 }, (_, i) => {
1133
+ const t = i / 4
1134
+ const value = vMin + (vMax - vMin) * t
1135
+ return { value, color: colorAt(value), label: fmtTick(value) }
1136
+ })
1137
+ return {
1138
+ ...empty,
1139
+ plot,
1140
+ calendarCells: cells,
1141
+ calendarMonthTicks: monthTicks,
1142
+ calendarLegend: legend,
1143
+ }
1144
+ }
1145
+
1146
+ // ---- Gauge --------------------------------------------------------
1147
+ // Semicircle dial: track arc + value arc + optional colored range bands
1148
+ // + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
1149
+ if (spec.type === 'gauge') {
1150
+ const min = spec.gaugeMin ?? 0
1151
+ const max = spec.gaugeMax ?? 100
1152
+ const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
1153
+ const target = spec.gaugeTarget
1154
+ const cx = width / 2
1155
+ const cy = height * 0.78
1156
+ const r = Math.min(width * 0.42, height * 0.65)
1157
+ // Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
1158
+ const A0 = Math.PI
1159
+ const A1 = 2 * Math.PI
1160
+ const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
1161
+ const arc = (a0: number, a1: number, radius: number): string => {
1162
+ const x1 = cx + radius * Math.cos(a0)
1163
+ const y1 = cy + radius * Math.sin(a0)
1164
+ const x2 = cx + radius * Math.cos(a1)
1165
+ const y2 = cy + radius * Math.sin(a1)
1166
+ const large = a1 - a0 > Math.PI ? 1 : 0
1167
+ return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
1168
+ }
1169
+ const trackPath = arc(A0, A1, r)
1170
+ const valuePath = arc(A0, angleAt(value), r)
1171
+ const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
1172
+ path: arc(angleAt(band.from), angleAt(band.to), r - 9),
1173
+ color: band.color, from: band.from, to: band.to,
1174
+ }))
1175
+ let targetPx: ChartGaugeLayout['target'] = null
1176
+ if (target != null && Number.isFinite(target)) {
1177
+ const a = angleAt(Math.max(min, Math.min(max, target)))
1178
+ const inner = r - 12
1179
+ const outer = r + 4
1180
+ targetPx = {
1181
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1182
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1183
+ }
1184
+ }
1185
+ // Tick marks just outside the track: a major tick every 1/4 of the scale,
1186
+ // with 4 minor ticks between each. Gives the dial a measured, instrument feel.
1187
+ const ticks: ChartGaugeLayout['ticks'] = []
1188
+ const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
1189
+ for (let i = 0; i <= TICK_TOTAL; i++) {
1190
+ const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
1191
+ const major = i % TICK_MINOR === 0
1192
+ const inner = r + 9
1193
+ const outer = r + (major ? 17 : 13)
1194
+ ticks.push({
1195
+ x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
1196
+ x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
1197
+ major,
1198
+ })
1199
+ }
1200
+ // Pointer needle: a kite (long tip toward the value, short counterweight
1201
+ // tail) pivoting on a center hub.
1202
+ const aV = angleAt(value)
1203
+ const tipR = r - 16, tailR = 18, baseR = 6
1204
+ const aPerp = aV + Math.PI / 2
1205
+ const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
1206
+ const needlePath =
1207
+ `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
1208
+ // Color the value arc by the band the value currently sits in.
1209
+ let valueColor: string | null = null
1210
+ for (const band of spec.gaugeRanges ?? []) {
1211
+ if (value >= band.from && value <= band.to) valueColor = band.color
1212
+ }
1213
+ return {
1214
+ ...empty,
1215
+ plot: { x: 0, y: 0, w: width, h: height },
1216
+ gauge: {
1217
+ cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
1218
+ ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
1219
+ minLabel: { x: cx - r, y: cy + 20 },
1220
+ maxLabel: { x: cx + r, y: cy + 20 },
1221
+ value, min, max, unit: spec.gaugeUnit ?? '',
1222
+ },
1223
+ }
1224
+ }
1225
+
1226
+ // ---- Tree-map -----------------------------------------------------
1227
+ // Squarified tree-map (Bruls et al. 2000): each level recursively
1228
+ // partitions its rectangle in proportion to its children, picking the
1229
+ // split orientation that keeps aspect ratios closest to 1.
1230
+ if (spec.type === 'treemap') {
1231
+ const root = spec.treemap
1232
+ if (!root) return { ...empty }
1233
+ const padL = 4, padR = 4, padT = 4, padB = 4
1234
+ const plotW = Math.max(1, width - padL - padR)
1235
+ const plotH = Math.max(1, height - padT - padB)
1236
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1237
+ const palette = spec.palette ?? DEFAULT_PALETTE
1238
+ const cells: ChartTreemapCell[] = []
1239
+ function totalOf(n: TreeNode): number {
1240
+ if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
1241
+ return Math.max(0, n.value ?? 0)
1242
+ }
1243
+ function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
1244
+ if (!items.length || w <= 0 || h <= 0) return
1245
+ const totals = items.map(totalOf)
1246
+ const sum = totals.reduce((a, b) => a + b, 0)
1247
+ if (sum <= 0) return
1248
+ // Process largest-first so big items dominate the first row.
1249
+ const ordered = items
1250
+ .map((n, i) => ({ node: n, value: totals[i]! }))
1251
+ .sort((a, b) => b.value - a.value)
1252
+ let cx = x, cy = y, cw = w, ch = h, remaining = sum
1253
+ let row: typeof ordered = []
1254
+ const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
1255
+ if (rowSum <= 0) return Infinity
1256
+ const rowArea = rowSum * scale
1257
+ const rowSide = rowArea / shortSide
1258
+ let worst = 0
1259
+ for (const v of vals) {
1260
+ const cell = v * scale
1261
+ const long = cell / rowSide
1262
+ const r = Math.max(shortSide / long, long / shortSide)
1263
+ if (r > worst) worst = r
1264
+ }
1265
+ return worst
1266
+ }
1267
+ function flushRow() {
1268
+ if (!row.length) return
1269
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
1270
+ const scale = (cw * ch) / remaining
1271
+ const horizontal = cw >= ch
1272
+ const shortSide = horizontal ? ch : cw
1273
+ const rowSide = (rowSum * scale) / shortSide
1274
+ let offset = 0
1275
+ for (const it of row) {
1276
+ const cellSize = (it.value * scale) / rowSide
1277
+ const cx2 = horizontal ? cx : cx + offset
1278
+ const cy2 = horizontal ? cy + offset : cy
1279
+ const ww = horizontal ? rowSide : cellSize
1280
+ const hh = horizontal ? cellSize : rowSide
1281
+ const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
1282
+ // Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
1283
+ if (it.node.children?.length) {
1284
+ cells.push({
1285
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1286
+ color, textColor: pickContrastText(color),
1287
+ name: it.node.name, value: it.value, depth,
1288
+ })
1289
+ const labelH = Math.min(18, hh * 0.25)
1290
+ squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
1291
+ } else {
1292
+ cells.push({
1293
+ x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
1294
+ color, textColor: pickContrastText(color),
1295
+ name: it.node.name, value: it.value, depth,
1296
+ })
1297
+ }
1298
+ offset += cellSize
1299
+ }
1300
+ // Shrink the remaining strip.
1301
+ if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
1302
+ remaining -= rowSum
1303
+ row = []
1304
+ }
1305
+ for (const it of ordered) {
1306
+ const scale = (cw * ch) / remaining
1307
+ const shortSide = Math.min(cw, ch)
1308
+ const rowSum = row.reduce((a, b) => a + b.value, 0)
1309
+ const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
1310
+ const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
1311
+ if (row.length && nextWorst > currWorst) {
1312
+ flushRow()
1313
+ }
1314
+ row.push(it)
1315
+ }
1316
+ flushRow()
1317
+ }
1318
+ const seedItems = root.children ?? [root]
1319
+ squarify(seedItems, padL, padT, plotW, plotH, 0)
1320
+ return { ...empty, plot, treemapCells: cells }
1321
+ }
1322
+
1323
+ // ---- Sankey -------------------------------------------------------
1324
+ // Multi-column flow layout. Each node assigned to a column by longest
1325
+ // path from any source. Within a column, nodes are stacked vertically;
1326
+ // height proportional to max(totalIn, totalOut). Links render as
1327
+ // bezier ribbons whose width is the link value (in pixels).
1328
+ if (spec.type === 'sankey') {
1329
+ const nodes = spec.sankeyNodes ?? []
1330
+ const links = spec.sankeyLinks ?? []
1331
+ if (!nodes.length || !links.length) return { ...empty }
1332
+ const padL = 10, padR = 10, padT = 14, padB = 14
1333
+ const plotW = Math.max(1, width - padL - padR)
1334
+ const plotH = Math.max(1, height - padT - padB)
1335
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1336
+ const palette = spec.palette ?? DEFAULT_PALETTE
1337
+ const nodeById = new Map(nodes.map((n) => [n.id, n]))
1338
+ // Column = longest path from any node with no incoming edges.
1339
+ const targets = new Set(links.map((l) => l.target))
1340
+ const sources = nodes.filter((n) => !targets.has(n.id))
1341
+ const column = new Map<string, number>()
1342
+ function visit(id: string, depth: number, seen: Set<string>) {
1343
+ if (seen.has(id)) return
1344
+ seen.add(id)
1345
+ const cur = column.get(id) ?? 0
1346
+ if (depth > cur || !column.has(id)) column.set(id, depth)
1347
+ for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
1348
+ seen.delete(id)
1349
+ }
1350
+ for (const s of sources) visit(s.id, 0, new Set())
1351
+ // Cover any nodes with no path from a source (orphan rings).
1352
+ for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
1353
+ const maxCol = Math.max(...column.values())
1354
+ const cols = maxCol + 1
1355
+ const nodeW = 14
1356
+ const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
1357
+ // Totals per node.
1358
+ const totalIn = new Map<string, number>()
1359
+ const totalOut = new Map<string, number>()
1360
+ for (const l of links) {
1361
+ totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
1362
+ totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
1363
+ }
1364
+ // Per-column groups + max total in that column.
1365
+ const byCol: Map<number, string[]> = new Map()
1366
+ for (const n of nodes) {
1367
+ const c = column.get(n.id) ?? 0
1368
+ const arr = byCol.get(c) ?? []
1369
+ arr.push(n.id); byCol.set(c, arr)
1370
+ }
1371
+ // Per-column total height + node height scale.
1372
+ let maxColTotal = 0
1373
+ for (const ids of byCol.values()) {
1374
+ const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
1375
+ if (t > maxColTotal) maxColTotal = t
1376
+ }
1377
+ if (maxColTotal === 0) return { ...empty, plot }
1378
+ const nodeGapPx = 8
1379
+ const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
1380
+ const placed: ChartSankeyNode[] = []
1381
+ for (const [c, ids] of byCol) {
1382
+ const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
1383
+ const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
1384
+ let yCursor = padT + (plotH - totalH) / 2
1385
+ const xCol = padL + c * (nodeW + gapBetweenColumns)
1386
+ ids.forEach((id, idx) => {
1387
+ const node = nodeById.get(id)!
1388
+ const h = heights[idx]!
1389
+ placed.push({
1390
+ id,
1391
+ label: node.label ?? id,
1392
+ color: node.color ?? palette[(placed.length) % palette.length]!,
1393
+ x: xCol, y: yCursor, w: nodeW, h,
1394
+ column: c,
1395
+ totalIn: totalIn.get(id) ?? 0,
1396
+ totalOut: totalOut.get(id) ?? 0,
1397
+ })
1398
+ yCursor += h + nodeGapPx
1399
+ })
1400
+ }
1401
+ const placedById = new Map(placed.map((n) => [n.id, n]))
1402
+ // Per-node sub-cursor so multiple links from one node stack vertically.
1403
+ const inCursor = new Map<string, number>()
1404
+ const outCursor = new Map<string, number>()
1405
+ const builtLinks: ChartSankeyLink[] = []
1406
+ // Sort links so wider ribbons render first (so thin ribbons stack on top).
1407
+ const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
1408
+ for (const link of sortedLinks) {
1409
+ const a = placedById.get(link.source)
1410
+ const b = placedById.get(link.target)
1411
+ if (!a || !b) continue
1412
+ const linkH = Math.max(1, link.value * heightScale)
1413
+ const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
1414
+ const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
1415
+ outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
1416
+ inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
1417
+ const x0 = a.x + a.w
1418
+ const x1 = b.x
1419
+ const mid = (x0 + x1) / 2
1420
+ const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
1421
+ builtLinks.push({
1422
+ path, color: link.color ?? a.color, width: linkH,
1423
+ source: link.source, target: link.target, value: link.value,
1424
+ })
1425
+ }
1426
+ return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
1427
+ }
1428
+
1429
+ // ---- Heatmap ------------------------------------------------------
1430
+ // Each series is one row, series.values are the cells across categories.
1431
+ // Color comes from a sequential/diverging/custom palette mapped to the
1432
+ // global value range. Cell text contrasts black/white against the cell.
1433
+ if (spec.type === 'heatmap') {
1434
+ if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
1435
+ // Layout: left gutter for row labels, bottom for column labels.
1436
+ const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
1437
+ const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
1438
+ const padR = 64 // room for the right-side legend bar
1439
+ const padT = 12
1440
+ const padB = 32
1441
+ const plotW = Math.max(1, width - padL - padR)
1442
+ const plotH = Math.max(1, height - padT - padB)
1443
+ const plot = { x: padL, y: padT, w: plotW, h: plotH }
1444
+ const cellW = plotW / spec.categories.length
1445
+ const cellH = plotH / series.length
1446
+ // Resolve value range across the whole matrix.
1447
+ let vMin = Infinity, vMax = -Infinity
1448
+ for (const s of series) for (const v of s.values) {
1449
+ if (!Number.isFinite(v)) continue
1450
+ if (v < vMin) vMin = v
1451
+ if (v > vMax) vMax = v
1452
+ }
1453
+ if (vMin === Infinity) { vMin = 0; vMax = 1 }
1454
+ if (vMin === vMax) vMax = vMin + 1
1455
+ // Pick the palette stops.
1456
+ const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
1457
+ const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
1458
+ const heatmapCells: ChartHeatmapCell[] = []
1459
+ series.forEach((s, ri) => {
1460
+ s.values.forEach((v, ci) => {
1461
+ if (!Number.isFinite(v)) return
1462
+ const color = colorAt(v)
1463
+ heatmapCells.push({
1464
+ x: round(padL + cellW * ci),
1465
+ y: round(padT + cellH * ri),
1466
+ w: round(cellW),
1467
+ h: round(cellH),
1468
+ color,
1469
+ textColor: pickContrastText(color),
1470
+ value: v,
1471
+ rowLabel: s.label,
1472
+ colLabel: spec.categories[ci] ?? '',
1473
+ })
1474
+ })
1475
+ })
1476
+ const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
1477
+ value: i,
1478
+ y: round(padT + cellH * i + cellH / 2),
1479
+ label: s.label,
1480
+ }))
1481
+ const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
1482
+ label,
1483
+ x: round(padL + cellW * i + cellW / 2),
1484
+ }))
1485
+ // Legend: sample 5 stops across the range.
1486
+ const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1487
+ const t = i / 4
1488
+ const value = vMin + (vMax - vMin) * t
1489
+ return { value, color: colorAt(value), label: fmtTick(value) }
1490
+ })
1491
+ return {
1492
+ ...empty,
1493
+ plot,
1494
+ heatmapCells,
1495
+ heatmapRowTicks,
1496
+ heatmapColTicks,
1497
+ heatmapLegend,
1498
+ }
322
1499
  }
323
1500
 
324
1501
  if (spec.type === 'pie') {
@@ -569,20 +1746,28 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
569
1746
 
570
1747
  const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
571
1748
  const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
1749
+ const leftLog = spec.yScale === 'log'
1750
+ const rightLog = spec.y2Scale === 'log'
572
1751
  const leftDom = spec.stacked100
573
1752
  ? niceScale(0, 100)
574
- : axisDomain(leftSeries, spec.categories, stacked, refsLeft)
1753
+ : axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
575
1754
  const rightDom = hasRightAxis
576
1755
  ? spec.stacked100
577
1756
  ? niceScale(0, 100)
578
- : axisDomain(rightSeries, spec.categories, stacked, refsRight)
1757
+ : axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
579
1758
  : null
580
1759
 
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)
1760
+ /** Map a data value to a y pixel. Returns NaN for non-positive values on
1761
+ * a log axis so callers can drop the point (line gap / missing bar). */
1762
+ const yOf = (dom: NiceScale, v: number, isLog = false) => {
1763
+ const t = project(v, dom.min, dom.max, isLog)
1764
+ if (t === null) return NaN
1765
+ return round(padT + plotH - t * plotH)
1766
+ }
1767
+ const yLeft = (v: number) => yOf(leftDom, v, leftLog)
1768
+ const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
585
1769
  const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
1770
+ const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
586
1771
 
587
1772
  const n = spec.categories.length
588
1773
  const slot = plotW / Math.max(1, n)
@@ -662,11 +1847,15 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
662
1847
  const barW = inner / barSeries.length
663
1848
  barSeries.forEach((s, bi) => {
664
1849
  const dom = domOf(s)
665
- const base = yOf(dom, Math.min(Math.max(0, dom.min), dom.max))
1850
+ const log = isLogOf(s)
1851
+ // Log axis: bars grow from the axis floor (dom.min) up to v rather
1852
+ // than from 0, since 0 is invalid in log space.
1853
+ const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
666
1854
  s.values.forEach((v, i) => {
667
1855
  if (!Number.isFinite(v)) return
1856
+ if (log && v <= 0) return
668
1857
  const x = padL + slot * i + groupPad / 2 + barW * bi
669
- const yV = yOf(dom, v)
1858
+ const yV = yOf(dom, v, log)
670
1859
  bars.push({
671
1860
  x: round(x),
672
1861
  y: Math.min(yV, base),
@@ -707,7 +1896,8 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
707
1896
  for (const s of series) {
708
1897
  if (s.kind === 'bar') continue
709
1898
  const dom = domOf(s)
710
- const yA = (v: number) => yOf(dom, v)
1899
+ const log = isLogOf(s)
1900
+ const yA = (v: number) => yOf(dom, v, log)
711
1901
  const isStackedArea = stacked && s.kind === 'area'
712
1902
  const px = (i: number) => xCenter(i)
713
1903
  let pts: ChartLinePoint[]
@@ -732,24 +1922,21 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
732
1922
  return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
733
1923
  })
734
1924
  }
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()
1925
+ // Build the line - smoothed via monotone cubic when requested, else
1926
+ // straight polylines. Either way, gaps break the path cleanly.
1927
+ const smooth = !!s.smooth
1928
+ const path = buildLinePath(pts, smooth)
747
1929
 
748
1930
  let areaPath = ''
749
1931
  if (s.kind === 'area' && pts.length) {
750
1932
  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(' ')
1933
+ const top = smooth
1934
+ ? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
1935
+ : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
1936
+ const back = smooth
1937
+ ? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
1938
+ monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
1939
+ : baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
753
1940
  areaPath = `${top} ${back} Z`
754
1941
  } else {
755
1942
  // One filled polygon per contiguous run of defined points.
@@ -766,39 +1953,138 @@ export function buildChart(spec: ChartSpec): ChartGeometry {
766
1953
  if (cur.length) runs.push(cur)
767
1954
  areaPath = runs
768
1955
  .map((run) => {
769
- const top = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
1956
+ const top = smooth
1957
+ ? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
1958
+ : run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
770
1959
  return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
771
1960
  })
772
1961
  .join(' ')
773
1962
  }
774
1963
  }
775
- lines.push({ path, areaPath, color: s.color, label: s.label, points: pts })
1964
+
1965
+ // Confidence band: shaded envelope between upperValues / lowerValues.
1966
+ // Both arrays must be present and aligned to the value array.
1967
+ let bandPath = ''
1968
+ if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
1969
+ const upperPts: Array<{ x: number; y: number }> = []
1970
+ const lowerPts: Array<{ x: number; y: number }> = []
1971
+ for (let i = 0; i < s.values.length; i += 1) {
1972
+ const u = s.upperValues[i]!
1973
+ const lo = s.lowerValues[i]!
1974
+ if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
1975
+ if (log && (u <= 0 || lo <= 0)) continue
1976
+ upperPts.push({ x: px(i), y: yA(u) })
1977
+ lowerPts.push({ x: px(i), y: yA(lo) })
1978
+ }
1979
+ if (upperPts.length >= 2) {
1980
+ const top = smooth
1981
+ ? monotoneCubicPath(upperPts)
1982
+ : upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
1983
+ const back = smooth
1984
+ ? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
1985
+ monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
1986
+ : lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
1987
+ bandPath = `${top} ${back} Z`
1988
+ }
1989
+ }
1990
+
1991
+ lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
776
1992
  }
777
1993
 
778
- const tickFor = (dom: NiceScale): ChartAxisTick[] =>
779
- dom.ticks.map((value) => ({ value, y: yOf(dom, value), label: fmtTick(value) }))
1994
+ const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
1995
+ dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: fmtTick(value) }))
780
1996
 
781
1997
  const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
782
- const dom = ref.axis === 'right' ? (rightDom ?? leftDom) : leftDom
1998
+ const onRight = ref.axis === 'right'
1999
+ const dom = onRight ? (rightDom ?? leftDom) : leftDom
2000
+ const log = onRight ? rightLog : leftLog
783
2001
  return {
784
- y: yOf(dom, ref.value),
2002
+ y: yOf(dom, ref.value, log),
785
2003
  label: ref.label ?? fmtTick(ref.value),
786
2004
  color: ref.color ?? '#ef4444',
787
2005
  dashed: ref.dashed !== false,
788
2006
  }
789
2007
  })
790
2008
 
2009
+ // ---- Overlays: trendline / moving average ------------------------
2010
+ // For every series with an `overlay`, compute the smoothed values and
2011
+ // render as a dashed line in the source series' color (or overlayColor).
2012
+ const overlays: ChartLine[] = []
2013
+ for (const s of series) {
2014
+ if (!s.overlay) continue
2015
+ const dom = domOf(s)
2016
+ const log = isLogOf(s)
2017
+ const overlayVals = computeOverlay(s.values, s.overlay)
2018
+ const color = s.overlayColor ?? s.color
2019
+ const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
2020
+ const ok = Number.isFinite(v) && (!log || v > 0)
2021
+ return {
2022
+ x: xCenter(i),
2023
+ y: ok ? yOf(dom, v, log) : NaN,
2024
+ label: spec.categories[i] ?? String(i),
2025
+ value: v,
2026
+ defined: ok,
2027
+ }
2028
+ })
2029
+ const path = buildLinePath(pts, !!s.smooth)
2030
+ overlays.push({
2031
+ path,
2032
+ areaPath: '',
2033
+ color,
2034
+ label: `${s.label} (${s.overlay})`,
2035
+ points: pts,
2036
+ })
2037
+ }
2038
+
2039
+ // ---- Annotations: resolve data-space anchors to pixel coords ------
2040
+ const annotations: ChartGeometry['annotations'] = []
2041
+ for (const a of (spec.annotations ?? [])) {
2042
+ let ax: number | null = null
2043
+ let ay: number | null = null
2044
+ if ('category' in a.at) {
2045
+ const ci = spec.categories.indexOf(a.at.category)
2046
+ if (ci < 0) continue
2047
+ ax = xCenter(ci)
2048
+ // Anchor to the named series' value at that category, else just
2049
+ // mid-plot. Picks the first matching series if `series` is set.
2050
+ const seriesName = a.at.series
2051
+ const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
2052
+ if (s) {
2053
+ const v = s.values[ci]
2054
+ if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
2055
+ }
2056
+ if (ay == null) ay = padT + plotH / 2
2057
+ } else {
2058
+ // Raw x/y in data space (x ignored for category x-axis; takes the
2059
+ // mid-plot in that case). y projects through the left axis.
2060
+ ax = padL + plotW / 2
2061
+ if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
2062
+ else ay = padT + plotH / 2
2063
+ }
2064
+ if (ax != null && ay != null && Number.isFinite(ay)) {
2065
+ annotations.push({
2066
+ x: ax,
2067
+ y: ay,
2068
+ label: a.label,
2069
+ color: a.color ?? '#0f172a',
2070
+ placement: a.placement ?? 'top',
2071
+ })
2072
+ }
2073
+ }
2074
+
791
2075
  return {
792
2076
  ...empty,
793
2077
  plot,
794
2078
  bars,
795
2079
  lines,
796
- yTicks: tickFor(leftDom),
797
- y2Ticks: rightDom ? tickFor(rightDom) : [],
2080
+ yTicks: tickFor(leftDom, leftLog),
2081
+ y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
798
2082
  hasRightAxis,
799
2083
  xTicks,
800
2084
  xLabelRotated: timeOk ? false : xLabelRotated,
801
2085
  referenceLines,
2086
+ overlays,
2087
+ annotations,
802
2088
  }
803
2089
  }
804
2090
 
@@ -831,6 +2117,10 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
831
2117
  topN?: number
832
2118
  /** Label for the bucketed remainder. Default "Other". */
833
2119
  otherLabel?: string
2120
+ /** Field carrying each row's stable id. When set, the resulting spec's
2121
+ * series carry `rowIds` arrays so click handlers can drill back to
2122
+ * the source rows. */
2123
+ idField?: keyof T & string
834
2124
  },
835
2125
  ): ChartSpec {
836
2126
  const reduce = opts.reduce ?? 'sum'
@@ -850,8 +2140,9 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
850
2140
  return idx
851
2141
  }
852
2142
 
853
- // Series keyed by name -> per-category {sum,count}.
854
- const seriesMap = new Map<string, { sum: number; count: number }[]>()
2143
+ // Series keyed by name -> per-category {sum,count,rowIds}.
2144
+ type Cell = { sum: number; count: number; rowIds: Array<string | number> }
2145
+ const seriesMap = new Map<string, Cell[]>()
855
2146
  const ensureSeries = (name: string) => {
856
2147
  let arr = seriesMap.get(name)
857
2148
  if (!arr) {
@@ -861,26 +2152,30 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
861
2152
  return arr
862
2153
  }
863
2154
 
2155
+ const trackIds = opts.idField !== undefined
864
2156
  for (const row of rows) {
865
2157
  const cat = String(row[opts.category] ?? '')
866
2158
  const ci = ensureCat(cat)
2159
+ const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
867
2160
  if (opts.series) {
868
2161
  const sName = String(row[opts.series] ?? '')
869
2162
  const arr = ensureSeries(sName)
870
2163
  const num = Number(row[valueFields[0]!])
871
- const cell = (arr[ci] ??= { sum: 0, count: 0 })
2164
+ const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
872
2165
  if (Number.isFinite(num)) {
873
2166
  cell.sum += num
874
2167
  cell.count += 1
2168
+ if (rowId !== undefined) cell.rowIds.push(rowId)
875
2169
  }
876
2170
  } else {
877
2171
  for (const vf of valueFields) {
878
2172
  const arr = ensureSeries(vf)
879
2173
  const num = Number(row[vf])
880
- const cell = (arr[ci] ??= { sum: 0, count: 0 })
2174
+ const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
881
2175
  if (Number.isFinite(num)) {
882
2176
  cell.sum += num
883
2177
  cell.count += 1
2178
+ if (rowId !== undefined) cell.rowIds.push(rowId)
884
2179
  }
885
2180
  }
886
2181
  }
@@ -889,9 +2184,12 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
889
2184
  const entries = [...seriesMap.entries()].map(([name, arr]) => ({
890
2185
  label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
891
2186
  values: categories.map((_, i) => {
892
- const cell = arr[i] ?? { sum: 0, count: 0 }
2187
+ const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
893
2188
  return reduceCell(cell.sum, cell.count)
894
2189
  }),
2190
+ rowIds: trackIds
2191
+ ? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
2192
+ : undefined,
895
2193
  }))
896
2194
 
897
2195
  // ---- Sort + top-N -----------------------------------------------------
@@ -915,10 +2213,17 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
915
2213
  values: keep
916
2214
  .map((i) => e.values[i]!)
917
2215
  .concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
2216
+ rowIds: e.rowIds
2217
+ ? keep.map((i) => e.rowIds![i]!).concat([rest.flatMap((i) => e.rowIds![i] ?? [])])
2218
+ : undefined,
918
2219
  }))
919
2220
  } else {
920
2221
  finalCategories = order.map((i) => categories[i]!)
921
- finalSeries = entries.map((e) => ({ label: e.label, values: order.map((i) => e.values[i]!) }))
2222
+ finalSeries = entries.map((e) => ({
2223
+ label: e.label,
2224
+ values: order.map((i) => e.values[i]!),
2225
+ rowIds: e.rowIds ? order.map((i) => e.rowIds![i]!) : undefined,
2226
+ }))
922
2227
  }
923
2228
 
924
2229
  return {