@delacour/react-native-ui 0.1.0-alpha.3

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 (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +192 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +435 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +232 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +100 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +33 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +247 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +48 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +161 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +286 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +672 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,726 @@
1
+ import { asNumber, candleSentiment, formatDateTick, formatNumberTick } from "@delacour/react-native-charts/core";
2
+ import { isLiteralColor } from "../../lib/color";
3
+ import { tv } from "../../lib/tv";
4
+ import type {
5
+ ChartAreaSpec,
6
+ ChartBarLayout,
7
+ ChartBarSlot,
8
+ ChartBarSpec,
9
+ ChartCandleColors,
10
+ ChartCandlestickKeys,
11
+ ChartConfig,
12
+ ChartDatum,
13
+ ChartResolvedSeries,
14
+ ChartTooltipInput,
15
+ ChartTooltipRow,
16
+ } from "./chart.types";
17
+
18
+ /**
19
+ * The series ramp, in the order a chart assigns it.
20
+ *
21
+ * Five, because five is what shadcn's palette declares and this package's token
22
+ * vocabulary is shadcn's. A sixth would not survive a pasted theme: a designer
23
+ * copying a tweakcn palette supplies `--chart-1` through `--chart-5` and
24
+ * nothing else, so a six-series chart would draw five themed lines and one
25
+ * stranger. Past five the ramp cycles, and a caller who genuinely needs six
26
+ * distinguishable series names the colours explicitly.
27
+ */
28
+ export const CHART_SERIES_TOKENS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"] as const;
29
+
30
+ export type ChartSeriesToken = (typeof CHART_SERIES_TOKENS)[number];
31
+
32
+ export const CHART_SIZES = ["sm", "md", "lg"] as const;
33
+ export type ChartSize = (typeof CHART_SIZES)[number];
34
+
35
+ /**
36
+ * The curves worth offering on a themed chart.
37
+ *
38
+ * A curated subset of what `@delacour/react-native-charts` can draw. The engine also exposes
39
+ * `basis`, `cardinal` and the bump curves; they are omitted here because
40
+ * `basis` does not pass through its own data — a scrub dot on it sits off every
41
+ * datum — and the rest are variations nobody asks for by name. A caller who
42
+ * wants one still passes it: `curve` takes the engine's full `CurveType`.
43
+ */
44
+ export const CHART_CURVES = ["monotone", "linear", "natural", "step"] as const;
45
+ export type ChartCurve = (typeof CHART_CURVES)[number];
46
+
47
+ /**
48
+ * How many *distinct* theme tokens one render can resolve.
49
+ *
50
+ * `useThemeColor` is a hook, so it cannot be called in a loop over a series
51
+ * list whose length changes. The root resolves a fixed number of slots instead
52
+ * and pads the list to reach it. Series sharing a token share a slot, so the
53
+ * five-token ramp costs five slots however many series walk it. Eight is an
54
+ * opinion rather than a limit of the technique — a categorical legend stops
55
+ * being readable well before eight distinct colours — and a chart that needs
56
+ * more can pass literal colours, which need no hook at all.
57
+ */
58
+ export const CHART_MAX_TOKEN_SERIES = 8;
59
+
60
+ export const chartVariants = tv({
61
+ slots: {
62
+ root: "w-full",
63
+ frame: "relative w-full overflow-hidden",
64
+ legend: "flex-row flex-wrap items-center justify-center gap-x-4 gap-y-2 pt-3",
65
+ legendItem: "flex-row items-center gap-1.5",
66
+ legendSwatch: "size-2.5 rounded-xs",
67
+ legendLabel: "text-xs text-muted-foreground",
68
+ tooltip: "absolute gap-1 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-sm",
69
+ tooltipHeading: "text-xs text-muted-foreground",
70
+ tooltipRow: "flex-row items-center gap-1.5",
71
+ tooltipSwatch: "size-2 rounded-xs",
72
+ tooltipName: "text-xs text-muted-foreground",
73
+ tooltipValue: "text-xs font-medium text-foreground",
74
+ pieCenter: "absolute inset-0 items-center justify-center",
75
+ pieCenterValue: "text-2xl font-semibold text-foreground",
76
+ pieCenterLabel: "text-xs text-muted-foreground",
77
+ },
78
+ variants: {
79
+ size: {
80
+ sm: { frame: "h-chart-sm" },
81
+ md: { frame: "h-chart-md" },
82
+ lg: { frame: "h-chart-lg" },
83
+ },
84
+ },
85
+ defaultVariants: { size: "md" },
86
+ });
87
+
88
+ export type ChartVariantProps = { size?: ChartSize };
89
+
90
+ /** The ramp slot a series takes by position, cycling at five. */
91
+ export function chartSeriesToken(index: number): ChartSeriesToken {
92
+ const position = Math.abs(Math.trunc(index)) % CHART_SERIES_TOKENS.length;
93
+ return CHART_SERIES_TOKENS[position] as ChartSeriesToken;
94
+ }
95
+
96
+ /**
97
+ * A shadcn-shaped config into an ordered series list, colour already decided.
98
+ *
99
+ * The config *is* the series list: its key order is the draw order and the
100
+ * ramp order, so a caller reorders series by reordering the object. `yKeys`
101
+ * narrows and reorders when a chart shows a subset.
102
+ */
103
+ export function resolveChartSeries(config: ChartConfig, yKeys?: readonly string[]): ChartResolvedSeries[] {
104
+ const keys = yKeys ?? Object.keys(config);
105
+ return keys
106
+ .filter((key) => config[key] !== undefined)
107
+ .map((key, index) => {
108
+ const entry = config[key];
109
+ return {
110
+ key,
111
+ // An empty label falls back to the key rather than through: a blank
112
+ // legend row is a swatch with nothing beside it, which reads as a
113
+ // rendering fault rather than as the missing label it is.
114
+ label: entry?.label === undefined || entry.label === "" ? key : entry.label,
115
+ color: entry?.color ?? chartSeriesToken(index),
116
+ };
117
+ });
118
+ }
119
+
120
+ /** A solid pie. Anything above zero is the hole of a donut. */
121
+ export const PIE_DEFAULT_INNER_RADIUS = 0;
122
+
123
+ /** A pie's rows, resolved: one series per drawn slice, index-aligned with its value. */
124
+ export type PieResolvedSeries = {
125
+ /** One per kept row, in data order, colour decided but not yet resolved. */
126
+ readonly series: ChartResolvedSeries[];
127
+ /** The kept rows' values, in the same order. */
128
+ readonly values: number[];
129
+ readonly total: number;
130
+ };
131
+
132
+ /**
133
+ * A pie's rows into an ordered series list, one per slice.
134
+ *
135
+ * The ramp walks the **data** rather than a config: a pie is a categorical
136
+ * chart whose categories are rows, so the first row is `chart-1`, and a
137
+ * `config` entry keyed by the row's name — `String(row[nameKey])`, the way a
138
+ * legend would print it — overrides the label or the colour of that one row
139
+ * without moving anyone else's ramp slot.
140
+ *
141
+ * A row whose value is negative, `NaN`, `null` or missing is dropped here,
142
+ * before the engine sees it, so every list this returns lines up index for
143
+ * index with the slices the engine draws and with the legend rows built from
144
+ * it. A zero stays: it draws nothing, but its legend row still names it.
145
+ *
146
+ * Two rows sharing a name get distinct keys — the second becomes `a-1` — so
147
+ * a legend keyed on them renders both rather than warning and dropping one.
148
+ */
149
+ export function resolvePieSeries(
150
+ data: readonly ChartDatum[],
151
+ nameKey: string,
152
+ valueKey: string,
153
+ config: ChartConfig
154
+ ): PieResolvedSeries {
155
+ const series: ChartResolvedSeries[] = [];
156
+ const values: number[] = [];
157
+ const seen = new Map<string, number>();
158
+ let total = 0;
159
+
160
+ for (const row of data) {
161
+ const value = asNumber(row[valueKey]);
162
+ if (!Number.isFinite(value) || value < 0) continue;
163
+
164
+ const index = series.length;
165
+ const raw = row[nameKey];
166
+ const name = raw === null || raw === undefined ? String(index) : String(raw);
167
+ const count = seen.get(name) ?? 0;
168
+ seen.set(name, count + 1);
169
+ const entry = config[name];
170
+
171
+ series.push({
172
+ key: count === 0 ? name : `${name}-${count}`,
173
+ label: entry?.label === undefined || entry.label === "" ? name : entry.label,
174
+ color: entry?.color ?? chartSeriesToken(index),
175
+ });
176
+ values.push(value);
177
+ total += value;
178
+ }
179
+
180
+ return { series, values, total };
181
+ }
182
+
183
+ /**
184
+ * A slice's value as a readout prints it: digits grouped in threes.
185
+ *
186
+ * A pie's values are counts and totals — sessions, seats, revenue — and a
187
+ * count reads as `1,240` in a readout beside a headline that says `1,125`.
188
+ * The axis formatter is deliberately not used: a tick label sits in a column
189
+ * where `1240` and `1,240` differ only in width, but a readout sits beside
190
+ * `PieChart.Center`, and the two must print one number one way. Hand-rolled
191
+ * rather than `toLocaleString` so it prints the same under `bun test` and
192
+ * on a Hermes build with no Intl data.
193
+ */
194
+ export function formatPieValue(value: number): string {
195
+ if (!Number.isFinite(value)) return "";
196
+ const plain = formatNumberTick(Math.abs(value));
197
+ const [whole = "", fraction] = plain.split(".");
198
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
199
+ const sign = value < 0 ? "-" : "";
200
+ return fraction === undefined ? `${sign}${grouped}` : `${sign}${grouped}.${fraction}`;
201
+ }
202
+
203
+ /** A slice's share of the whole, in percent. Zero when there is no whole. */
204
+ export function pieSlicePercent(value: number, total: number): number {
205
+ if (!(total > 0) || !Number.isFinite(value)) return 0;
206
+ return (value / total) * 100;
207
+ }
208
+
209
+ /** What a slice label prints. A function receives the slice's label, value and fraction. */
210
+ export type PieLabelFormat = "percent" | "value" | "label" | ((slice: PieLabelSlice) => string);
211
+
212
+ /** The part of a slice a label can print. */
213
+ export type PieLabelSlice = {
214
+ readonly label: string;
215
+ readonly value: number;
216
+ /** Share of the total, in `[0, 1]`. */
217
+ readonly fraction: number;
218
+ };
219
+
220
+ /**
221
+ * The text a slice label shows.
222
+ *
223
+ * A percentage by default, because a label on a slice answers "how much of
224
+ * the whole" — the value and the name are the legend's and the tooltip's job.
225
+ * Rounded to a whole number: `41.62%` on a wedge is more digits than the
226
+ * wedge's width can justify.
227
+ */
228
+ export function pieLabelText(format: PieLabelFormat | undefined, slice: PieLabelSlice): string {
229
+ if (typeof format === "function") return format(slice);
230
+ switch (format) {
231
+ case "value":
232
+ return formatNumberTick(slice.value);
233
+ case "label":
234
+ return slice.label;
235
+ default:
236
+ return `${Math.round(slice.fraction * 100)}%`;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A `0..1` fraction of the radius as the engine's percentage spec.
242
+ *
243
+ * A fraction rather than points because it reads the same at every chart
244
+ * size: `0.6` is the same donut on a `sm` and a `lg` chart. Clamped, and a
245
+ * non-number is no hole, which is what the engine does with one too.
246
+ */
247
+ export function pieInnerRadiusSpec(fraction: number): `${number}%` {
248
+ const clamped = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
249
+ return `${Math.round(clamped * 100)}%`;
250
+ }
251
+
252
+ export type ChartColorPartition = {
253
+ /** Exactly `max` token names, distinct, padded so the hook count never varies. */
254
+ readonly tokens: readonly string[];
255
+ /** Which slot of `tokens` each distinct token was resolved into. */
256
+ readonly slotOf: Readonly<Record<string, number>>;
257
+ /** Series whose colour is a literal, which needs no hook to resolve. */
258
+ readonly literals: Readonly<Record<string, string>>;
259
+ };
260
+
261
+ /**
262
+ * Splits the series into what a hook must resolve and what it need not.
263
+ *
264
+ * A literal (`#EC4899`) is already a colour, so it bypasses `useThemeColor`
265
+ * entirely — which is why a twenty-series chart works as long as its colours
266
+ * are literals. The token-valued remainder is collected as **distinct** tokens
267
+ * in first-appearance order — a token is one lookup however many series wear
268
+ * it, so the five-token ramp costs five slots for a twenty-slice pie — and
269
+ * padded to exactly `max` so the number of hooks the root calls is constant
270
+ * across renders.
271
+ *
272
+ * Throws past the cap rather than truncating. A chart quietly drawing its ninth
273
+ * colour wrong is worse than one that says why.
274
+ */
275
+ export function partitionChartColors(
276
+ series: readonly ChartResolvedSeries[],
277
+ max: number = CHART_MAX_TOKEN_SERIES
278
+ ): ChartColorPartition {
279
+ const tokens: string[] = [];
280
+ const slotOf: Record<string, number> = {};
281
+ const literals: Record<string, string> = {};
282
+
283
+ for (const entry of series) {
284
+ if (isLiteralColor(entry.color)) {
285
+ literals[entry.key] = entry.color;
286
+ continue;
287
+ }
288
+ if (slotOf[entry.color] !== undefined) continue;
289
+ slotOf[entry.color] = tokens.length;
290
+ tokens.push(entry.color);
291
+ }
292
+
293
+ if (tokens.length > max) {
294
+ throw new Error(
295
+ `[DelacourUI.Chart] ${tokens.length} distinct theme tokens are in use, and at most ${max} distinct tokens can be resolved in one render. ` +
296
+ "Give the extra series literal colours, which need no lookup."
297
+ );
298
+ }
299
+
300
+ while (tokens.length < max) tokens.push(CHART_SERIES_TOKENS[0] as string);
301
+
302
+ return { tokens, slotOf, literals };
303
+ }
304
+
305
+ /**
306
+ * Reassembles resolved colours back onto the series, in the original order.
307
+ *
308
+ * `resolved` holds one entry per slot of `partitionChartColors`' token list, in
309
+ * that order; a series reads the slot its token was assigned, so two series on
310
+ * one token read one value. A token that the active theme does not emit comes
311
+ * back `undefined`, and the series keeps its token name — which renders as
312
+ * nothing rather than as black, and is the honest outcome for a colour the
313
+ * theme has no value for.
314
+ */
315
+ export function applyChartColors(
316
+ series: readonly ChartResolvedSeries[],
317
+ partition: ChartColorPartition,
318
+ resolved: readonly (string | undefined)[]
319
+ ): ChartResolvedSeries[] {
320
+ return series.map((entry) => {
321
+ const literal = partition.literals[entry.key];
322
+ if (literal !== undefined) return { ...entry, color: literal };
323
+ const slot = partition.slotOf[entry.color];
324
+ const value = slot === undefined ? undefined : resolved[slot];
325
+ return { ...entry, color: value ?? entry.color };
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Where a tooltip sits so it stays inside the frame.
331
+ *
332
+ * Flipped to the cursor's left near the right edge and clamped at both, so it
333
+ * never leaves the chart — a tooltip half off-screen is worse than no tooltip,
334
+ * because the value it exists to show is the part that gets cut.
335
+ *
336
+ * A worklet, so the tooltip tracks the finger on the UI thread. The directive
337
+ * is a string rather than an import, which is what keeps this module free of
338
+ * React Native and reachable from `bun test`.
339
+ */
340
+ export function chartTooltipOffset(input: ChartTooltipInput): { x: number; y: number } {
341
+ "worklet";
342
+ const gap = input.gap ?? 12;
343
+ const frameWidth = Number.isFinite(input.frameWidth) ? input.frameWidth : 0;
344
+ const width = Number.isFinite(input.width) ? input.width : 0;
345
+ const height = Number.isFinite(input.height) ? input.height : 0;
346
+
347
+ const flipped = input.x + gap + width > frameWidth;
348
+ const rawX = flipped ? input.x - gap - width : input.x + gap;
349
+ const maxX = Math.max(0, frameWidth - width);
350
+ const x = rawX < 0 ? 0 : rawX > maxX ? maxX : rawX;
351
+
352
+ const frameHeight = Number.isFinite(input.frameHeight) ? input.frameHeight : 0;
353
+ const maxY = Math.max(0, frameHeight - height);
354
+ const rawY = input.y - height / 2;
355
+ const y = rawY < 0 ? 0 : rawY > maxY ? maxY : rawY;
356
+
357
+ return { x, y };
358
+ }
359
+
360
+ /** Axis label size in points, by chart size. */
361
+ export function chartAxisFontSize(size: ChartSize): number {
362
+ switch (size) {
363
+ case "sm":
364
+ return 10;
365
+ case "md":
366
+ return 11;
367
+ case "lg":
368
+ return 12;
369
+ }
370
+ }
371
+
372
+ /** How many ticks an axis aims for, by chart size. */
373
+ export function chartTickCount(size: ChartSize): number {
374
+ switch (size) {
375
+ case "sm":
376
+ return 3;
377
+ case "md":
378
+ return 4;
379
+ case "lg":
380
+ return 5;
381
+ }
382
+ }
383
+
384
+ /** How much of the series colour a lone area's fill starts with, before fading out. */
385
+ export const AREA_FILL_OPACITY = 0.25;
386
+
387
+ /** A stacked band's flat fill. Darker than a fade's top, because nothing under it shows through. */
388
+ export const AREA_STACKED_FILL_OPACITY = 0.65;
389
+
390
+ export type AreaFillInput = {
391
+ readonly stacked: boolean;
392
+ readonly gradient?: boolean;
393
+ readonly opacity?: number;
394
+ };
395
+
396
+ export type AreaFill = {
397
+ readonly gradient: boolean;
398
+ readonly opacity: number;
399
+ };
400
+
401
+ /**
402
+ * How an area is painted.
403
+ *
404
+ * A lone area fades to transparent towards the baseline: a flat fill at any
405
+ * readable opacity competes with the line it sits under. A stacked band is
406
+ * the opposite case. Three bands each fading over the full plot height blur
407
+ * into one wash, and nothing says where one series ends and the next begins
408
+ * — so a band is flat, at an opacity strong enough to read as a region. An
409
+ * explicit `gradient` or `opacity` wins in either case.
410
+ */
411
+ export function resolveAreaFill({ stacked, gradient, opacity }: AreaFillInput): AreaFill {
412
+ return {
413
+ gradient: gradient ?? !stacked,
414
+ opacity: opacity ?? (stacked ? AREA_STACKED_FILL_OPACITY : AREA_FILL_OPACITY),
415
+ };
416
+ }
417
+
418
+ /** Half a step each side, so an outermost bar, candle or dot sits inside the plot. */
419
+ export const EDGE_X_PADDING = 0.5;
420
+
421
+ /** A candle chart's y padding, as a fraction of the extent — room above the top wick and below the bottom one. */
422
+ export const CANDLE_Y_PADDING = 0.1;
423
+
424
+ export type DomainDefaultsInput = {
425
+ readonly bars: ChartBarLayout;
426
+ readonly candlestick: ChartCandlestickKeys | null;
427
+ /** Whether any `Chart.Scatter` is placed. */
428
+ readonly scatter: boolean;
429
+ readonly includeZero?: boolean;
430
+ readonly domain?: { readonly y?: readonly [number | undefined, number | undefined] };
431
+ };
432
+
433
+ export type DomainDefaults = {
434
+ readonly includeZero: boolean | undefined;
435
+ readonly domainPadding: { readonly x?: number; readonly y?: number } | undefined;
436
+ };
437
+
438
+ /**
439
+ * What the marks ask of the domain, before the caller's own props.
440
+ *
441
+ * A bar stands on zero, so bars pull zero into y. A candle does not: prices in
442
+ * the 140–160 band on an axis from zero are a thin strip, so a candle chart
443
+ * keeps the data's own extent and pads it a tenth each way, which keeps the
444
+ * top and bottom wicks off the plot's edges. An explicit y domain turns that
445
+ * padding off — the caller has said where the axis ends. Bars, candles and
446
+ * dots all pad x by half a step, because the outermost of each is centred on
447
+ * the domain's end and would otherwise straddle the plot's edge.
448
+ */
449
+ export function resolveDomainDefaults({
450
+ bars,
451
+ candlestick,
452
+ scatter,
453
+ includeZero,
454
+ domain,
455
+ }: DomainDefaultsInput): DomainDefaults {
456
+ const hasBars = bars.mode !== "none";
457
+ const hasCandles = candlestick !== null;
458
+ const x = hasBars || hasCandles || scatter ? EDGE_X_PADDING : undefined;
459
+ const y = hasCandles && domain?.y === undefined ? CANDLE_Y_PADDING : undefined;
460
+ const domainPadding = x === undefined ? undefined : y === undefined ? { x } : { x, y };
461
+ return {
462
+ includeZero: includeZero ?? (hasBars ? true : undefined),
463
+ domainPadding,
464
+ };
465
+ }
466
+
467
+ /** Past this many categories the labels collide, and dropping some is the lesser harm. */
468
+ export const CATEGORY_TICK_CAP = 12;
469
+
470
+ /**
471
+ * How many ticks the category axis aims for.
472
+ *
473
+ * A bar's category name is that bar's label, so a bar chart asks for every
474
+ * category rather than the size's tick budget: six months downsampled to
475
+ * four ticks lose Feb and May, and a bar with nothing under it reads as a
476
+ * bug rather than as a budget. Capped where the labels would collide, and
477
+ * never below what the size would have asked for, so a two-bar chart on a
478
+ * numeric axis is not left with two ticks.
479
+ *
480
+ * Only for a chart with bands — bars or candles. A line's x axis is a scale
481
+ * and its ticks are a budget, as before.
482
+ */
483
+ export function resolveCategoryTickCount(size: ChartSize, categoryCount: number, hasBands: boolean): number {
484
+ const budget = chartTickCount(size);
485
+ if (!hasBands) return budget;
486
+ const every = Number.isFinite(categoryCount) ? Math.min(Math.trunc(categoryCount), CATEGORY_TICK_CAP) : 0;
487
+ return Math.max(budget, every);
488
+ }
489
+
490
+ /** A span with no width still reads as a date, not as a clock time. */
491
+ const ONE_DAY_MS = 86_400_000;
492
+
493
+ /**
494
+ * How a tooltip prints the x field of the row under the cursor.
495
+ *
496
+ * A `Date` is the case this exists for. `String(new Date())` is
497
+ * `"Tue Jan 20 2026 00:00:00 GMT+0000"`, which is what a tooltip showed
498
+ * before this: a full RFC timestamp beside a two-digit price, in a box sized
499
+ * for neither.
500
+ *
501
+ * It formats through the **same** `formatDateTick` the axis uses, at the same
502
+ * granularity, so the heading reads `20 Jan` directly above an axis reading
503
+ * `18 Jan` — rather than two different renderings of one instant. The
504
+ * granularity comes from the data's own span, so an intraday series gets a
505
+ * clock and a multi-year one gets a year.
506
+ *
507
+ * A number goes through `formatNumberTick`, for the same reason an axis does:
508
+ * `0.1 + 0.2` should not print as `0.30000000000000004`.
509
+ */
510
+ export function resolveXValueFormat(data: readonly ChartDatum[], xKey: string): (row: ChartDatum) => string {
511
+ let lo = Number.POSITIVE_INFINITY;
512
+ let hi = Number.NEGATIVE_INFINITY;
513
+ let sawDate = false;
514
+
515
+ for (const row of data) {
516
+ const value = row[xKey];
517
+ if (!(value instanceof Date)) continue;
518
+ const time = value.getTime();
519
+ if (!Number.isFinite(time)) continue;
520
+ sawDate = true;
521
+ if (time < lo) lo = time;
522
+ if (time > hi) hi = time;
523
+ }
524
+
525
+ if (!sawDate) {
526
+ return (row) => {
527
+ const value = row[xKey];
528
+ if (typeof value === "number") return formatNumberTick(value);
529
+ return value === null || value === undefined ? "" : String(value);
530
+ };
531
+ }
532
+
533
+ // Only a *zero* span is substituted. Flooring every span at a day would
534
+ // swallow a genuine intraday one and print `1 Jan` where the axis prints
535
+ // `15:30`.
536
+ const measured = hi - lo;
537
+ const span = measured > 0 ? measured : ONE_DAY_MS;
538
+ return (row) => {
539
+ const value = row[xKey];
540
+ return value instanceof Date ? formatDateTick(value.getTime(), span) : "";
541
+ };
542
+ }
543
+
544
+ /** The one-stack rule, said the same way wherever it is broken. */
545
+ const ONE_STACK =
546
+ "[DelacourUI.Chart] a chart holds one stack per chart. Every stacked mark has to share one stackId, and a stacked bar cannot sit beside a loose one.";
547
+
548
+ /**
549
+ * Every `Chart.Bar` in a chart, into one arrangement.
550
+ *
551
+ * Bars group by being siblings and stack by sharing a `stackId`, which is
552
+ * shadcn's surface — a call site writes `<Chart.Bar yKey="a" />` twice and
553
+ * gets a grouped chart, adds `stackId="t"` to both and gets a stacked one.
554
+ * The root has to see them all before any of them can know its width or its
555
+ * base, so this runs on the root's child list rather than in the part.
556
+ *
557
+ * One stack per chart in this version: two `stackId`s, or a stack beside a
558
+ * loose bar, throw by name rather than drawing something ambiguous. A key
559
+ * placed twice throws too — the second bar would paint over the first.
560
+ */
561
+ export function resolveBarLayout(specs: readonly ChartBarSpec[]): ChartBarLayout {
562
+ if (specs.length === 0) return { mode: "none", keys: [], slotOf: {} };
563
+
564
+ const keys: string[] = [];
565
+ for (const spec of specs) {
566
+ if (keys.includes(spec.yKey)) {
567
+ throw new Error(`[DelacourUI.Chart] <Chart.Bar yKey="${spec.yKey}"> was placed twice. Each series is one bar.`);
568
+ }
569
+ keys.push(spec.yKey);
570
+ }
571
+
572
+ const stackIds = new Set(specs.map((spec) => spec.stackId));
573
+ if (stackIds.size > 1) throw new Error(ONE_STACK);
574
+ const stackId = specs[0]?.stackId;
575
+
576
+ const slotOf: Record<string, ChartBarSlot> = {};
577
+ if (stackId !== undefined) {
578
+ keys.forEach((key, index) => {
579
+ slotOf[key] = { groupIndex: 0, groupCount: 1, stackIndex: index };
580
+ });
581
+ return { mode: "stacked", keys, stackId, slotOf };
582
+ }
583
+
584
+ keys.forEach((key, index) => {
585
+ slotOf[key] = { groupIndex: index, groupCount: keys.length };
586
+ });
587
+ return { mode: keys.length === 1 ? "single" : "grouped", keys, slotOf };
588
+ }
589
+
590
+ /**
591
+ * Which areas stack, in placement order.
592
+ *
593
+ * The same one-stack rule as the bars, and it is one stack across both: the
594
+ * engine's `stackKeys` is a single ordered list, so a chart cannot hold a bar
595
+ * stack and an area stack at once. An area without a `stackId` beside a
596
+ * stacked one is simply not in the stack — it draws to the baseline as
597
+ * before, which is what an unstacked area always did.
598
+ */
599
+ export function resolveStackedAreaKeys(areas: readonly ChartAreaSpec[], bars: ChartBarLayout): readonly string[] {
600
+ const stacked = areas.filter((area) => area.stackId !== undefined);
601
+ if (stacked.length === 0) return [];
602
+ if (new Set(stacked.map((area) => area.stackId)).size > 1) throw new Error(ONE_STACK);
603
+ if (bars.mode === "stacked") throw new Error(ONE_STACK);
604
+ return stacked.map((area) => area.yKey);
605
+ }
606
+
607
+ /**
608
+ * The keys the engine plots: every series, plus the candle fields.
609
+ *
610
+ * A candlestick's `config` is keyed by its close field alone — the legend and
611
+ * the tooltip name one price, not four — so the other three fields have to
612
+ * reach the engine some other way. Without duplicates, because the engine
613
+ * builds one series per key and a repeated key would be two of them.
614
+ */
615
+ export function resolveChartKeys(keys: readonly string[], candlestick: ChartCandlestickKeys | null): string[] {
616
+ const resolved = [...keys];
617
+ if (candlestick === null) return resolved;
618
+ for (const key of [candlestick.open, candlestick.high, candlestick.low, candlestick.close]) {
619
+ if (!resolved.includes(key)) resolved.push(key);
620
+ }
621
+ return resolved;
622
+ }
623
+
624
+ /**
625
+ * The `rounded-*` step a bar's corners follow at each chart size.
626
+ *
627
+ * A bar is a filled block, and the step `Checkbox` picks for a filled block
628
+ * of about the same width is the right one here: `xs` at the small size,
629
+ * `sm` above it. Named so the multiplier below can be pinned to `tokens.css`.
630
+ */
631
+ export const BAR_RADIUS_STEP: Record<ChartSize, "xs" | "sm"> = { sm: "xs", md: "sm", lg: "sm" };
632
+
633
+ /**
634
+ * What each size multiplies `--radius` by, restating `tokens.css`.
635
+ *
636
+ * Restated because the corner scale is `@theme inline`: Tailwind substitutes
637
+ * each step into its utilities and emits no `--radius-xs` variable, so
638
+ * `--radius` is the only one that survives to runtime and anything computing
639
+ * a corner in JavaScript multiplies for itself. `chart.variants.test.ts` pins
640
+ * these against the file, so retuning the scale fails the build rather than
641
+ * quietly leaving the bars on the old curve.
642
+ */
643
+ export const BAR_RADIUS_MULTIPLIER: Record<ChartSize, number> = { sm: 0.4, md: 0.6, lg: 0.6 };
644
+
645
+ /**
646
+ * A bar's corner radius in points.
647
+ *
648
+ * `--radius` × the size's multiplier by default, so a pasted theme that
649
+ * squares its buttons squares its bars too. `rounded={false}` is square,
650
+ * a number is exact, and nothing here goes below zero — a negative radius
651
+ * would be handed to a path builder that clamps it anyway, but this is the
652
+ * place that says so.
653
+ */
654
+ export function resolveBarRadius(size: ChartSize, radius: number, rounded?: boolean | number): number {
655
+ if (rounded === false) return 0;
656
+ if (typeof rounded === "number") return Number.isFinite(rounded) ? Math.max(0, rounded) : 0;
657
+ return Number.isFinite(radius) ? Math.max(0, radius * BAR_RADIUS_MULTIPLIER[size]) : 0;
658
+ }
659
+
660
+ /**
661
+ * The tokens a candle takes for its sentiment.
662
+ *
663
+ * The first two slots of the series ramp, so a candlestick is painted from the
664
+ * same five colours as every other chart and a pasted palette recolours it with
665
+ * them. `success` and `destructive` were the first choice — they already mean
666
+ * up and down elsewhere — but they made the one chart on a dashboard that
667
+ * ignored the ramp, in red and green a theme never picked for its charts.
668
+ * A flat candle is `muted-foreground`: it says nothing, in the colour of things
669
+ * that say nothing. Only literal overrides are accepted on the part, so the
670
+ * root can resolve these three with three fixed hook calls.
671
+ */
672
+ export const CANDLE_SENTIMENT_TOKENS: ChartCandleColors = {
673
+ positive: "chart-1",
674
+ negative: "chart-2",
675
+ neutral: "muted-foreground",
676
+ };
677
+
678
+ /** What the tooltip needs to know about a candlestick, once its colours are values. */
679
+ export type ChartTooltipCandle = {
680
+ readonly keys: ChartCandlestickKeys;
681
+ readonly colors: ChartCandleColors;
682
+ };
683
+
684
+ const CANDLE_FIELD_LABELS = ["Open", "High", "Low", "Close"] as const;
685
+
686
+ /**
687
+ * The rows a tooltip prints for the row under the cursor.
688
+ *
689
+ * One per series, in draw order — unless a candlestick is present, in which
690
+ * case the four price fields come first, labelled by name and swatched in
691
+ * the candle's own sentiment colour, and a series that is one of those
692
+ * fields is not printed again. Any other series, a volume line say, follows.
693
+ */
694
+ export function resolveTooltipRows(
695
+ series: readonly ChartResolvedSeries[],
696
+ candlestick: ChartTooltipCandle | null,
697
+ row: ChartDatum | undefined
698
+ ): ChartTooltipRow[] {
699
+ const readField = (key: string): unknown => (row === undefined ? undefined : row[key]);
700
+ if (candlestick === null) {
701
+ return series.map((entry) => ({
702
+ key: entry.key,
703
+ label: entry.label,
704
+ color: entry.color,
705
+ value: readField(entry.key),
706
+ }));
707
+ }
708
+
709
+ const { keys, colors } = candlestick;
710
+ const fields = [keys.open, keys.high, keys.low, keys.close];
711
+ // `candleSentiment` treats a NaN as neutral, which is what a missing row is.
712
+ const color = colors[candleSentiment(asNumber(readField(keys.open)), asNumber(readField(keys.close)))];
713
+
714
+ const rows: ChartTooltipRow[] = fields.map((key, index) => ({
715
+ key,
716
+ label: CANDLE_FIELD_LABELS[index] as string,
717
+ color,
718
+ value: readField(key),
719
+ }));
720
+
721
+ for (const entry of series) {
722
+ if (fields.includes(entry.key)) continue;
723
+ rows.push({ key: entry.key, label: entry.label, color: entry.color, value: readField(entry.key) });
724
+ }
725
+ return rows;
726
+ }