@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.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- 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
|
+
}
|