@eifi1/ui-kit 0.7.1 → 0.8.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 (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -17,14 +17,43 @@
17
17
  // Charts are drawn left-to-right in every writing direction — an abscissa is a number
18
18
  // line, not text — so `orientation` below is physical, and only the HTML chrome (the
19
19
  // reset button) is placed by logical side.
20
+ //
21
+ // **Bars, areas and periods too (0.8.0).** keksdose drew five money charts in raw
22
+ // recharts — net worth, investments, income/expense, grouped payees, stacked categories
23
+ // — each re-deciding the grid, the tooltip, the axis width and the "—" for a gap. They
24
+ // are the same arrangement with a different mark, so a series now says `type: "bar" |
25
+ // "area"` (and `stack`), and the abscissa may be a row of PERIODS (`x.type:
26
+ // "category"`) or real dates (`x.type: "time"`) instead of a number the app had to
27
+ // invent. What each of those does to the zoom is decided in one place,
28
+ // `defaultZoomAxes` in `chart-zoom.tsx`.
20
29
  import { useMemo } from "react";
21
- import type { ReactNode } from "react";
22
- import { CartesianGrid, Label, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
30
+ import type { ReactNode, RefObject } from "react";
31
+ import {
32
+ Area,
33
+ Bar,
34
+ CartesianGrid,
35
+ ComposedChart,
36
+ Label,
37
+ Line,
38
+ ReferenceDot,
39
+ ReferenceLine,
40
+ XAxis,
41
+ YAxis,
42
+ } from "recharts";
23
43
  import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
24
- import { DEFAULT_Y_AXIS, withChartZoom, type ZoomBinding } from "./chart-zoom";
25
- import { STEP_DASH, strokeDash } from "./toggle-legend";
44
+ import {
45
+ DEFAULT_Y_AXIS,
46
+ axisExtent,
47
+ withChartZoom,
48
+ type ZoomAxesSetting,
49
+ type ZoomBinding,
50
+ type ZoomFitSource,
51
+ } from "./chart-zoom";
52
+ import { STEP_DASH, strokeDash, type LegendEntry } from "./toggle-legend";
26
53
  import { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from "./series-chart-labels";
27
- import { niceTicks } from "./series-chart-ticks";
54
+ import { categoryTicks, integerTicks, niceTicks, timeTicksWithUnit, type TimeTickUnit } from "./series-chart-ticks";
55
+ // The tick module stays internal; the one type of it a public prop names is re-exported.
56
+ export type { TimeTickUnit } from "./series-chart-ticks";
28
57
  import { paletteFor } from "../theme/chart-palette";
29
58
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
30
59
  import { cn } from "../lib/cn";
@@ -41,16 +70,99 @@ export interface SeriesChartSeries {
41
70
  * reference against the measurement. Shorthand for `dash: 1`. */
42
71
  dashed?: boolean;
43
72
  /** Which of {@link STROKE_PATTERNS} this line takes, for charts where the stroke says
44
- * WHICH QUANTITY and the colour says WHICH MEASUREMENT. */
45
- dash?: number;
73
+ * WHICH QUANTITY and the colour says WHICH MEASUREMENT.
74
+ *
75
+ * Or an SVG `stroke-dasharray` string of the caller's own, for a pattern the five do
76
+ * not have: keksdose's `"4 3"`, a tighter dash than the ladder's `"5 4"` that its
77
+ * hand-drawn recharts lines used before the kit. The legend
78
+ * entry ({@link seriesLegendEntries}) carries the same string, so it draws the same
79
+ * pattern. */
80
+ dash?: number | string;
46
81
  /** `stepAfter`, for a whole-number channel that jumps rather than travels — a
47
82
  * straight line between index 0 and index 1 draws an index of 0.5, which does not
48
83
  * exist. Drawn in the {@link STEP_DASH} pattern, whatever `dash` says. */
49
84
  step?: boolean;
50
85
  /** Which of `axes` this line is measured on. Default: `"y"`, the single one. */
51
86
  axis?: string;
87
+ /**
88
+ * The mark. `"line"` (the default) is what every chart before 0.8.0 drew. `"bar"` and
89
+ * `"area"` are read as a LENGTH from zero, so the axis they stand on always includes
90
+ * zero (see `SeriesChartAxis.includeZero`) and the zoom treats them differently (see
91
+ * `defaultZoomAxes`). Bars without a `stack` stand side by side in each slot — the
92
+ * grouped bars of keksdose's payee report.
93
+ */
94
+ type?: SeriesChartType;
95
+ /**
96
+ * Bars or areas with the same `stack` are drawn on top of each other and the axis is
97
+ * fitted to their SUM (positive and negative layers apart, like recharts'
98
+ * `stackOffset="sign"`). keksdose's spending-by-category area. Ignored on a line:
99
+ * recharts cannot stack one.
100
+ */
101
+ stack?: string;
102
+ /**
103
+ * Marks on the line's (or area's) points. `true` rings every sample — the price
104
+ * history, where a shop has a value only on the days somebody shopped there and the
105
+ * dots are the only thing saying which points were MEASURED. A function decides per
106
+ * point: `true` for the default dot, `false`/`null` for none, or any SVG node drawn
107
+ * as-is at `point.cx`/`point.cy` — a buy/sell marker on keksdose's paper price.
108
+ * Never called for a point with no value. Bars have no points, and ignore it.
109
+ */
110
+ dot?: boolean | ((point: SeriesChartPoint) => ReactNode);
111
+ /** Line (or area outline) weight in px. Default 2, or 1.5 for a `step`. keksdose
112
+ * draws the subject of a chart at 2.5 and the reference it is read against at 1.5. */
113
+ strokeWidth?: number;
114
+ /** `"linear"` for a line that must not look smoothed — keksdose's cash-buffer
115
+ * PROJECTION, a straight extrapolation that a monotone curve would dress up as data.
116
+ * Default `"monotone"`; `step` wins over both. */
117
+ curve?: "monotone" | "linear";
118
+ /** An area's or bar's fill opacity. Default: 1 for a bar, 0.55 for a stacked area
119
+ * (the layers must stay tellable apart where they meet) and 0.2 for a single one. */
120
+ fillOpacity?: number;
121
+ /**
122
+ * The dot that follows the pointer along a line or an area. `false` takes it off a
123
+ * series that is not a measurement — keksdose's cash-buffer PROJECTION, where a hover
124
+ * ring on the extrapolated line reads as a sampled value (`cash-buffer-chart.tsx`).
125
+ * `{ r }` sets its radius. Default: recharts' dot. Bars have none, and ignore it.
126
+ */
127
+ activeDot?: boolean | { r?: number };
52
128
  }
53
129
 
130
+ /** What a series draws. See {@link SeriesChartSeries.type}. */
131
+ export type SeriesChartType = "line" | "bar" | "area";
132
+
133
+ /** One point of a series, as a `dot` function is handed it. */
134
+ export interface SeriesChartPoint {
135
+ /** The series' key. */
136
+ key: string;
137
+ /** The row's position in `rows`. */
138
+ index: number;
139
+ /** The caller's own row, with everything the chart did not plot still in it. */
140
+ row: SeriesChartRow;
141
+ /** The abscissa in the caller's terms: the number, the category, or (time) a Date. */
142
+ x: SeriesChartXValue;
143
+ value: number;
144
+ /** Where the point is drawn, in the chart's pixels. */
145
+ cx: number;
146
+ cy: number;
147
+ /** The series' paint, `var(--color-<key>)`. */
148
+ color: string;
149
+ }
150
+
151
+ /** A row of a series chart: one abscissa value and the series' values at it. Anything
152
+ * else in it (a raw ISO period for a drilldown) rides along untouched and is handed
153
+ * back in {@link SeriesChartPoint.row} and {@link SeriesChartHit.row}. */
154
+ export type SeriesChartRow = Readonly<Record<string, unknown>>;
155
+
156
+ /** An abscissa value: a number (`x.type: "number"`), a category (a string, a number or
157
+ * a Date standing for a period), or a time (a Date, epoch ms or an ISO string). */
158
+ export type SeriesChartXValue = number | string | Date;
159
+
160
+ /** Ticks a caller decides — a list, or a function of the domain on show, so a zoom
161
+ * window gets ticks from the same rule instead of none. */
162
+ export type SeriesChartTickValues<T> =
163
+ | readonly T[]
164
+ | ((domain: [number, number]) => readonly T[] | undefined);
165
+
54
166
  interface SeriesChartAxisShape {
55
167
  id: string;
56
168
  /**
@@ -75,6 +187,21 @@ interface SeriesChartAxisShape {
75
187
  * tooltip gets it from `valueFormat`. Default: `Intl.NumberFormat` in the kit's
76
188
  * locale, up to two fraction digits. */
77
189
  format?: (value: number) => string;
190
+ /**
191
+ * The tick values, instead of the round ones the chart picks. For a grid the DATA
192
+ * dictates — keksdose's price axes tick on whole cents (`pricePaddedDomain`), where a
193
+ * 1/2/5 ladder would print €1.25 under a formatter that can only say €1.3. Values
194
+ * outside the domain on show are dropped; a function is asked again for every zoom
195
+ * window, so the cent grid can refine with it.
196
+ */
197
+ tickValues?: SeriesChartTickValues<number>;
198
+ /**
199
+ * Keep zero inside the fitted band. On by default for an axis carrying a bar or an
200
+ * area — their length IS the value, and a truncated baseline draws 40 as three times
201
+ * 20 — and off for lines, whose shape is the point (a stock price anchored at zero is
202
+ * a hairline). A pinned `domain` is left alone either way.
203
+ */
204
+ includeZero?: boolean;
78
205
  }
79
206
 
80
207
  /**
@@ -89,21 +216,110 @@ interface SeriesChartAxisShape {
89
216
  export type SeriesChartAxis = SeriesChartAxisShape &
90
217
  ({ title: string; hide?: false } | { title?: string; hide: true });
91
218
 
219
+ /**
220
+ * What a tick or the tooltip heading of the abscissa is handed besides its position —
221
+ * the value in the caller's own terms, which on a category or time axis is not the
222
+ * number the plot runs on.
223
+ */
224
+ export interface SeriesChartXTick {
225
+ /** The number itself, the row's category, or (time) a Date. */
226
+ value: SeriesChartXValue;
227
+ /** The slot, on a category axis: the row's index in `rows`. */
228
+ index?: number;
229
+ /** On a time axis: what one step of the ticks on show is, so a label can say as much
230
+ * as it means — a month start as "Mar 26", a midnight as "14 Mar". */
231
+ unit?: TimeTickUnit;
232
+ }
233
+
92
234
  export interface SeriesChartX {
235
+ /**
236
+ * What the abscissa is.
237
+ *
238
+ * - `"number"` (the default) — a number line, and all a chart before 0.8.0 could draw.
239
+ * - `"category"` — PERIODS or names, one evenly spaced slot per row, in row order:
240
+ * keksdose's months, quarters and budget categories, which it used to hand recharts
241
+ * preformatted. The values stay the caller's own — `"2026-05"`, a Date for the
242
+ * month, a category name — and need not be sortable, unique or numeric. The plot
243
+ * runs on the slot INDEX, which is what lets it zoom: a drag across the third to the
244
+ * ninth month shows those months, a tick on each whole slot and the bars clipped at
245
+ * the edge. What else zooms depends on the MARKS, not the axis — see `zoomAxes`.
246
+ * - `"time"` — real TIME: a Date, epoch ms or an ISO string per row, placed by when
247
+ * it happened. keksdose's price history and cash buffer, where a slot per row would
248
+ * draw two receipts a day apart and two five months apart the same width. Ticks fall
249
+ * on calendar boundaries (midnights, Mondays, month and quarter starts, new years)
250
+ * in local time; a date-only ISO string is LOCAL midnight, not the UTC one
251
+ * `new Date("2026-05-01")` gives, which lands on the previous day west of Greenwich.
252
+ * Zooms like a number line.
253
+ */
254
+ type?: "number" | "category" | "time";
93
255
  /** Row key of the abscissa. Default `"x"`, which is what {@link mergeSeries} writes. */
94
256
  key?: string;
95
257
  /** What the abscissa is, with its unit. Drawn under the axis. */
96
258
  title?: string;
97
- /** A tick, formatted — bare, like the y axes'. */
98
- format?: (value: number) => string;
259
+ /**
260
+ * A tick, formatted — bare, like the y axes'.
261
+ *
262
+ * `value` is the POSITION on the number line the plot runs on — the number itself,
263
+ * epoch ms, or the slot index — so the one-argument formatters every chart before
264
+ * 0.8.0 passes keep their meaning, and a time axis' `(ms) => …` is the tickFormatter
265
+ * keksdose already has. The caller's own value (the category, the Date) is `tick.value`.
266
+ * Default: the kit's number format; a category as is (a number formatted, a Date as a
267
+ * medium date); a time as much of the date as the tick step means.
268
+ */
269
+ format?: (value: number, tick: SeriesChartXTick) => string;
99
270
  /**
100
271
  * Whether this chart prints the abscissa's ticks. On by default; off for every chart
101
272
  * of a stack sharing one x window but the bottom one, which prints them for all. It
102
273
  * costs no alignment: the x mapping is decided by the margins and the y bands.
103
274
  */
104
275
  ticks?: boolean;
105
- /** The tooltip's heading. Default: the abscissa value through `format`. */
106
- label?: (value: number) => ReactNode;
276
+ /** The tooltip's heading. Default: `format` — or, on a time axis, a medium date (with
277
+ * the time for hourly data), since a tick's "Mar 26" is not which day it was. */
278
+ label?: (value: number, tick: SeriesChartXTick) => ReactNode;
279
+ /** The ticks instead of the automatic ones, in the caller's terms: numbers, the
280
+ * categories to label, or instants. See `SeriesChartAxis.tickValues`. */
281
+ tickValues?: SeriesChartTickValues<SeriesChartXValue>;
282
+ /**
283
+ * Tilt the tick labels by this many degrees — negative rises to the right. For long
284
+ * category names that do not fit side by side: keksdose's budget performance draws its
285
+ * categories at −45° (`budget-performance-tab.tsx`). The label is anchored by its END
286
+ * for a negative angle and its START for a positive one, so it hangs off the tick
287
+ * instead of being centred across it; the anchor is physical, like the plot (see
288
+ * `ChartContainer`), so it is the same in RTL. The band under the axis grows to the
289
+ * rotated height of the longest label on show, estimated from its length (at most
290
+ * 120 px, past which a label is clipped). Default 0.
291
+ */
292
+ tickAngle?: number;
293
+ /**
294
+ * On a `"number"` axis, tick only on whole numbers: a step of 1 at the least, so a
295
+ * five-point index series reads 0 1 2 3 4 rather than 0 0.5 1 … 4 — ticks at
296
+ * positions no row can have (keksdose's short step series). A zoom window that holds
297
+ * no whole number falls back to the ordinary ticks rather than to none.
298
+ *
299
+ * AUTOMATIC by default — on when every row's x is a whole number — because that is
300
+ * exactly the case the half ticks are wrong in, and it changes nothing elsewhere: a
301
+ * span wider than about eight already ticks on whole steps. `false` for a continuous
302
+ * quantity that merely happens to be sampled on whole numbers (1 Hz steps) and whose
303
+ * zoom should still be ruled in fractions; `true` to force it over rows that are not.
304
+ * Ignored on category (already one tick per slot) and time axes.
305
+ */
306
+ integerTicks?: boolean;
307
+ }
308
+
309
+ /** Room for the tooltip beyond the chart — see {@link SeriesChartProps.tooltip}. */
310
+ export interface SeriesChartTooltip {
311
+ /** Let the tooltip run past the chart's own box on that axis instead of recharts
312
+ * clamping it inside. Default: `{ x: true }` when `boundary` is set, else neither. */
313
+ allowEscapeViewBox?: { x?: boolean; y?: boolean };
314
+ /**
315
+ * The element that actually CLIPS the chart — usually a horizontal-scroll wrapper
316
+ * around a chart wider than the card. The tooltip then flips to the left of the cursor
317
+ * when it would spill past that element's visible right edge (see
318
+ * `ChartTooltipContent`'s `boundaryRef`), rather than against the wide chart's far edge
319
+ * the reader has not scrolled to. keksdose's budget performance, which keeps a scroll
320
+ * wrapper and a per-category `minWidth` (`budget-performance-tab.tsx`).
321
+ */
322
+ boundary?: RefObject<HTMLElement | null>;
107
323
  }
108
324
 
109
325
  /**
@@ -124,9 +340,99 @@ export interface SeriesChartSpan {
124
340
  color?: string;
125
341
  }
126
342
 
343
+ /** The colours a reference or a marker can take by name — the kit's semantic tokens,
344
+ * so a threshold reads the same on every chart and flips with the theme. */
345
+ export type SeriesChartTone =
346
+ | "muted"
347
+ | "brand"
348
+ | "income"
349
+ | "expense"
350
+ | "net"
351
+ | "success"
352
+ | "warning"
353
+ | "danger"
354
+ | "info";
355
+
356
+ const TONE_COLOR: Record<SeriesChartTone, string> = {
357
+ muted: "var(--text-muted)",
358
+ brand: "var(--brand)",
359
+ income: "var(--money-income)",
360
+ expense: "var(--money-expense)",
361
+ net: "var(--money-net)",
362
+ success: "var(--success)",
363
+ warning: "var(--warning)",
364
+ danger: "var(--danger)",
365
+ info: "var(--info)",
366
+ };
367
+
368
+ /**
369
+ * A line across the whole plot at one value: a threshold, a target, an event.
370
+ *
371
+ * keksdose's cash buffer draws "one month of runway" across at y = 30 and the projected
372
+ * depletion date down at its x. Unlike a {@link SeriesChartSpan}, which closes a shape
373
+ * in data coordinates, a reference spans the plot, whatever the zoom.
374
+ *
375
+ * A reference is part of the FITTED band: one outside the data widens the axis to show
376
+ * it, because a threshold the reader cannot see is not a threshold. A pinned domain or a
377
+ * zoom window does not widen — there it is discarded when outside, like a span.
378
+ */
379
+ export interface SeriesChartReference {
380
+ /** Distinct within the chart. Default: its position in the list. */
381
+ key?: string;
382
+ /** `"x"` for a vertical line at an abscissa, otherwise the id of the y axis the value
383
+ * is measured on. Default `"y"`, the single one — so no y axis may be called `"x"`. */
384
+ axis?: string;
385
+ /** An abscissa in the x axis' own terms (a category, a Date) for `axis: "x"`, a
386
+ * number on that axis otherwise. A category the rows do not have draws nothing. */
387
+ value: SeriesChartXValue;
388
+ /** Written along the line, inside the plot at its top-left. SVG text: a string. */
389
+ label?: string;
390
+ /** Default `"muted"`. `color` wins over it. */
391
+ tone?: SeriesChartTone;
392
+ color?: string;
393
+ /** Which of `STROKE_PATTERNS`. Default 1, dashed: a reference is not data, and a
394
+ * solid line reads as a series nobody put in the legend. */
395
+ dash?: number;
396
+ }
397
+
398
+ /**
399
+ * A single labelled point — today's figure on keksdose's cash buffer, the month the
400
+ * projection runs dry. For a mark on EVERY sample, or on the samples that meet a test,
401
+ * use the series' `dot` instead; a marker is for a point that is a fact of its own.
402
+ * Fitted like a reference: it widens the band it would otherwise fall outside of.
403
+ */
404
+ export interface SeriesChartMarker {
405
+ /** Distinct within the chart. Default: its position in the list. */
406
+ key?: string;
407
+ /** In the x axis' own terms, like a reference's `value`. */
408
+ x: SeriesChartXValue;
409
+ y: number;
410
+ /** The y axis `y` is measured on. Default `"y"`. */
411
+ axis?: string;
412
+ /** Written above the point. */
413
+ label?: string;
414
+ /** Default `"brand"`. `color` wins over it. */
415
+ tone?: SeriesChartTone;
416
+ color?: string;
417
+ /** Radius in px. Default 4.5. */
418
+ r?: number;
419
+ }
420
+
421
+ /** What a click on the plot picked. */
422
+ export interface SeriesChartHit {
423
+ /** The row's position in `rows`. */
424
+ index: number;
425
+ /** The caller's own row — with its `rawPeriod`, id or whatever the drilldown needs. */
426
+ row: SeriesChartRow;
427
+ x: SeriesChartXValue;
428
+ /** The series, when the click landed ON a bar. A click anywhere else picks the slot
429
+ * (the period), not a series. */
430
+ key?: string;
431
+ }
432
+
127
433
  export interface SeriesChartProps {
128
434
  /** One row per abscissa value. A missing key is a hole (see `connectNulls`). */
129
- rows: Record<string, number>[];
435
+ rows: readonly SeriesChartRow[];
130
436
  series: SeriesChartSeries[];
131
437
  /** Default: one untitled axis, `"y"`. */
132
438
  axes?: SeriesChartAxis[];
@@ -134,9 +440,13 @@ export interface SeriesChartProps {
134
440
  /** A value in the tooltip, where a number stands on its own and needs its unit.
135
441
  * Default: `Intl.NumberFormat` in the kit's locale. */
136
442
  valueFormat?: (value: number) => string;
137
- /** Tailwind height class. Default `h-72`. The empty state takes it too, so a chart
138
- * losing its last line does not relayout the page under it. */
139
- height?: string;
443
+ /** Tailwind height class, or a height in pixels. Default `h-72`. The empty state
444
+ * takes it too, so a chart losing its last line does not relayout the page under it.
445
+ * A number is for a height the caller holds as a number — keksdose's report charts
446
+ * take `height = 260` as a prop and wrap the chart in a `SizedSeriesChart` div
447
+ * (reports/charts/networth-line.tsx) only to turn it into a style, since a class
448
+ * cannot be built from a number Tailwind never saw. */
449
+ height?: string | number;
140
450
  /** Bridge holes left by sources sampled on different grids. See {@link mergeSeries}. */
141
451
  connectNulls?: boolean;
142
452
  /** Shown, centred at the chart's height, instead of an empty chart. Default: the
@@ -149,6 +459,26 @@ export interface SeriesChartProps {
149
459
  */
150
460
  animationMs?: number;
151
461
  spans?: SeriesChartSpan[];
462
+ /** Lines across the plot at one value. */
463
+ references?: SeriesChartReference[];
464
+ /** Single labelled points. */
465
+ markers?: SeriesChartMarker[];
466
+ /**
467
+ * Which axes a drag may zoom: `"both"`, `"x"`, `"y"` or `"none"`. Default: read off
468
+ * the marks — see `defaultZoomAxes` (lines both, areas x, bars none). An axis carrying
469
+ * bars or areas never takes a DRAGGED y window: it refits to the x window, from zero.
470
+ * Ignored by `StaticSeriesChart`, which does not zoom.
471
+ */
472
+ zoomAxes?: ZoomAxesSetting;
473
+ /**
474
+ * A click on the plot: the slot under the pointer, and the series when it landed on a
475
+ * bar. keksdose's drilldowns — a payee's month, a budget line, a spending category.
476
+ * A drag that zoomed is not a click.
477
+ */
478
+ onPointClick?: (hit: SeriesChartHit) => void;
479
+ /** Where the tooltip may go: for a chart inside a scroll wrapper. See
480
+ * {@link SeriesChartTooltip}. */
481
+ tooltip?: SeriesChartTooltip;
152
482
  /** Supplied by `withChartZoom` and by nothing else. */
153
483
  zoom?: ZoomBinding;
154
484
  /** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */
@@ -167,6 +497,29 @@ export const AXIS_TITLE_STRIP = 16;
167
497
  /** Where inside its strip the rotated title sits. */
168
498
  const AXIS_TITLE_OFFSET = 10;
169
499
 
500
+ /** The tallest band tilted x ticks reserve, in px. A category name longer than this at
501
+ * its angle is clipped rather than squeezing the plot down to a strip. */
502
+ const MAX_TILTED_TICK_BAND = 120;
503
+
504
+ /** What one character of a tick label is taken to be, in px, for the tilted band — the
505
+ * shell's `text-xs`. An estimate: the SVG is not laid out yet when the band is decided. */
506
+ const TICK_CHAR_WIDTH = 7;
507
+ const TICK_LINE_HEIGHT = 12;
508
+ /** recharts' gap between the axis line and a bottom tick's text. */
509
+ const TICK_GAP = 8;
510
+
511
+ /**
512
+ * The height the x axis reserves for ticks tilted by `angle` degrees: the rotated box of
513
+ * the longest label, from its length. `undefined` for level ticks — recharts' own 30 px.
514
+ */
515
+ function tiltedTickBand(labels: readonly string[], angle: number): number | undefined {
516
+ if (!angle) return undefined;
517
+ const rad = (Math.abs(angle) * Math.PI) / 180;
518
+ const longest = Math.max(0, ...labels.map((label) => label.length)) * TICK_CHAR_WIDTH;
519
+ const band = Math.sin(rad) * longest + Math.cos(rad) * TICK_LINE_HEIGHT + TICK_GAP;
520
+ return Math.min(MAX_TILTED_TICK_BAND, Math.max(30, Math.ceil(band)));
521
+ }
522
+
170
523
  /** The width the ticks of a y axis get when a caller does not say. */
171
524
  export const AXIS_TICK_WIDTH = 48;
172
525
 
@@ -275,6 +628,58 @@ export function seriesKey(...parts: (string | number)[]): string {
275
628
  return /^[A-Za-z_]/.test(key) ? key : `_${key}`;
276
629
  }
277
630
 
631
+ /**
632
+ * The band an axis carrying bars or areas draws: the data's extent with ZERO in it,
633
+ * padded only on the sides that are not zero — bars standing on a frame's bottom edge,
634
+ * not floating four per cent above it on a band of air nobody asked for.
635
+ * `undefined` when there is nothing to fit.
636
+ */
637
+ export function anchoredBand(extent: readonly [number, number] | undefined): [number, number] | undefined {
638
+ if (!extent) return undefined;
639
+ const low = Math.min(0, extent[0]);
640
+ const high = Math.max(0, extent[1]);
641
+ const pad = (high - low) * AUTO_PAD || 1;
642
+ return [low < 0 ? low - pad : 0, high > 0 ? high + pad : 0];
643
+ }
644
+
645
+ /**
646
+ * Series with their colours settled BEFORE any are switched off.
647
+ *
648
+ * A series with no `color` takes `paletteFor(its position)` — and a legend toggle that
649
+ * hands the chart a shorter list moves every later series one position up, repainting
650
+ * it in its neighbour's colour on each click. Resolving the colours on the full list
651
+ * and filtering after keeps each series in the colour its legend entry shows.
652
+ */
653
+ export function visibleSeries(
654
+ series: readonly SeriesChartSeries[],
655
+ hidden: ReadonlySet<string>,
656
+ ): SeriesChartSeries[] {
657
+ return series
658
+ .map((entry, index) => ({ ...entry, color: entry.color ?? paletteFor(index) }))
659
+ .filter((entry) => !hidden.has(entry.key));
660
+ }
661
+
662
+ /**
663
+ * The `ToggleLegend` entries for a chart's FULL series list — colours resolved the way
664
+ * the chart resolves them, and a stroke mark for a line drawn in a pattern (dashed,
665
+ * step) so the key promises the stroke the plot draws. A bar or an area is a swatch.
666
+ * Pair with {@link visibleSeries} for the chart itself.
667
+ */
668
+ export function seriesLegendEntries(series: readonly SeriesChartSeries[]): LegendEntry[] {
669
+ return series.map((entry, index) => {
670
+ // A custom dash array goes to the legend as it is, so the swatch draws what the plot does.
671
+ const own = entry.dash ?? (entry.dashed ? 1 : 0);
672
+ const dash = entry.step ? STEP_DASH : own;
673
+ const line = (entry.type ?? "line") === "line";
674
+ return {
675
+ key: entry.key,
676
+ label: entry.label,
677
+ color: entry.color ?? paletteFor(index),
678
+ ...(line && dash !== 0 ? { marker: "stroke" as const, dash } : {}),
679
+ };
680
+ });
681
+ }
682
+
278
683
  /** The number format every default falls back to. */
279
684
  function useDefaultFormat(localeProp: string | undefined): (value: number) => string {
280
685
  const locale = useKitLocale(localeProp);
@@ -287,14 +692,145 @@ function useDefaultFormat(localeProp: string | undefined): (value: number) => st
287
692
  /** The Y-axis list a chart draws when the caller gave none. */
288
693
  const ONE_UNTITLED_AXIS: SeriesChartAxis[] = [{ id: DEFAULT_Y_AXIS, title: "" }];
289
694
 
695
+ /** Where a category or time chart keeps each row's plotted position. Not the caller's
696
+ * key: that one still holds their own value, which `format` and a hit hand back. */
697
+ const PLOTTED_X = "__seriesChartX";
698
+
699
+ /** A finite number, or `undefined` — the only thing a row cell counts as a sample. */
700
+ const finite = (value: unknown): number | undefined =>
701
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
702
+
703
+ /** A time value as epoch ms. A date-only ISO string is LOCAL midnight (see
704
+ * `SeriesChartX.type`); anything unreadable is `undefined`, i.e. not plotted. */
705
+ function toTime(value: unknown): number | undefined {
706
+ if (value instanceof Date) return finite(value.getTime());
707
+ if (typeof value === "number") return finite(value);
708
+ if (typeof value !== "string") return undefined;
709
+ const day = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(value);
710
+ if (day) return new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3] ?? 1)).getTime();
711
+ return finite(Date.parse(value));
712
+ }
713
+
714
+ /** A category's identity, so a Date matches another Date of the same instant. */
715
+ const categoryId = (value: unknown) =>
716
+ value instanceof Date ? `d:${value.getTime()}` : `${typeof value}:${String(value)}`;
717
+
290
718
  /**
291
- * {@link SeriesChart} without the zoom: the same picture, no drag layer, no reset
292
- * button. For a thumbnail, a print view, or a chart the consumer wraps in its own
293
- * interaction.
719
+ * The abscissa, reduced to a number line — which is all the plot, the ticks and the
720
+ * zoom ever deal in. A number stays itself; a category becomes its slot index; a time
721
+ * its epoch ms. Everything the caller sees (ticks, tooltip, hits) is turned back.
294
722
  */
295
- export function StaticSeriesChart({
296
- rows,
297
- x = {},
723
+ interface XModel {
724
+ kind: "number" | "category" | "time";
725
+ /** The caller's key, still holding their value. */
726
+ sourceKey: string;
727
+ /** The key the plot reads. The same as `sourceKey` on a number line. */
728
+ plotKey: string;
729
+ /** The rows as plotted. The caller's own on a number line. */
730
+ rows: readonly SeriesChartRow[];
731
+ /** The caller's rows, which dots and hits hand back. */
732
+ source: readonly SeriesChartRow[];
733
+ position: (value: unknown) => number | undefined;
734
+ valueAt: (position: number) => SeriesChartXValue;
735
+ }
736
+
737
+ function xModel(rows: readonly SeriesChartRow[], x: SeriesChartX): XModel {
738
+ const sourceKey = x.key ?? "x";
739
+ if (x.type === "category") {
740
+ const slots = new Map<string, number>();
741
+ rows.forEach((row, index) => {
742
+ const id = categoryId(row[sourceKey]);
743
+ if (!slots.has(id)) slots.set(id, index);
744
+ });
745
+ return {
746
+ kind: "category",
747
+ sourceKey,
748
+ plotKey: PLOTTED_X,
749
+ rows: rows.map((row, index) => ({ ...row, [PLOTTED_X]: index })),
750
+ source: rows,
751
+ position: (value) => slots.get(categoryId(value)),
752
+ valueAt: (position) => rows[Math.round(position)]?.[sourceKey] as SeriesChartXValue,
753
+ };
754
+ }
755
+ if (x.type === "time") {
756
+ return {
757
+ kind: "time",
758
+ sourceKey,
759
+ plotKey: PLOTTED_X,
760
+ rows: rows.map((row) => ({ ...row, [PLOTTED_X]: toTime(row[sourceKey]) })),
761
+ source: rows,
762
+ position: toTime,
763
+ valueAt: (position) => new Date(position),
764
+ };
765
+ }
766
+ return {
767
+ kind: "number",
768
+ sourceKey,
769
+ plotKey: sourceKey,
770
+ rows,
771
+ source: rows,
772
+ position: finite,
773
+ valueAt: (position) => position,
774
+ };
775
+ }
776
+
777
+ /** Caller ticks, as positions inside the domain on show. */
778
+ function resolveTicks<T>(
779
+ values: SeriesChartTickValues<T> | undefined,
780
+ domain: [number, number] | undefined,
781
+ position: (value: T) => number | undefined,
782
+ ): number[] | undefined {
783
+ if (!values || !domain) return undefined;
784
+ const list = typeof values === "function" ? values(domain) : values;
785
+ if (!list) return undefined;
786
+ const slack = (domain[1] - domain[0]) * 1e-9;
787
+ return list
788
+ .map(position)
789
+ .filter((at): at is number => at !== undefined && at >= domain[0] - slack && at <= domain[1] + slack);
790
+ }
791
+
792
+ /** A low/high pair widened by some values, or `undefined` if there is still none. */
793
+ function extend(
794
+ span: readonly [number, number] | undefined,
795
+ values: readonly (number | undefined)[],
796
+ ): [number, number] | undefined {
797
+ let out: [number, number] | undefined = span ? [span[0], span[1]] : undefined;
798
+ for (const value of values) {
799
+ if (value === undefined || !Number.isFinite(value)) continue;
800
+ out = out ? [Math.min(out[0], value), Math.max(out[1], value)] : [value, value];
801
+ }
802
+ return out;
803
+ }
804
+
805
+ /** How a time tick is written, by what one step of the ticks is. */
806
+ const TIME_TICK_FORMAT: Record<TimeTickUnit, Intl.DateTimeFormatOptions> = {
807
+ hour: { hour: "2-digit", minute: "2-digit" },
808
+ day: { day: "numeric", month: "short" },
809
+ month: { month: "short", year: "2-digit" },
810
+ year: { year: "numeric" },
811
+ };
812
+
813
+ /** The two numbers every mark type needs to agree on for the chart to read as one. */
814
+ const LINE_WIDTH = 2;
815
+ const STEP_WIDTH = 1.5;
816
+
817
+ /** Bars round their FREE end only, and a stacked bar has none but the top layer's —
818
+ * which recharts cannot tell apart per cell, so stacks stay square. */
819
+ const BAR_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
820
+
821
+ interface PlotProps extends Omit<SeriesChartProps, "rows" | "x"> {
822
+ /** The rows AS PLOTTED — what the zoom fits on. The caller's are `model.source`. */
823
+ rows: readonly SeriesChartRow[];
824
+ /** What the zoom fits on: the plotted key. */
825
+ x: { key: string };
826
+ model: XModel;
827
+ xAxis: SeriesChartX;
828
+ }
829
+
830
+ /** The chart proper, over an abscissa already reduced to numbers (see {@link XModel}). */
831
+ function SeriesPlot({
832
+ model,
833
+ xAxis,
298
834
  series,
299
835
  axes = ONE_UNTITLED_AXIS,
300
836
  valueFormat,
@@ -303,20 +839,33 @@ export function StaticSeriesChart({
303
839
  empty,
304
840
  animationMs = 0,
305
841
  spans,
842
+ references,
843
+ markers,
844
+ onPointClick,
845
+ tooltip,
306
846
  zoom,
307
847
  labels: labelsProp,
308
- locale,
848
+ locale: localeProp,
309
849
  className,
310
- }: SeriesChartProps) {
850
+ }: PlotProps) {
311
851
  const labels = useKitLabels("seriesChart", DEFAULT_SERIES_CHART_LABELS, labelsProp);
312
- const number = useDefaultFormat(locale);
852
+ const locale = useKitLocale(localeProp);
853
+ const number = useDefaultFormat(localeProp);
854
+
855
+ // A number is pixels, set inline; a string is a class. Either way the one value
856
+ // sizes both the chart and its empty state.
857
+ const heightClass = typeof height === "string" ? height : undefined;
858
+ const heightStyle = typeof height === "number" ? { height } : undefined;
313
859
 
314
860
  // The empty state takes the chart's own height and is centred in it: a chart that
315
861
  // shrank to its "nothing to draw" sentence moved everything under it up the page
316
862
  // the moment a legend entry was switched off.
317
- if (!rows.length || !series.length) {
863
+ if (!model.source.length || !series.length) {
318
864
  return (
319
- <div className={cn("flex w-full items-center justify-center px-2 text-center", height)}>
865
+ <div
866
+ className={cn("flex w-full items-center justify-center px-2 text-center", heightClass)}
867
+ style={heightStyle}
868
+ >
320
869
  {empty !== undefined ? (
321
870
  empty
322
871
  ) : (
@@ -326,8 +875,10 @@ export function StaticSeriesChart({
326
875
  );
327
876
  }
328
877
 
329
- const xKey = x.key ?? "x";
330
- const xFormat = x.format ?? number;
878
+ const x = xAxis;
879
+ const xKey = model.plotKey;
880
+ const plotted = model.rows;
881
+ const rows = model.source;
331
882
  const config: ChartConfig = Object.fromEntries(
332
883
  series.map((entry, index) => [
333
884
  entry.key,
@@ -346,29 +897,199 @@ export function StaticSeriesChart({
346
897
  // gutter on its side, so a margin beside one is a second gutter. What remains is the
347
898
  // overhang of the first/last x tick where no y axis covers it.
348
899
  const margin = {
349
- top: 8,
900
+ // Room for a marker's label over a point at the top of the band.
901
+ top: markers?.some((marker) => marker.label) ? 22 : 8,
350
902
  right: onRight ? 0 : 10,
351
903
  left: onLeft ? 0 : 10,
352
904
  // Under the ticks, not under the whole axis: whatever comes next owns the gap.
353
905
  bottom: x.title ? 18 : 2,
354
906
  };
355
907
 
908
+ const refs = references ?? [];
909
+ const xRefs = refs.filter((ref) => ref.axis === "x");
910
+ const yRefs = refs.filter((ref) => ref.axis !== "x");
911
+ const xPos = (value: SeriesChartXValue) => model.position(value);
912
+
356
913
  // Fitted with air around it, unless the reader has zoomed or the caller pinned it.
357
914
  // Ticks are the round values INSIDE whichever domain that is (see
358
915
  // `series-chart-ticks.ts`), so a zoom window still gets round numbers, just finer.
359
- const fittedX = zoom?.xDomain ?? paddedDomain(rows.map((row) => row[xKey]));
360
- const fittedY = (axis: SeriesChartAxis) =>
361
- zoom?.yDomains[axis.id] ??
362
- axis.domain ??
363
- paddedDomain(
364
- series
365
- .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)
366
- .flatMap((entry) => rows.map((row) => row[entry.key])),
916
+ // A category axis is its slots, each half a slot of air either side — room for a
917
+ // bar, and the tick under the middle of it.
918
+ const fittedX: [number, number] | undefined =
919
+ zoom?.xDomain ??
920
+ (model.kind === "category"
921
+ ? [-0.5, rows.length - 0.5]
922
+ : padBand(
923
+ ...(extend(
924
+ undefined,
925
+ [
926
+ ...plotted.map((row) => finite(row[xKey])),
927
+ ...xRefs.map((ref) => xPos(ref.value)),
928
+ ...(markers ?? []).map((marker) => xPos(marker.x)),
929
+ ],
930
+ ) ?? [Infinity, -Infinity]),
931
+ ));
932
+
933
+ const timeUnit = model.kind === "time" ? timeTicksWithUnit(fittedX) : undefined;
934
+ // Whole-number ticks on a number line whose rows are all whole numbers — see
935
+ // `SeriesChartX.integerTicks`.
936
+ const integerX =
937
+ model.kind === "number" &&
938
+ (x.integerTicks ??
939
+ plotted.every((row) => {
940
+ const value = finite(row[xKey]);
941
+ return value === undefined || Number.isInteger(value);
942
+ }));
943
+ const xTicks =
944
+ resolveTicks(x.tickValues, fittedX, xPos) ??
945
+ (model.kind === "category"
946
+ ? categoryTicks(fittedX, rows.length)
947
+ : model.kind === "time"
948
+ ? timeUnit?.ticks
949
+ : integerX
950
+ ? (integerTicks(fittedX) ?? niceTicks(fittedX))
951
+ : niceTicks(fittedX));
952
+
953
+ // Ticks and the tooltip heading, back in the caller's terms.
954
+ const timeFormat = new Intl.DateTimeFormat(locale, TIME_TICK_FORMAT[timeUnit?.unit ?? "day"]);
955
+ const dateFormat = new Intl.DateTimeFormat(locale, {
956
+ dateStyle: "medium",
957
+ ...(timeUnit?.unit === "hour" ? { timeStyle: "short" } : {}),
958
+ });
959
+ const categoryText = (value: SeriesChartXValue) =>
960
+ value instanceof Date
961
+ ? dateFormat.format(value)
962
+ : typeof value === "number"
963
+ ? number(value)
964
+ : String(value ?? "");
965
+ const tickAt = (position: number): SeriesChartXTick => ({
966
+ value: model.valueAt(position),
967
+ ...(model.kind === "category" ? { index: Math.round(position) } : {}),
968
+ ...(timeUnit ? { unit: timeUnit.unit } : {}),
969
+ });
970
+ const defaultFormat = (position: number, tick: SeriesChartXTick) =>
971
+ model.kind === "category"
972
+ ? categoryText(tick.value)
973
+ : model.kind === "time"
974
+ ? timeFormat.format(position)
975
+ : number(position);
976
+ const format = x.format ?? defaultFormat;
977
+ const xFormat = (position: number) => format(position, tickAt(position));
978
+ const label =
979
+ x.label ??
980
+ (model.kind === "time" ? (position: number) => dateFormat.format(position) : format);
981
+ const xLabel = (position: number) => label(position, tickAt(position));
982
+
983
+ const tickAngle = x.ticks === false ? 0 : (x.tickAngle ?? 0);
984
+ const tickBand = tiltedTickBand((xTicks ?? []).map((tick) => xFormat(tick)), tickAngle);
985
+
986
+ const source: ZoomFitSource = {
987
+ rows: plotted,
988
+ series,
989
+ axes,
990
+ xKey,
991
+ };
992
+ // An axis a bar or an area stands on is read from zero (see `includeZero`).
993
+ const anchored = (axis: SeriesChartAxis) =>
994
+ axis.includeZero ??
995
+ series.some(
996
+ (entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id && (entry.type ?? "line") !== "line",
997
+ );
998
+ const onAxis = (axisId: string) => (ref: { axis?: string }) => (ref.axis ?? DEFAULT_Y_AXIS) === axisId;
999
+ const fittedY = (axis: SeriesChartAxis): [number, number] | undefined => {
1000
+ const own = [
1001
+ ...yRefs.filter(onAxis(axis.id)).map((ref) => finite(ref.value)),
1002
+ ...(markers ?? []).filter(onAxis(axis.id)).map((marker) => finite(marker.y)),
1003
+ ];
1004
+ if (anchored(axis)) {
1005
+ // Never a dragged y window: refitted to the x window, still from zero.
1006
+ if (zoom?.xDomain) return anchoredBand(axisExtent(source, axis.id, zoom.xDomain)) ?? axis.domain;
1007
+ return axis.domain ?? anchoredBand(extend(axisExtent(source, axis.id), own));
1008
+ }
1009
+ return (
1010
+ zoom?.yDomains[axis.id] ??
1011
+ axis.domain ??
1012
+ padBand(...(extend(axisExtent(source, axis.id), own) ?? [Infinity, -Infinity]))
367
1013
  );
1014
+ };
1015
+
1016
+ // The abscissa of a row in the caller's terms: a category is its slot, which is
1017
+ // its index; a time or a number is read back from where it was plotted.
1018
+ const xOf = (index: number): SeriesChartXValue =>
1019
+ model.kind === "category"
1020
+ ? (rows[index][model.sourceKey] as SeriesChartXValue)
1021
+ : model.valueAt(finite(plotted[index][xKey]) ?? NaN);
1022
+ const hitAt = (index: number, key?: string): SeriesChartHit | undefined => {
1023
+ const row = rows[index];
1024
+ return row ? { index, row, x: xOf(index), key } : undefined;
1025
+ };
1026
+
1027
+ const dotFor = (entry: SeriesChartSeries) => {
1028
+ const want = entry.dot;
1029
+ if (!want) return false;
1030
+ const color = `var(--color-${entry.key})`;
1031
+ return (props: { cx?: number; cy?: number; index: number }) => {
1032
+ const { cx, cy, index } = props;
1033
+ const value = finite(rows[index]?.[entry.key]);
1034
+ if (value === undefined || !Number.isFinite(cx) || !Number.isFinite(cy)) return <g />;
1035
+ const point: SeriesChartPoint = {
1036
+ key: entry.key,
1037
+ index,
1038
+ row: rows[index],
1039
+ x: xOf(index),
1040
+ value,
1041
+ cx: cx!,
1042
+ cy: cy!,
1043
+ color,
1044
+ };
1045
+ const drawn = want === true ? true : want(point);
1046
+ if (drawn === true) {
1047
+ return (
1048
+ <circle
1049
+ className="recharts-dot"
1050
+ cx={cx}
1051
+ cy={cy}
1052
+ r={3}
1053
+ fill={color}
1054
+ // Ringed in the surface, so a dot on a crossing line still reads as a dot.
1055
+ stroke="var(--bg-surface)"
1056
+ strokeWidth={1.5}
1057
+ />
1058
+ );
1059
+ }
1060
+ if (drawn === false || drawn == null) return <g />;
1061
+ return <g>{drawn}</g>;
1062
+ };
1063
+ };
1064
+
1065
+ const animation = {
1066
+ isAnimationActive: animationMs > 0,
1067
+ animationDuration: animationMs,
1068
+ animationEasing: "ease-out" as const,
1069
+ };
368
1070
 
369
1071
  return (
370
- <ChartContainer config={config} className={cn("w-full", height, className)}>
371
- <LineChart data={rows} margin={margin}>
1072
+ <ChartContainer
1073
+ config={config}
1074
+ className={cn("w-full", heightClass, className, onPointClick && "cursor-pointer")}
1075
+ style={heightStyle}
1076
+ >
1077
+ <ComposedChart
1078
+ data={plotted as Record<string, unknown>[]}
1079
+ margin={margin}
1080
+ // Positive and negative layers stacked apart, as `axisExtent` fits them — a
1081
+ // month's expenses hang below the axis instead of eating into its income.
1082
+ stackOffset="sign"
1083
+ onClick={
1084
+ onPointClick
1085
+ ? (state) => {
1086
+ const index = Number(state?.activeTooltipIndex ?? state?.activeIndex);
1087
+ const hit = Number.isInteger(index) ? hitAt(index) : undefined;
1088
+ if (hit) onPointClick(hit);
1089
+ }
1090
+ : undefined
1091
+ }
1092
+ >
372
1093
  {/* Both ways: a measurement plot is read by putting a ruler on it, and a
373
1094
  horizontal-only grid answers half of those questions. */}
374
1095
  <CartesianGrid yAxisId={gridAxis} />
@@ -376,19 +1097,24 @@ export function StaticSeriesChart({
376
1097
  dataKey={xKey}
377
1098
  // Numeric, not categorical: a measured sweep is unevenly spaced, and a
378
1099
  // category axis would straighten exactly the curvature the chart is for.
1100
+ // A category chart is numeric too — on the slot index — which is what lets
1101
+ // it zoom and keeps its ticks under the middle of each bar group.
379
1102
  type="number"
380
1103
  domain={fittedX ?? ["dataMin", "dataMax"]}
381
- ticks={niceTicks(fittedX)}
1104
+ ticks={xTicks}
382
1105
  // Clip the lines to a zoom window instead of recharts widening it back out.
383
- allowDataOverflow={zoom?.xDomain !== undefined}
1106
+ allowDataOverflow={zoom?.xDomain !== undefined || model.kind === "category"}
384
1107
  tickLine={false}
385
1108
  axisLine={false}
386
1109
  minTickGap={32}
387
1110
  tick={x.ticks === false ? false : undefined}
1111
+ {...(tickAngle
1112
+ ? { angle: tickAngle, textAnchor: tickAngle < 0 ? "end" : "start" }
1113
+ : {})}
388
1114
  // With neither ticks nor title there is nothing to reserve the band for, and
389
1115
  // recharts' own 30 px would leave a gap under every chart of a stack.
390
- height={x.ticks === false && !x.title ? 4 : undefined}
391
- tickFormatter={xFormat}
1116
+ height={x.ticks === false && !x.title ? 4 : tickBand}
1117
+ tickFormatter={(value: number) => xFormat(Number(value))}
392
1118
  >
393
1119
  {x.title && (
394
1120
  <Label
@@ -401,14 +1127,17 @@ export function StaticSeriesChart({
401
1127
  </XAxis>
402
1128
  {axes.map((axis) => {
403
1129
  const domain = fittedY(axis);
1130
+ const zoomed = anchored(axis)
1131
+ ? zoom?.xDomain !== undefined
1132
+ : zoom?.yDomains[axis.id] !== undefined;
404
1133
  return (
405
1134
  <YAxis
406
1135
  key={axis.id}
407
1136
  yAxisId={axis.id}
408
1137
  hide={axis.hide}
409
1138
  domain={domain}
410
- ticks={niceTicks(domain)}
411
- allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}
1139
+ ticks={resolveTicks(axis.tickValues, domain, finite) ?? niceTicks(domain)}
1140
+ allowDataOverflow={zoomed}
412
1141
  orientation={axis.orientation ?? "left"}
413
1142
  width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
414
1143
  tickLine={false}
@@ -435,31 +1164,89 @@ export function StaticSeriesChart({
435
1164
  );
436
1165
  })}
437
1166
  <ChartTooltip
1167
+ {...(tooltip?.allowEscapeViewBox || tooltip?.boundary
1168
+ ? { allowEscapeViewBox: tooltip.allowEscapeViewBox ?? { x: true } }
1169
+ : {})}
1170
+ // The content shifts itself 12 px off the cursor (or flips) against the
1171
+ // boundary; recharts' own offset on top would double it.
1172
+ {...(tooltip?.boundary ? { offset: 0 } : {})}
438
1173
  content={
439
1174
  <ChartTooltipContent
440
- labelFormatter={(value) => (x.label ?? xFormat)(Number(value))}
1175
+ labelFormatter={(value) => xLabel(Number(value))}
441
1176
  valueFormatter={valueFormat ?? number}
1177
+ boundaryRef={tooltip?.boundary}
442
1178
  />
443
1179
  }
444
1180
  />
445
- {series.map((entry) => (
446
- <Line
447
- key={entry.key}
448
- yAxisId={entry.axis ?? DEFAULT_Y_AXIS}
449
- type={entry.step ? "stepAfter" : "monotone"}
450
- dataKey={entry.key}
451
- stroke={`var(--color-${entry.key})`}
452
- strokeWidth={entry.step ? 1.5 : 2}
453
- strokeDasharray={strokeDash(
454
- entry.step ? STEP_DASH : (entry.dash ?? (entry.dashed ? 1 : 0)),
455
- )}
456
- dot={false}
457
- connectNulls={connectNulls}
458
- isAnimationActive={animationMs > 0}
459
- animationDuration={animationMs}
460
- animationEasing="ease-out"
461
- />
462
- ))}
1181
+ {series.map((entry) => {
1182
+ const type = entry.type ?? "line";
1183
+ const yAxisId = entry.axis ?? DEFAULT_Y_AXIS;
1184
+ const color = `var(--color-${entry.key})`;
1185
+ const own = entry.dash ?? (entry.dashed ? 1 : 0);
1186
+ const dash = entry.step ? strokeDash(STEP_DASH) : typeof own === "string" ? own : strokeDash(own);
1187
+ const curve = entry.step ? "stepAfter" : (entry.curve ?? "monotone");
1188
+ const width = entry.strokeWidth ?? (entry.step ? STEP_WIDTH : LINE_WIDTH);
1189
+ if (type === "bar") {
1190
+ return (
1191
+ <Bar
1192
+ key={entry.key}
1193
+ yAxisId={yAxisId}
1194
+ dataKey={entry.key}
1195
+ stackId={entry.stack}
1196
+ fill={color}
1197
+ fillOpacity={entry.fillOpacity}
1198
+ radius={entry.stack === undefined ? BAR_RADIUS : 0}
1199
+ onClick={
1200
+ onPointClick
1201
+ ? (_bar, index, event) => {
1202
+ // The plot's own click would report the same slot again,
1203
+ // without the series.
1204
+ event.stopPropagation();
1205
+ const hit = hitAt(index, entry.key);
1206
+ if (hit) onPointClick(hit);
1207
+ }
1208
+ : undefined
1209
+ }
1210
+ {...animation}
1211
+ />
1212
+ );
1213
+ }
1214
+ if (type === "area") {
1215
+ return (
1216
+ <Area
1217
+ key={entry.key}
1218
+ yAxisId={yAxisId}
1219
+ type={curve}
1220
+ dataKey={entry.key}
1221
+ stackId={entry.stack}
1222
+ stroke={color}
1223
+ strokeWidth={width}
1224
+ strokeDasharray={dash}
1225
+ fill={color}
1226
+ fillOpacity={entry.fillOpacity ?? (entry.stack !== undefined ? 0.55 : 0.2)}
1227
+ dot={dotFor(entry)}
1228
+ {...(entry.activeDot !== undefined ? { activeDot: entry.activeDot } : {})}
1229
+ connectNulls={connectNulls}
1230
+ {...animation}
1231
+ />
1232
+ );
1233
+ }
1234
+ return (
1235
+ <Line
1236
+ key={entry.key}
1237
+ yAxisId={yAxisId}
1238
+ type={curve}
1239
+ dataKey={entry.key}
1240
+ stroke={color}
1241
+ strokeWidth={width}
1242
+ strokeDasharray={dash}
1243
+ dot={dotFor(entry)}
1244
+ {...(entry.activeDot !== undefined ? { activeDot: entry.activeDot } : {})}
1245
+ connectNulls={connectNulls}
1246
+ {...animation}
1247
+ />
1248
+ );
1249
+ })}
463
1250
  {spans?.map((span) => (
464
1251
  <ReferenceLine
465
1252
  key={span.key}
@@ -475,15 +1262,95 @@ export function StaticSeriesChart({
475
1262
  ifOverflow="discard"
476
1263
  />
477
1264
  ))}
1265
+ {refs.map((ref, index) => {
1266
+ const vertical = ref.axis === "x";
1267
+ const at = vertical ? xPos(ref.value) : finite(ref.value);
1268
+ if (at === undefined) return null;
1269
+ const color = ref.color ?? TONE_COLOR[ref.tone ?? "muted"];
1270
+ return (
1271
+ <ReferenceLine
1272
+ key={ref.key ?? `reference-${index}`}
1273
+ // A vertical line still needs a y axis to be drawn against, and every
1274
+ // axis here is named — the grid's is always there.
1275
+ yAxisId={vertical ? gridAxis : (ref.axis ?? DEFAULT_Y_AXIS)}
1276
+ {...(vertical ? { x: at } : { y: at })}
1277
+ stroke={color}
1278
+ strokeWidth={1}
1279
+ strokeDasharray={strokeDash(ref.dash ?? 1)}
1280
+ ifOverflow="discard"
1281
+ label={
1282
+ ref.label
1283
+ ? {
1284
+ value: ref.label,
1285
+ position: "insideTopLeft",
1286
+ fontSize: 10,
1287
+ fill: color,
1288
+ }
1289
+ : undefined
1290
+ }
1291
+ />
1292
+ );
1293
+ })}
1294
+ {markers?.map((marker, index) => {
1295
+ const at = xPos(marker.x);
1296
+ if (at === undefined || finite(marker.y) === undefined) return null;
1297
+ const color = marker.color ?? TONE_COLOR[marker.tone ?? "brand"];
1298
+ return (
1299
+ <ReferenceDot
1300
+ key={marker.key ?? `marker-${index}`}
1301
+ yAxisId={marker.axis ?? DEFAULT_Y_AXIS}
1302
+ x={at}
1303
+ y={marker.y}
1304
+ r={marker.r ?? 4.5}
1305
+ fill={color}
1306
+ stroke="var(--bg-surface)"
1307
+ strokeWidth={1.5}
1308
+ ifOverflow="discard"
1309
+ label={
1310
+ marker.label
1311
+ ? {
1312
+ value: marker.label,
1313
+ position: "top",
1314
+ fontSize: 11,
1315
+ fontWeight: 600,
1316
+ fill: "var(--text-primary)",
1317
+ }
1318
+ : undefined
1319
+ }
1320
+ />
1321
+ );
1322
+ })}
478
1323
  {zoom?.layer}
479
- </LineChart>
1324
+ </ComposedChart>
480
1325
  </ChartContainer>
481
1326
  );
482
1327
  }
483
1328
 
1329
+ const ZoomablePlot = withChartZoom(SeriesPlot);
1330
+
1331
+ /** The caller's props, with the abscissa reduced to numbers for the plot and the zoom. */
1332
+ function plotProps(props: SeriesChartProps): PlotProps {
1333
+ const x = props.x ?? {};
1334
+ const model = xModel(props.rows, x);
1335
+ return { ...props, rows: model.rows, x: { key: model.plotKey }, model, xAxis: x };
1336
+ }
1337
+
1338
+ /**
1339
+ * {@link SeriesChart} without the zoom: the same picture, no drag layer, no reset
1340
+ * button. For a thumbnail, a print view, or a chart the consumer wraps in its own
1341
+ * interaction.
1342
+ */
1343
+ export function StaticSeriesChart(props: SeriesChartProps) {
1344
+ return <SeriesPlot {...plotProps(props)} />;
1345
+ }
1346
+
484
1347
  /**
485
1348
  * The series chart, zoomable. Drag across the plot to zoom (the drag's shape picks the
486
1349
  * axes), double-click or press the reset button to go back; wrap several in
487
- * `SharedXZoom` to move their x windows together.
1350
+ * `SharedXZoom` to move their x windows together. Which axes zoom depends on the marks
1351
+ * — see {@link SeriesChartProps.zoomAxes}.
488
1352
  */
489
- export const SeriesChart = withChartZoom(StaticSeriesChart);
1353
+ export function SeriesChart(props: SeriesChartProps) {
1354
+ // The zoom fits the PLOTTED rows — slot indices and epoch ms, not the caller's labels.
1355
+ return <ZoomablePlot {...plotProps(props)} />;
1356
+ }