@svgrid/grid 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -0,0 +1,417 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * The chart panel's pickers: type, the columns a chart reads, the
4
+ * aggregate, and the per-type switches (stacked, horizontal, donut, log
5
+ * scale, labels, bucket, date axis, bins, funnel shape, candle style,
6
+ * indicator chips), plus the sentence that says why the chosen type cannot
7
+ * draw yet. One component rendered twice: as a row under the panel head,
8
+ * and as a form on the builder's Data tab, so the two never disagree about
9
+ * what a type needs.
10
+ *
11
+ * Every label comes from `GridChartPanelMessages`, so the panel and the
12
+ * builder localize together through `localization.text`.
13
+ */
14
+ import type { ChartReducer, ChartType } from "./chart-types";
15
+ import type { ChartPanelIndicator } from "./chart-financial";
16
+ import type { ChartTimeBucket } from "./chart-stats";
17
+ import type { GridChartPanelMessages } from "./chart-panel-messages";
18
+
19
+ /** What the type picker knows about a chart type. */
20
+ export type ChartPanelTypeDef = { value: ChartType; label: string; group: string };
21
+ type Shape = { dims: unknown[]; measures: unknown[]; dates: unknown[] };
22
+ type TypeSeed = { value: ChartType; label: keyof GridChartPanelMessages; group: keyof GridChartPanelMessages; needs?: (c: Shape) => boolean };
23
+
24
+ /**
25
+ * The chart types the picker offers, grouped by what they are FOR.
26
+ *
27
+ * `needs` gates on COLUMN SHAPE only, never on the data. Column shape is
28
+ * stable while the user filters; row counts are not, and a picker whose
29
+ * options vanish mid-session because a filter emptied a group is worse than
30
+ * one that explains itself. Data-level problems get a sentence instead, see
31
+ * `typeIssue` below.
32
+ */
33
+ const TYPES: TypeSeed[] = [
34
+ { value: "bar", label: "chartTypeBar", group: "chartGroupCompare" },
35
+ { value: "line", label: "chartTypeLine", group: "chartGroupCompare" },
36
+ { value: "area", label: "chartTypeArea", group: "chartGroupCompare" },
37
+ { value: "lollipop", label: "chartTypeLollipop", group: "chartGroupCompare" },
38
+ { value: "pareto", label: "chartTypePareto", group: "chartGroupCompare" },
39
+ { value: "range-bar", label: "chartTypeRangeBar", group: "chartGroupCompare", needs: (c) => c.measures.length >= 2 },
40
+ { value: "range-area", label: "chartTypeRangeArea", group: "chartGroupCompare", needs: (c) => c.measures.length >= 2 },
41
+ { value: "dumbbell", label: "chartTypeDumbbell", group: "chartGroupCompare", needs: (c) => c.measures.length >= 2 },
42
+ { value: "radial-column", label: "chartTypeRadialColumn", group: "chartGroupCompare" },
43
+ { value: "radial-bar", label: "chartTypeRadialBar", group: "chartGroupCompare" },
44
+ { value: "nightingale", label: "chartTypeNightingale", group: "chartGroupCompare" },
45
+ { value: "pie", label: "chartTypePie", group: "chartGroupPartOfWhole" },
46
+ { value: "treemap", label: "chartTypeTreemap", group: "chartGroupPartOfWhole" },
47
+ { value: "sunburst", label: "chartTypeSunburst", group: "chartGroupPartOfWhole" },
48
+ { value: "funnel", label: "chartTypeFunnel", group: "chartGroupFlow" },
49
+ { value: "waterfall", label: "chartTypeWaterfall", group: "chartGroupFlow" },
50
+ { value: "sankey", label: "chartTypeSankey", group: "chartGroupFlow", needs: (c) => c.dims.length >= 2 },
51
+ { value: "chord", label: "chartTypeChord", group: "chartGroupFlow", needs: (c) => c.dims.length >= 2 },
52
+ { value: "radar", label: "chartTypeRadar", group: "chartGroupDistribution" },
53
+ { value: "heatmap", label: "chartTypeHeatmap", group: "chartGroupDistribution", needs: (c) => c.dims.length >= 2 },
54
+ { value: "scatter", label: "chartTypeScatter", group: "chartGroupDistribution", needs: (c) => c.measures.length >= 2 },
55
+ { value: "boxplot", label: "chartTypeBoxplot", group: "chartGroupDistribution" },
56
+ { value: "histogram", label: "chartTypeHistogram", group: "chartGroupDistribution" },
57
+ { value: "gauge", label: "chartTypeGauge", group: "chartGroupSingleValue" },
58
+ { value: "bullet", label: "chartTypeBullet", group: "chartGroupSingleValue" },
59
+ { value: "calendar", label: "chartTypeCalendar", group: "chartGroupOverTime", needs: (c) => c.dates.length >= 1 },
60
+ { value: "stream", label: "chartTypeStream", group: "chartGroupOverTime", needs: (c) => c.dims.length >= 2 },
61
+ { value: "candlestick", label: "chartTypeCandlestick", group: "chartGroupOverTime", needs: (c) => c.dates.length >= 1 && c.measures.length >= 4 },
62
+ { value: "ohlc", label: "chartTypeOhlc", group: "chartGroupOverTime", needs: (c) => c.dates.length >= 1 && c.measures.length >= 4 },
63
+ ];
64
+
65
+ /** The types the panel offers for these columns, labelled in the given language. */
66
+ export function chartPanelTypes(columns: Shape, m: GridChartPanelMessages): ChartPanelTypeDef[] {
67
+ return TYPES.filter((t) => !t.needs || t.needs(columns)).map((t) => ({ value: t.value, label: m[t.label], group: m[t.group] }));
68
+ }
69
+
70
+ /** The reducers the Aggregate picker offers, in its order. */
71
+ export function chartPanelReducers(m: GridChartPanelMessages): Array<{ value: ChartReducer; label: string }> {
72
+ return [
73
+ { value: "sum", label: m.chartReduceSum },
74
+ { value: "avg", label: m.chartReduceAvg },
75
+ { value: "count", label: m.chartReduceCount },
76
+ { value: "min", label: m.chartReduceMin },
77
+ { value: "max", label: m.chartReduceMax },
78
+ { value: "median", label: m.chartReduceMedian },
79
+ { value: "p90", label: m.chartReduceP90 },
80
+ { value: "first", label: m.chartReduceFirst },
81
+ { value: "last", label: m.chartReduceLast },
82
+ { value: "countDistinct", label: m.chartReduceCountDistinct },
83
+ ];
84
+ }
85
+
86
+ /** Named series palettes. "Theme" defers to --sg-chart-1..8 and the defaults. */
87
+ export const CHART_PANEL_PALETTES: Array<{ key: string; label: keyof GridChartPanelMessages; colors: string[] | null }> = [
88
+ { key: "theme", label: "chartPaletteTheme", colors: null },
89
+ { key: "ocean", label: "chartPaletteOcean", colors: ["#0ea5e9", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4"] },
90
+ { key: "sunset", label: "chartPaletteSunset", colors: ["#f59e0b", "#ef4444", "#ec4899", "#8b5cf6", "#f97316"] },
91
+ { key: "forest", label: "chartPaletteForest", colors: ["#16a34a", "#65a30d", "#0d9488", "#4d7c0f", "#0891b2"] },
92
+ { key: "slate", label: "chartPaletteSlate", colors: ["#475569", "#0ea5e9", "#64748b", "#38bdf8", "#94a3b8"] },
93
+ ];
94
+
95
+ /** The indicator chips a price chart offers: panes first, then overlays. */
96
+ const INDICATORS: Array<{ value: ChartPanelIndicator; label: keyof GridChartPanelMessages; title: keyof GridChartPanelMessages }> = [
97
+ { value: "volume", label: "chartIndVolume", title: "chartIndVolumeTitle" },
98
+ { value: "rsi", label: "chartIndRsi", title: "chartIndRsiTitle" },
99
+ { value: "macd", label: "chartIndMacd", title: "chartIndMacdTitle" },
100
+ { value: "stochastic", label: "chartIndStochastic", title: "chartIndStochasticTitle" },
101
+ { value: "atr", label: "chartIndAtr", title: "chartIndAtrTitle" },
102
+ { value: "obv", label: "chartIndObv", title: "chartIndObvTitle" },
103
+ { value: "sma", label: "chartIndSma", title: "chartIndSmaTitle" },
104
+ { value: "ema", label: "chartIndEma", title: "chartIndEmaTitle" },
105
+ { value: "bb", label: "chartIndBb", title: "chartIndBbTitle" },
106
+ { value: "vwap", label: "chartIndVwap", title: "chartIndVwapTitle" },
107
+ ];
108
+ const OVERLAY_KINDS: ChartPanelIndicator[] = ["sma", "ema", "bb", "vwap"];
109
+ /** The price columns a candlestick / OHLC chart reads, in picker order. */
110
+ const OHLC_FIELDS: Array<{ key: "open" | "high" | "low" | "close" | "volume"; label: keyof GridChartPanelMessages }> = [
111
+ { key: "open", label: "chartOpen" },
112
+ { key: "high", label: "chartHigh" },
113
+ { key: "low", label: "chartLow" },
114
+ { key: "close", label: "chartClosePrice" },
115
+ { key: "volume", label: "chartVolume" },
116
+ ];
117
+
118
+ // Which controls this type can actually use. Allow-lists rather than "not
119
+ // pie" guards: with thirty types, "everything except pie" hands a gauge a
120
+ // Stacked checkbox.
121
+ const CARTESIAN: ChartType[] = ["bar", "line", "area", "lollipop", "pareto", "range-bar", "range-area", "dumbbell", "stream"];
122
+ /** Types whose second measure is the HIGH end (or the target) rather than a Y. */
123
+ const TWO_MEASURE: ChartType[] = ["range-bar", "range-area", "dumbbell"];
124
+ const BUCKETS: Array<{ value: ChartTimeBucket | ""; label: keyof GridChartPanelMessages }> = [
125
+ { value: "", label: "chartBucketExact" },
126
+ { value: "day", label: "chartBucketDay" },
127
+ { value: "week", label: "chartBucketWeek" },
128
+ { value: "month", label: "chartBucketMonth" },
129
+ { value: "quarter", label: "chartBucketQuarter" },
130
+ { value: "year", label: "chartBucketYear" },
131
+ ];
132
+ </script>
133
+
134
+ <script lang="ts" generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData">
135
+ import type { RowData, TableFeatures } from "./index";
136
+ import type { SvGridController } from "./SvGrid.controller.svelte";
137
+ import { pivotChartType } from "./chart-pivot";
138
+
139
+ let {
140
+ ctrl,
141
+ messages: m,
142
+ scope = "panel",
143
+ }: {
144
+ ctrl: SvGridController<TFeatures, TData>;
145
+ messages: GridChartPanelMessages;
146
+ /** `panel`: the row under the panel head, Type first. `builder`: the
147
+ * Data tab's form, without Type (the gallery picks it). */
148
+ scope?: "panel" | "builder";
149
+ } = $props();
150
+
151
+ const columns = $derived(ctrl.chartableColumns);
152
+ /** Pivot mode: the pivot on screen is the chart, so the data pickers go and the
153
+ * type list keeps the shapes a pivot can take (no scatter, gauge, ranges, candles). */
154
+ const pivot = $derived(ctrl.pivotResult != null);
155
+ const availableTypes = $derived(chartPanelTypes(columns, m).filter((t) => !pivot || pivotChartType(t.value) === t.value));
156
+ const typeGroups = $derived([...new Set(availableTypes.map((t) => t.group))]);
157
+ const reducers = $derived(chartPanelReducers(m));
158
+
159
+ function toggleIndicator(kind: ChartPanelIndicator) {
160
+ const on = ctrl.chartIndicators.includes(kind);
161
+ // One overlay at a time: a series carries a single `overlay`.
162
+ const base = OVERLAY_KINDS.includes(kind) ? ctrl.chartIndicators.filter((k) => !OVERLAY_KINDS.includes(k)) : ctrl.chartIndicators;
163
+ ctrl.chartIndicators = on ? base.filter((k) => k !== kind) : [...base, kind];
164
+ }
165
+
166
+ const activePalette = $derived(
167
+ CHART_PANEL_PALETTES.find((x) => x.colors && ctrl.chartPalette && x.colors.join() === ctrl.chartPalette.join())?.key ?? "theme",
168
+ );
169
+ const wantsHorizontal = $derived(ctrl.chartType === "bar");
170
+ const wantsDonut = $derived(ctrl.chartType === "pie" || ctrl.chartType === "sunburst");
171
+ const wantsGroupBy = $derived(!pivot && ctrl.chartType !== "gauge" && ctrl.chartType !== "histogram");
172
+ const wantsSplitBy = $derived(
173
+ !pivot && columns.dims.length > 1 &&
174
+ !["pie", "gauge", "waterfall", "funnel", "calendar", "bullet", "pareto", "radial-bar"].includes(ctrl.chartType),
175
+ );
176
+ const wantsStacked = $derived(["bar", "line", "area", "radial-column"].includes(ctrl.chartType));
177
+ const wantsLogScale = $derived(
178
+ CARTESIAN.includes(ctrl.chartType) || ctrl.chartType === "scatter" || ctrl.chartType === "boxplot" || ctrl.chartType === "histogram",
179
+ );
180
+ const wantsDataLabels = $derived(
181
+ !["gauge", "sankey", "calendar", "treemap", "scatter", "boxplot", "chord", "sunburst", "stream"].includes(ctrl.chartType),
182
+ );
183
+ const wantsSecondMeasure = $derived(!pivot && (ctrl.chartType === "scatter" || TWO_MEASURE.includes(ctrl.chartType) || ctrl.chartType === "bullet"));
184
+ /** What the second measure picker is labelled: Y for a scatter, High for a
185
+ * range, Target for a bullet. */
186
+ const secondMeasureLabel = $derived(ctrl.chartType === "scatter" ? m.chartY : ctrl.chartType === "bullet" ? m.chartTarget : m.chartHigh);
187
+ const wantsFunnelShape = $derived(ctrl.chartType === "funnel");
188
+ const wantsCandleStyle = $derived(ctrl.chartType === "candlestick");
189
+ const wantsOhlc = $derived(!pivot && ctrl.chartIsOhlc);
190
+ const wantsBins = $derived(!pivot && ctrl.chartType === "histogram");
191
+ const wantsValue = $derived(!pivot && !ctrl.chartIsOhlc);
192
+ /**
193
+ * A box plot is the one type that reduces its groups itself: the whole point
194
+ * is the spread of the sample, so `sum | avg | count` has nothing to say
195
+ * about it. Showing an Aggregate select that changes nothing is worse than
196
+ * not showing one.
197
+ */
198
+ const wantsReduce = $derived(!pivot && ctrl.chartType !== "scatter" && ctrl.chartType !== "boxplot" && ctrl.chartType !== "histogram" && !ctrl.chartIsOhlc);
199
+
200
+ /**
201
+ * Why the current type cannot draw anything useful yet, as a sentence.
202
+ *
203
+ * The alternative - removing the option - means a chart can disappear from
204
+ * under the user when they filter. Saying what is missing keeps the picker
205
+ * honest without making it unstable.
206
+ */
207
+ const typeIssue = $derived.by<string | null>(() => {
208
+ const t = ctrl.chartType;
209
+ const cats = ctrl.chartSpec?.categories.length ?? 0;
210
+ if (t === "heatmap" && !ctrl.chartSeriesId && columns.dims.length > 1) return m.chartIssueHeatmap;
211
+ if (t === "sankey" && !ctrl.chartSeriesId) return m.chartIssueSankey;
212
+ if (t === "radar" && cats > 0 && cats < 3) return m.chartIssueRadar;
213
+ if (t === "funnel" && cats === 1) return m.chartIssueFunnel;
214
+ if (t === "chord" && !ctrl.chartSeriesId) return m.chartIssueChord;
215
+ if (t === "stream" && !ctrl.chartSeriesId) return m.chartIssueStream;
216
+ if (TWO_MEASURE.includes(t) && !ctrl.effectiveChartMeasure2Id) return m.chartIssueSecondMeasure;
217
+ if (ctrl.chartIsOhlc) {
218
+ const o = ctrl.chartOhlc;
219
+ if (!ctrl.chartOhlcDateId) return m.chartIssueOhlcDate;
220
+ if (!o.open || !o.high || !o.low || !o.close) return m.chartIssueOhlcColumns;
221
+ }
222
+ return null;
223
+ });
224
+ </script>
225
+
226
+ <div class="sv-grid-chart-controls" class:is-form={scope === "builder"} data-scope={scope}>
227
+ {#if scope === "panel"}
228
+ <label class="sv-grid-chart-ctl">
229
+ <span class="sv-grid-chart-ctl-lbl">{m.chartType}</span>
230
+ <select value={ctrl.chartType} onchange={(e) => (ctrl.chartType = e.currentTarget.value as ChartType)}>
231
+ <!-- Grouped: a flat thirty-item list is a wall. optgroup is native,
232
+ so this costs no new component. -->
233
+ {#each typeGroups as g (g)}
234
+ <optgroup label={g}>
235
+ {#each availableTypes.filter((t) => t.group === g) as t (t.value)}
236
+ <option value={t.value}>{t.label}</option>
237
+ {/each}
238
+ </optgroup>
239
+ {/each}
240
+ </select>
241
+ </label>
242
+ {/if}
243
+ {#if wantsOhlc}
244
+ <label class="sv-grid-chart-ctl">
245
+ <span class="sv-grid-chart-ctl-lbl">{m.chartDate}</span>
246
+ <select value={ctrl.chartOhlcDateId ?? ""} onchange={(e) => (ctrl.chartDimensionId = e.currentTarget.value || null)}>
247
+ {#each columns.dates as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
248
+ </select>
249
+ </label>
250
+ {#each OHLC_FIELDS as f (f.key)}
251
+ <label class="sv-grid-chart-ctl">
252
+ <span class="sv-grid-chart-ctl-lbl">{m[f.label]}</span>
253
+ <select value={ctrl.chartOhlc[f.key] ?? ""} onchange={(e) => (ctrl.chartOhlc = { ...ctrl.chartOhlc, [f.key]: e.currentTarget.value || null })}>
254
+ {#if f.key === "volume"}<option value="">{m.chartNone}</option>{/if}
255
+ {#each columns.measures as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
256
+ </select>
257
+ </label>
258
+ {/each}
259
+ {:else if wantsGroupBy}
260
+ <label class="sv-grid-chart-ctl">
261
+ <span class="sv-grid-chart-ctl-lbl">{ctrl.chartType === "sankey" ? m.chartFrom : ctrl.chartType === "heatmap" ? m.chartColumns : m.chartGroupBy}</span>
262
+ <select value={ctrl.chartDimensionId ?? ""} onchange={(e) => (ctrl.chartDimensionId = e.currentTarget.value || null)}>
263
+ {#each columns.dims as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
264
+ </select>
265
+ </label>
266
+ {/if}
267
+ {#if wantsSplitBy}
268
+ <label class="sv-grid-chart-ctl">
269
+ <span class="sv-grid-chart-ctl-lbl">{ctrl.chartType === "sankey" ? m.chartTo : ctrl.chartType === "heatmap" ? m.chartRows : m.chartSplitBy}</span>
270
+ <select value={ctrl.chartSeriesId ?? ""} onchange={(e) => (ctrl.chartSeriesId = e.currentTarget.value || null)}>
271
+ <option value="">{m.chartNone}</option>
272
+ {#each columns.dims as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
273
+ </select>
274
+ </label>
275
+ {/if}
276
+ {#if wantsValue}
277
+ <label class="sv-grid-chart-ctl">
278
+ <span class="sv-grid-chart-ctl-lbl">{ctrl.chartType === "scatter" ? m.chartX : TWO_MEASURE.includes(ctrl.chartType) ? m.chartLow : ctrl.chartType === "gauge" ? m.chartMetric : m.chartValue}</span>
279
+ <select value={ctrl.chartMeasureId ?? ""} onchange={(e) => (ctrl.chartMeasureId = e.currentTarget.value || null)}>
280
+ {#each columns.measures as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
281
+ </select>
282
+ </label>
283
+ {/if}
284
+ {#if wantsSecondMeasure}
285
+ <label class="sv-grid-chart-ctl">
286
+ <span class="sv-grid-chart-ctl-lbl">{secondMeasureLabel}</span>
287
+ <select value={ctrl.effectiveChartMeasure2Id ?? ""} onchange={(e) => (ctrl.chartMeasure2Id = e.currentTarget.value || null)}>
288
+ {#each columns.measures as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
289
+ </select>
290
+ </label>
291
+ {/if}
292
+ {#if wantsReduce}
293
+ <label class="sv-grid-chart-ctl">
294
+ <span class="sv-grid-chart-ctl-lbl">{m.chartAggregate}</span>
295
+ <select value={ctrl.chartReduce} onchange={(e) => (ctrl.chartReduce = e.currentTarget.value as ChartReducer)}>
296
+ {#each reducers as r (r.value)}<option value={r.value}>{r.label}</option>{/each}
297
+ </select>
298
+ </label>
299
+ {/if}
300
+ {#if wantsBins}
301
+ <label class="sv-grid-chart-ctl" title={m.chartBinsTitle}>
302
+ <span class="sv-grid-chart-ctl-lbl">{m.chartBins}</span>
303
+ <input class="sv-grid-chart-num" type="number" min="1" max="200" value={ctrl.chartBins ?? ""} placeholder={m.chartBuilderAuto} onchange={(e) => (ctrl.chartBins = e.currentTarget.value ? Number(e.currentTarget.value) : null)} />
304
+ </label>
305
+ {/if}
306
+ {#if wantsFunnelShape}
307
+ <label class="sv-grid-chart-ctl">
308
+ <span class="sv-grid-chart-ctl-lbl">{m.chartShape}</span>
309
+ <select value={ctrl.chartFunnelShape ?? "trapezoid"} onchange={(e) => (ctrl.chartFunnelShape = e.currentTarget.value as "trapezoid" | "pyramid" | "cone")}>
310
+ <option value="trapezoid">{m.chartShapeFunnel}</option>
311
+ <option value="pyramid">{m.chartShapePyramid}</option>
312
+ <option value="cone">{m.chartShapeCone}</option>
313
+ </select>
314
+ </label>
315
+ {/if}
316
+ {#if wantsCandleStyle}
317
+ <label class="sv-grid-chart-ctl">
318
+ <span class="sv-grid-chart-ctl-lbl">{m.chartCandles}</span>
319
+ <select value={ctrl.chartCandleStyle ?? "classic"} onchange={(e) => (ctrl.chartCandleStyle = e.currentTarget.value as "classic" | "hollow" | "heikin-ashi")}>
320
+ <option value="classic">{m.chartCandlesClassic}</option>
321
+ <option value="hollow">{m.chartCandlesHollow}</option>
322
+ <option value="heikin-ashi">{m.chartCandlesHeikinAshi}</option>
323
+ </select>
324
+ </label>
325
+ {/if}
326
+ {#if wantsOhlc}
327
+ <span class="sv-grid-chart-ctl sv-grid-chart-indicators" role="group" aria-label={m.chartIndicators}>
328
+ <span class="sv-grid-chart-ctl-lbl">{m.chartIndicators}</span>
329
+ <span class="sv-grid-chart-chips">
330
+ {#each INDICATORS as ind (ind.value)}
331
+ <button
332
+ type="button"
333
+ class="sv-grid-chart-chip"
334
+ class:is-on={ctrl.chartIndicators.includes(ind.value)}
335
+ aria-pressed={ctrl.chartIndicators.includes(ind.value)}
336
+ title={m[ind.title]}
337
+ onclick={() => toggleIndicator(ind.value)}
338
+ >{m[ind.label]}</button>
339
+ {/each}
340
+ </span>
341
+ </span>
342
+ {/if}
343
+ <label class="sv-grid-chart-ctl">
344
+ <span class="sv-grid-chart-ctl-lbl">{m.chartFormat}</span>
345
+ <select value={ctrl.chartValueFormat} onchange={(e) => (ctrl.chartValueFormat = e.currentTarget.value as "number" | "currency" | "percent")}>
346
+ <option value="number">{m.chartFormatNumber}</option>
347
+ <option value="currency">{m.chartFormatCurrency}</option>
348
+ <option value="percent">{m.chartFormatPercent}</option>
349
+ </select>
350
+ </label>
351
+ {#if wantsStacked}
352
+ <label class="sv-grid-chart-toggle">
353
+ <input type="checkbox" checked={ctrl.chartStacked} onchange={(e) => (ctrl.chartStacked = e.currentTarget.checked)} />
354
+ {m.chartStacked}
355
+ </label>
356
+ <label class="sv-grid-chart-toggle" title={m.chartStacked100Title}>
357
+ <input type="checkbox" checked={ctrl.chartStacked100} onchange={(e) => (ctrl.chartStacked100 = e.currentTarget.checked)} />
358
+ {m.chartStacked100}
359
+ </label>
360
+ {/if}
361
+ {#if wantsHorizontal}
362
+ <label class="sv-grid-chart-toggle" title={m.chartHorizontalTitle}>
363
+ <input
364
+ type="checkbox"
365
+ checked={ctrl.chartOrientation === "horizontal"}
366
+ onchange={(e) => (ctrl.chartOrientation = e.currentTarget.checked ? "horizontal" : "vertical")}
367
+ />
368
+ {m.chartHorizontal}
369
+ </label>
370
+ {/if}
371
+ {#if wantsDonut}
372
+ <label class="sv-grid-chart-toggle">
373
+ <input type="checkbox" checked={ctrl.chartDonut} onchange={(e) => (ctrl.chartDonut = e.currentTarget.checked)} />
374
+ {m.chartDonut}
375
+ </label>
376
+ {/if}
377
+ <label class="sv-grid-chart-ctl">
378
+ <span class="sv-grid-chart-ctl-lbl">{m.chartColours}</span>
379
+ <select
380
+ value={activePalette}
381
+ onchange={(e) => (ctrl.chartPalette = CHART_PANEL_PALETTES.find((x) => x.key === e.currentTarget.value)?.colors ?? null)}
382
+ >
383
+ {#each CHART_PANEL_PALETTES as pal (pal.key)}<option value={pal.key}>{m[pal.label]}</option>{/each}
384
+ </select>
385
+ </label>
386
+ {#if wantsDataLabels}
387
+ <label class="sv-grid-chart-toggle">
388
+ <input type="checkbox" checked={ctrl.chartDataLabels} onchange={(e) => (ctrl.chartDataLabels = e.currentTarget.checked)} />
389
+ {m.chartLabels}
390
+ </label>
391
+ {/if}
392
+ {#if wantsLogScale}
393
+ <label class="sv-grid-chart-toggle" title={m.chartLogScaleTitle}>
394
+ <input type="checkbox" checked={ctrl.chartLogScale} onchange={(e) => (ctrl.chartLogScale = e.currentTarget.checked)} />
395
+ {m.chartLogScale}
396
+ </label>
397
+ {/if}
398
+ {#if ctrl.chartDimensionIsDate && wantsGroupBy}
399
+ <label class="sv-grid-chart-ctl" title={m.chartBucketTitle}>
400
+ <span class="sv-grid-chart-ctl-lbl">{m.chartBucket}</span>
401
+ <select value={ctrl.chartBucket ?? ""} onchange={(e) => (ctrl.chartBucket = (e.currentTarget.value || null) as ChartTimeBucket | null)}>
402
+ {#each BUCKETS as b (b.value)}<option value={b.value}>{m[b.label]}</option>{/each}
403
+ </select>
404
+ </label>
405
+ {/if}
406
+ {#if ctrl.chartDimensionIsDate && CARTESIAN.includes(ctrl.chartType)}
407
+ <label class="sv-grid-chart-toggle" title={m.chartDateAxisTitle}>
408
+ <input type="checkbox" checked={ctrl.chartTimeAxis} onchange={(e) => (ctrl.chartTimeAxis = e.currentTarget.checked)} />
409
+ {m.chartDateAxis}
410
+ </label>
411
+ {/if}
412
+ {#if pivot}
413
+ <p class="sv-grid-chart-hint sv-grid-chart-pivot-hint" role="note">{m.chartPivotHint}</p>
414
+ {:else if typeIssue}
415
+ <p class="sv-grid-chart-hint" role="note">{typeIssue}</p>
416
+ {/if}
417
+ </div>
@@ -31,10 +31,9 @@
31
31
  localization?: { locale?: string | ReadonlyArray<string> };
32
32
  } = $props();
33
33
 
34
- // Container size -> chart viewBox, so the chart fills the grid area and
35
- // re-lays out on resize (the same responsive behaviour a table view has).
36
- let w = $state(0);
37
- let h = $state(0);
34
+ // The chart fills the grid area and re-lays out on resize (the same
35
+ // responsive behaviour a table view has) through its own `autosize`, which
36
+ // also keeps its legend inside the box instead of below it.
38
37
 
39
38
  const spec = $derived.by<ChartSpec>(() => {
40
39
  const s = rowsToChartSpec((data ?? []) as ReadonlyArray<Record<string, unknown>>, {
@@ -57,20 +56,20 @@
57
56
  });
58
57
  </script>
59
58
 
60
- <div class="sv-grid-chart-view" bind:clientWidth={w} bind:clientHeight={h}>
61
- {#if w > 0 && h > 0}
62
- <SvGridChart
63
- {spec}
64
- width={w}
65
- height={h}
66
- legend={chart.legend ?? true}
67
- dataLabels={chart.dataLabels ?? false}
68
- />
69
- {/if}
59
+ <div class="sv-grid-chart-view">
60
+ <SvGridChart
61
+ {spec}
62
+ autosize
63
+ legend={chart.legend ?? true}
64
+ dataLabels={chart.dataLabels ?? false}
65
+ />
70
66
  </div>
71
67
 
72
68
  <style>
69
+ /* A grid, so the autosized chart is stretched to the full height rather
70
+ than asking for a percentage of it (which cannot be measured reliably). */
73
71
  .sv-grid-chart-view {
72
+ display: grid;
74
73
  width: 100%;
75
74
  height: 100%;
76
75
  min-height: 0;
@@ -22,6 +22,11 @@
22
22
  onClose?: () => void
23
23
  title?: string
24
24
  size?: 'sm' | 'md' | 'lg'
25
+ /**
26
+ * Panel width in px, when the three presets miss the content's natural
27
+ * width. Wins over `size`; the user's own resize wins over both.
28
+ */
29
+ width?: number
25
30
  draggable?: boolean
26
31
  resizable?: boolean
27
32
  closeOnBackdrop?: boolean
@@ -37,6 +42,7 @@
37
42
  onClose,
38
43
  title,
39
44
  size = 'md',
45
+ width,
40
46
  draggable = false,
41
47
  resizable = false,
42
48
  closeOnBackdrop = true,
@@ -118,7 +124,7 @@
118
124
  class:is-resized={!!box}
119
125
  {...overlay.dialogProps({ labelledBy: title ? titleId : undefined })}
120
126
  style:transform={`translate(calc(-50% + ${drag.x}px), calc(-50% + ${drag.y}px))`}
121
- style:width={box ? `${box.w}px` : undefined}
127
+ style:width={box ? `${box.w}px` : width ? `${width}px` : undefined}
122
128
  style:height={box ? `${box.h}px` : undefined}
123
129
  >
124
130
  {#if title || !hideClose}
@@ -38,6 +38,15 @@ describe('SvModal', () => {
38
38
  } finally { destroy() }
39
39
  })
40
40
 
41
+ it('takes an exact width over the size preset', () => {
42
+ const { destroy } = mountModal({ open: true, size: 'sm', width: 440 })
43
+ try {
44
+ const el = dialog()!
45
+ expect(el.classList.contains('sv-modal--sm')).toBe(true)
46
+ expect(el.style.width).toBe('440px')
47
+ } finally { destroy() }
48
+ })
49
+
41
50
  it('locks body scroll while open and restores it on close', () => {
42
51
  document.body.style.overflow = 'auto'
43
52
  const { destroy } = mountModal({ open: true })
package/src/ai.test.ts CHANGED
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
2
  import {
3
3
  setAIProvider, getAIProvider, hasAIProvider,
4
4
  mockAIProvider,
5
- aiFilter, aiSmartFill, aiSummarize, aiClassify, aiChart,
5
+ aiFilter, aiSmartFill, aiSummarize, aiClassify, aiChart, aiExplainChart, enableAiCharting, disableAiCharting,
6
6
  type AIProvider, type AIRequest,
7
7
  } from './ai'
8
8
 
@@ -552,10 +552,65 @@ describe('aiChart type vocabulary', () => {
552
552
  expect(g.dimension).toBeNull()
553
553
  })
554
554
 
555
+ it('plans a candlestick with indicators when the data has a date and four prices', async () => {
556
+ const prices = [
557
+ { day: '2026-01-05', open: 10, high: 12, low: 9, close: 11, volume: 100 },
558
+ { day: '2026-01-06', open: 11, high: 13, low: 10, close: 12, volume: 120 },
559
+ ]
560
+ const p = await aiChart(fakeApi(prices).api as never, 'candlestick price chart with volume and RSI')
561
+ expect(p.type).toBe('candlestick')
562
+ expect(p.dimension).toBe('day')
563
+ expect(p.indicators).toEqual(['volume', 'rsi'])
564
+ // Without four numeric columns there is nothing to draw candles from.
565
+ expect((await plan('candlestick of revenue')).type).toBe('bar')
566
+ // Indicators only mean something on a candlestick, and unknown ones are dropped.
567
+ const loose: AIProvider = async () =>
568
+ JSON.stringify({ type: 'candlestick', dimension: 'day', measure: 'close', reduce: 'sum', indicators: ['rsi', 'bogus'] })
569
+ setAIProvider(loose)
570
+ const q = await aiChart(fakeApi(prices).api as never, 'anything')
571
+ expect(q.indicators).toEqual(['rsi'])
572
+ })
573
+
555
574
  it('coerces an unknown type rather than passing it through', async () => {
556
575
  const bogus: AIProvider = async () =>
557
- JSON.stringify({ type: 'sunburst', dimension: 'region', measure: 'revenue', reduce: 'sum' })
576
+ JSON.stringify({ type: 'hexbin', dimension: 'region', measure: 'revenue', reduce: 'sum' })
558
577
  setAIProvider(bogus)
559
578
  expect((await plan('anything')).type).toBe('bar')
560
579
  })
561
580
  })
581
+
582
+ describe('aiExplainChart', () => {
583
+ it('grounds the prompt on chartSummary and returns the summary with the insights', async () => {
584
+ const prompts: string[] = []
585
+ setAIProvider(async (req) => {
586
+ prompts.push(req.prompt)
587
+ expect(req.task).toBe('explain-chart')
588
+ return JSON.stringify({ insights: ['one', 'two', 'three', 'four'] })
589
+ })
590
+ const spec = { type: 'bar' as const, categories: ['Q1', 'Q2'], series: [{ label: 'Sales', values: [10, 15] }] }
591
+ const out = await aiExplainChart({ getChartSpec: () => spec } as never)
592
+ expect(out.summary).toBe('Sales rises 50% from 10 at Q1 to 15 at Q2.')
593
+ expect(out.insights).toEqual(['one', 'two', 'three'])
594
+ expect(prompts[0]).toContain('Reading of the chart: Sales rises 50%')
595
+ expect(prompts[0]).toContain('category\tSales\nQ1\t10\nQ2\t15')
596
+ // No chart: no call.
597
+ expect(await aiExplainChart({ getChartSpec: () => null } as never)).toEqual({ summary: 'No chart to explain.', insights: [] })
598
+ expect(prompts).toHaveLength(1)
599
+ })
600
+
601
+ it('the mock provider answers with two insights, and enableAiCharting registers the explain handler', async () => {
602
+ setAIProvider(mockAIProvider)
603
+ const spec = { type: 'line' as const, categories: ['a', 'b'], series: [{ label: 'v', values: [1, 3] }] }
604
+ const out = await aiExplainChart({ getChartSpec: () => spec } as never)
605
+ expect(out.insights).toHaveLength(2)
606
+ expect(out.insights[0]).toContain('front-loaded')
607
+ const handlers: Record<string, unknown> = {}
608
+ const api = { getChartSpec: () => spec, setChartAiHandler: (fn: unknown) => { handlers.chart = fn }, setChartExplainHandler: (fn: unknown) => { handlers.explain = fn } }
609
+ enableAiCharting(api as never)
610
+ expect(typeof handlers.explain).toBe('function')
611
+ const res = await (handlers.explain as () => Promise<{ summary: string }>)()
612
+ expect(res.summary).toBe('v rises 200% from 1 at a to 3 at b.')
613
+ disableAiCharting(api as never)
614
+ expect(handlers.explain).toBeNull()
615
+ })
616
+ })