@delacour/react-native-charts 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +97 -0
  2. package/package.json +61 -0
  3. package/src/animation/AGENTS.md +40 -0
  4. package/src/animation/animation.types.ts +16 -0
  5. package/src/animation/index.ts +2 -0
  6. package/src/animation/use-animated-path.ts +74 -0
  7. package/src/cartesian/AGENTS.md +110 -0
  8. package/src/cartesian/axes/chart-x-axis.tsx +52 -0
  9. package/src/cartesian/axes/chart-y-axis.tsx +56 -0
  10. package/src/cartesian/cartesian-chart.context.tsx +43 -0
  11. package/src/cartesian/cartesian-chart.tsx +132 -0
  12. package/src/cartesian/cartesian-chart.types.ts +181 -0
  13. package/src/cartesian/hooks/use-canvas-size.ts +37 -0
  14. package/src/cartesian/hooks/use-chart-model.ts +278 -0
  15. package/src/cartesian/index.ts +41 -0
  16. package/src/cartesian/marks/chart-area.tsx +83 -0
  17. package/src/cartesian/marks/chart-bar-group.tsx +132 -0
  18. package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
  19. package/src/cartesian/marks/chart-bar.tsx +195 -0
  20. package/src/cartesian/marks/chart-candlestick.tsx +148 -0
  21. package/src/cartesian/marks/chart-cursor.tsx +168 -0
  22. package/src/cartesian/marks/chart-grid.tsx +46 -0
  23. package/src/cartesian/marks/chart-line.tsx +73 -0
  24. package/src/cartesian/marks/chart-scatter.tsx +71 -0
  25. package/src/core/AGENTS.md +77 -0
  26. package/src/core/animation/morph-strategy.test.ts +38 -0
  27. package/src/core/animation/morph-strategy.ts +53 -0
  28. package/src/core/animation/resample-points.test.ts +68 -0
  29. package/src/core/animation/resample-points.ts +64 -0
  30. package/src/core/chart.types.ts +62 -0
  31. package/src/core/curve/build-area.test.ts +48 -0
  32. package/src/core/curve/build-area.ts +58 -0
  33. package/src/core/curve/build-line.test.ts +64 -0
  34. package/src/core/curve/build-line.ts +53 -0
  35. package/src/core/curve/curves.test.ts +20 -0
  36. package/src/core/curve/curves.ts +48 -0
  37. package/src/core/geometry/band.test.ts +79 -0
  38. package/src/core/geometry/band.ts +111 -0
  39. package/src/core/geometry/category-domain.test.ts +33 -0
  40. package/src/core/geometry/category-domain.ts +18 -0
  41. package/src/core/geometry/chart-bounds.test.ts +36 -0
  42. package/src/core/geometry/chart-bounds.ts +47 -0
  43. package/src/core/geometry/chart-layout.test.ts +230 -0
  44. package/src/core/geometry/chart-layout.ts +162 -0
  45. package/src/core/geometry/domain.test.ts +87 -0
  46. package/src/core/geometry/domain.ts +94 -0
  47. package/src/core/geometry/grid-lines.test.ts +48 -0
  48. package/src/core/geometry/grid-lines.ts +43 -0
  49. package/src/core/geometry/stack.test.ts +151 -0
  50. package/src/core/geometry/stack.ts +109 -0
  51. package/src/core/geometry/step.test.ts +34 -0
  52. package/src/core/geometry/step.ts +21 -0
  53. package/src/core/geometry/transform-input-data.test.ts +105 -0
  54. package/src/core/geometry/transform-input-data.ts +88 -0
  55. package/src/core/geometry/x-values.test.ts +37 -0
  56. package/src/core/geometry/x-values.ts +36 -0
  57. package/src/core/index.ts +109 -0
  58. package/src/core/interaction/closest-index.test.ts +36 -0
  59. package/src/core/interaction/closest-index.ts +27 -0
  60. package/src/core/interaction/path-segments.test.ts +60 -0
  61. package/src/core/interaction/path-segments.ts +155 -0
  62. package/src/core/interaction/y-for-x.test.ts +135 -0
  63. package/src/core/interaction/y-for-x.ts +106 -0
  64. package/src/core/polar/build-slice-path.test.ts +173 -0
  65. package/src/core/polar/build-slice-path.ts +98 -0
  66. package/src/core/polar/label-position.test.ts +41 -0
  67. package/src/core/polar/label-position.ts +18 -0
  68. package/src/core/polar/pie-input.test.ts +36 -0
  69. package/src/core/polar/pie-input.ts +32 -0
  70. package/src/core/polar/polar-point.test.ts +55 -0
  71. package/src/core/polar/polar-point.ts +29 -0
  72. package/src/core/polar/polar.types.ts +47 -0
  73. package/src/core/polar/resolve-layout.test.ts +63 -0
  74. package/src/core/polar/resolve-layout.ts +55 -0
  75. package/src/core/polar/resolve-slices.test.ts +89 -0
  76. package/src/core/polar/resolve-slices.ts +58 -0
  77. package/src/core/polar/slice-index-at.test.ts +52 -0
  78. package/src/core/polar/slice-index-at.ts +42 -0
  79. package/src/core/polar/slice-opacity.test.ts +29 -0
  80. package/src/core/polar/slice-opacity.ts +12 -0
  81. package/src/core/scale/make-scale.test.ts +55 -0
  82. package/src/core/scale/make-scale.ts +78 -0
  83. package/src/core/scale/scale.test.ts +76 -0
  84. package/src/core/scale/scale.ts +65 -0
  85. package/src/core/scale/scale.types.ts +30 -0
  86. package/src/core/shape/build-bars.test.ts +203 -0
  87. package/src/core/shape/build-bars.ts +167 -0
  88. package/src/core/shape/build-candles.test.ts +156 -0
  89. package/src/core/shape/build-candles.ts +153 -0
  90. package/src/core/shape/build-scatter.test.ts +103 -0
  91. package/src/core/shape/build-scatter.ts +81 -0
  92. package/src/core/shape/rect-path.test.ts +61 -0
  93. package/src/core/shape/rect-path.ts +69 -0
  94. package/src/core/text/axis-gutters.test.ts +70 -0
  95. package/src/core/text/axis-gutters.ts +70 -0
  96. package/src/core/text/bar-label.test.ts +92 -0
  97. package/src/core/text/bar-label.ts +61 -0
  98. package/src/core/text/format-tick.test.ts +87 -0
  99. package/src/core/text/format-tick.ts +76 -0
  100. package/src/core/text/label-anchor.test.ts +59 -0
  101. package/src/core/text/label-anchor.ts +62 -0
  102. package/src/core/ticks/build-ticks.test.ts +38 -0
  103. package/src/core/ticks/build-ticks.ts +36 -0
  104. package/src/core/ticks/linear-ticks.test.ts +56 -0
  105. package/src/core/ticks/linear-ticks.ts +67 -0
  106. package/src/core/ticks/tick-count.test.ts +46 -0
  107. package/src/core/ticks/tick-count.ts +35 -0
  108. package/src/core/ticks/tick.types.ts +7 -0
  109. package/src/core/ticks/time-ticks.test.ts +61 -0
  110. package/src/core/ticks/time-ticks.ts +22 -0
  111. package/src/core/util/as-number.test.ts +45 -0
  112. package/src/core/util/as-number.ts +22 -0
  113. package/src/core/util/clamp.test.ts +21 -0
  114. package/src/core/util/clamp.ts +16 -0
  115. package/src/core/util/extent.test.ts +33 -0
  116. package/src/core/util/extent.ts +37 -0
  117. package/src/core/util/read-at.test.ts +14 -0
  118. package/src/core/util/read-at.ts +14 -0
  119. package/src/core/util/sided-number.test.ts +28 -0
  120. package/src/core/util/sided-number.ts +34 -0
  121. package/src/docs.test.ts +45 -0
  122. package/src/flat-worklet.test.ts +130 -0
  123. package/src/gesture/AGENTS.md +84 -0
  124. package/src/gesture/gesture-overlay.tsx +21 -0
  125. package/src/gesture/gesture.types.ts +143 -0
  126. package/src/gesture/index.ts +12 -0
  127. package/src/gesture/use-chart-scrub.ts +44 -0
  128. package/src/gesture/use-scrub-gesture.ts +162 -0
  129. package/src/index.ts +6 -0
  130. package/src/no-classname.test.ts +27 -0
  131. package/src/polar/AGENTS.md +55 -0
  132. package/src/polar/hooks/use-polar-model.ts +88 -0
  133. package/src/polar/index.ts +14 -0
  134. package/src/polar/marks/pie-inset.tsx +70 -0
  135. package/src/polar/marks/pie-label.tsx +71 -0
  136. package/src/polar/marks/pie-slice.tsx +82 -0
  137. package/src/polar/marks/pie-slices.tsx +60 -0
  138. package/src/polar/polar-chart.context.tsx +37 -0
  139. package/src/polar/polar-chart.tsx +91 -0
  140. package/src/polar/polar-chart.types.ts +82 -0
  141. package/src/purity.test.ts +75 -0
  142. package/src/skia/AGENTS.md +39 -0
  143. package/src/skia/build-path.ts +22 -0
  144. package/src/skia/font.ts +78 -0
  145. package/src/skia/index.ts +9 -0
  146. package/src/source-tree.test.ts +33 -0
@@ -0,0 +1,132 @@
1
+ import { Canvas } from "@shopify/react-native-skia";
2
+ import { type ReactElement, useEffect, useMemo } from "react";
3
+ import { StyleSheet, View } from "react-native";
4
+ import { useSharedValue } from "react-native-reanimated";
5
+ import type { ChartRow } from "../core/chart.types";
6
+ import { buildLinePath } from "../core/curve/build-line";
7
+ import { toCurvePath } from "../core/interaction/path-segments";
8
+ import { EMPTY_SCRUB_MODEL, type ScrubModel, type ScrubSeries } from "../gesture/gesture.types";
9
+ import { ChartGestureOverlay } from "../gesture/gesture-overlay";
10
+ import { useScrubGesture } from "../gesture/use-scrub-gesture";
11
+ import { CartesianChartContext } from "./cartesian-chart.context";
12
+ import type { CartesianChartProps, CartesianRenderArgs, XKeyOf, YKeyOf } from "./cartesian-chart.types";
13
+ import { useCanvasSize } from "./hooks/use-canvas-size";
14
+ import { useChartModel } from "./hooks/use-chart-model";
15
+
16
+ /**
17
+ * A cartesian chart: scales, ticks, a plot rect, and whatever marks you place.
18
+ *
19
+ * ## Where things are mounted, and why
20
+ *
21
+ * ```
22
+ * View onLayout, and the only thing that knows the chart's size
23
+ * Canvas the Skia reconciler starts here
24
+ * Provider ← rendered INSIDE the canvas, so marks below can read it
25
+ * children
26
+ * Overlay an ordinary RN view; the canvas has no touch targets
27
+ * ```
28
+ *
29
+ * The provider's position is the whole trick. `<Canvas>` mounts a second React
30
+ * reconciler and context does not cross a reconciler boundary — but it
31
+ * resolves by which reconciler renders the *provider node*, so placing the
32
+ * provider among the canvas' children puts it in the Skia tree and consumers
33
+ * below it resolve normally. No `its-fine`, no context bridge, no
34
+ * `FiberProvider` wrapping the host app.
35
+ *
36
+ * The consequence for a themed wrapper is that every hook — `useThemeColor`,
37
+ * anything reading a CSS variable — must be called **above** the canvas and
38
+ * its result passed down as a plain value. There is nowhere inside to call one.
39
+ *
40
+ * ## Both child forms
41
+ *
42
+ * `children` may be Skia nodes, which read the chart from context, or a
43
+ * function of the resolved chart. The same mark components serve both: given
44
+ * `yKey` they read context, given `points` they use what they are handed.
45
+ */
46
+ export function CartesianChart<
47
+ Row extends ChartRow,
48
+ const XK extends XKeyOf<Row>,
49
+ const YK extends readonly YKeyOf<Row>[],
50
+ >(props: CartesianChartProps<Row, XK, YK>): ReactElement {
51
+ const { data, xKey, yKeys, children, scrub, scrubConfig, onBoundsChange, ...rest } = props;
52
+
53
+ const { size, onLayout } = useCanvasSize();
54
+
55
+ const model = useChartModel({
56
+ ...rest,
57
+ data: data as readonly ChartRow[],
58
+ xKey,
59
+ yKeys,
60
+ canvas: size,
61
+ scrub,
62
+ });
63
+
64
+ const { bounds, points, xPositions, stacked, xScale, yScale, curve, orientation } = model;
65
+
66
+ // Everything the scrub worklet reads, as plain data in one shared value.
67
+ // Rebuilt on the JavaScript thread whenever the chart changes; read on the
68
+ // UI thread every frame of a drag.
69
+ const scrubModel = useSharedValue<ScrubModel>(EMPTY_SCRUB_MODEL);
70
+
71
+ // A stacked key's `ys` are the tops of its segments, so the cursor dot sits
72
+ // on the segment that is visible; its `values` stay the raw series, which is
73
+ // what a readout should print. On a horizontal chart the value position is
74
+ // the point's x, and there is no curve to glide along.
75
+ const nextScrubModel = useMemo<ScrubModel>(() => {
76
+ const horizontal = orientation === "horizontal";
77
+ const series: ScrubSeries[] = model.yKeys.map((key) => {
78
+ const raw = points[key] ?? [];
79
+ const drawn = stacked[key] ?? raw;
80
+ return {
81
+ key,
82
+ path: horizontal ? [] : toCurvePath(buildLinePath(drawn, { curve })),
83
+ ys: drawn.map((point) => (horizontal ? (point.yValue === null ? null : point.x) : point.y)),
84
+ values: raw.map((point) => point.yValue),
85
+ };
86
+ });
87
+
88
+ const scale = horizontal ? yScale : xScale;
89
+ return {
90
+ axis: horizontal ? "y" : "x",
91
+ left: bounds.left,
92
+ right: bounds.right,
93
+ top: bounds.top,
94
+ bottom: bounds.bottom,
95
+ positions: xPositions,
96
+ scale,
97
+ xPositions,
98
+ xScale: scale,
99
+ series,
100
+ };
101
+ }, [bounds, points, stacked, xPositions, xScale, yScale, curve, model.yKeys, orientation]);
102
+
103
+ useEffect(() => {
104
+ scrubModel.value = nextScrubModel;
105
+ }, [nextScrubModel, scrubModel]);
106
+
107
+ useEffect(() => {
108
+ onBoundsChange?.(bounds);
109
+ }, [bounds, onBoundsChange]);
110
+
111
+ const gesture = useScrubGesture({ state: scrub, model: scrubModel, config: scrubConfig });
112
+
113
+ const content =
114
+ typeof children === "function"
115
+ ? children({ ...model, points: model.points, data } as CartesianRenderArgs<Row, YK>)
116
+ : children;
117
+
118
+ return (
119
+ <View onLayout={onLayout} style={styles.root}>
120
+ <Canvas style={StyleSheet.absoluteFill}>
121
+ <CartesianChartContext.Provider value={model}>{content}</CartesianChartContext.Provider>
122
+ </Canvas>
123
+ {gesture === null ? null : <ChartGestureOverlay gesture={gesture} />}
124
+ </View>
125
+ );
126
+ }
127
+
128
+ CartesianChart.displayName = "DelacourCharts.CartesianChart";
129
+
130
+ const styles = StyleSheet.create({
131
+ root: { flex: 1, overflow: "hidden" },
132
+ });
@@ -0,0 +1,181 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import type { ReactNode } from "react";
3
+ import type { ChartAnimation } from "../animation/animation.types";
4
+ import type { ChartBounds, ChartOrientation, ChartPoint, ChartRow, ChartSegment, ChartSize } from "../core/chart.types";
5
+ import type { CurveType } from "../core/curve/curves";
6
+ import type { DomainPadding } from "../core/geometry/domain";
7
+ import type { ScaleDescriptor, ScaleType } from "../core/scale/scale.types";
8
+ import type { LabelMetrics } from "../core/text/label-anchor";
9
+ import type { ChartTick } from "../core/ticks/tick.types";
10
+ import type { SidedNumber } from "../core/util/sided-number";
11
+ import type { ChartScrubState, ScrubConfig } from "../gesture/gesture.types";
12
+
13
+ /**
14
+ * Fields that can position a point along x — a number, a date, or a label.
15
+ *
16
+ * A row typed only as `Record<string, unknown>` knows nothing about its own
17
+ * fields, so narrowing it would produce `never` and reject every key. The
18
+ * `string extends keyof Row` guard detects exactly that case — it holds only
19
+ * for an index signature — and widens back to `string`. Without it a caller
20
+ * who has not typed their rows cannot name a key at all, which is a type error
21
+ * on correct code.
22
+ */
23
+ export type XKeyOf<Row extends ChartRow> = string extends keyof Row
24
+ ? string
25
+ : {
26
+ [Key in keyof Row]-?: Row[Key] extends number | string | Date | null | undefined ? Key : never;
27
+ }[keyof Row] &
28
+ string;
29
+
30
+ /** Fields that can be measured up y. Widens the same way — see `XKeyOf`. */
31
+ export type YKeyOf<Row extends ChartRow> = string extends keyof Row
32
+ ? string
33
+ : {
34
+ [Key in keyof Row]-?: Row[Key] extends number | null | undefined ? Key : never;
35
+ }[keyof Row] &
36
+ string;
37
+
38
+ export type AxisLabelFormatter = (value: number, index: number) => string;
39
+
40
+ export type AxisOptions = {
41
+ /** Draw this axis at all. Ticks and labels both. */
42
+ readonly show?: boolean;
43
+ /** How many ticks to aim for. The generator rounds it to human values. */
44
+ readonly tickCount?: number;
45
+ /** Explicit tick values, instead of generating any. */
46
+ readonly tickValues?: readonly number[];
47
+ readonly formatLabel?: AxisLabelFormatter;
48
+ };
49
+
50
+ export type { ChartOrientation };
51
+
52
+ /**
53
+ * The width one datum owns along the category axis.
54
+ *
55
+ * `value` is in domain units — one for a categorical axis, a month's worth of
56
+ * milliseconds on a monthly time scale. `px` is the same distance on the
57
+ * canvas. A bar's band and the tooltip's highlight are both stated in it.
58
+ *
59
+ * It is the **category** step whichever axis the categories lie on: on a
60
+ * horizontal chart `px` is a distance along y. The name keeps the vertical
61
+ * case, like `xPositions` and `xValue`.
62
+ */
63
+ export type ChartStep = {
64
+ readonly value: number;
65
+ readonly px: number;
66
+ };
67
+
68
+ /** Everything a mark, an axis or a render prop can read about the chart. */
69
+ export type ChartContextValue = {
70
+ /** One array per y key, in the order the keys were given. */
71
+ readonly points: Readonly<Record<string, readonly ChartPoint[]>>;
72
+ /** Canvas position of each row's category, shared by every series — a y on a horizontal chart. */
73
+ readonly xPositions: readonly number[];
74
+ readonly xStep: ChartStep;
75
+ /**
76
+ * The stacked series, one entry per key in `stackKeys`.
77
+ *
78
+ * `points[key]` still holds the raw series; a stacked mark reads here for
79
+ * the edges it draws between, and a tooltip reads there for the value it
80
+ * prints.
81
+ */
82
+ readonly stacked: Readonly<Record<string, readonly ChartSegment[]>>;
83
+ readonly orientation: ChartOrientation;
84
+ /** The plot rect — the canvas minus padding and axis gutters. */
85
+ readonly bounds: ChartBounds;
86
+ /** The whole canvas, including the gutters. */
87
+ readonly canvas: ChartSize;
88
+ /** The scale on the canvas' x axis: categories when vertical, values when horizontal. */
89
+ readonly xScale: ScaleDescriptor;
90
+ /** The scale on the canvas' y axis: values when vertical, categories when horizontal. */
91
+ readonly yScale: ScaleDescriptor;
92
+ readonly xTicks: readonly ChartTick[];
93
+ readonly yTicks: readonly ChartTick[];
94
+ readonly xLabels: readonly string[];
95
+ readonly yLabels: readonly string[];
96
+ readonly yKeys: readonly string[];
97
+ /** `null` until the font resolves; an axis then draws ticks and no labels. */
98
+ readonly font: SkFont | null;
99
+ readonly lineHeight: number;
100
+ /** The font's ascent and descent, for placing a label by its glyph box. */
101
+ readonly fontMetrics: LabelMetrics;
102
+ readonly curve: CurveType;
103
+ readonly animation: ChartAnimation;
104
+ readonly scrub: ChartScrubState | null;
105
+ /** Whether x holds positions rather than measurements. */
106
+ readonly isCategorical: boolean;
107
+ /** Whether there is room to draw in. */
108
+ readonly ready: boolean;
109
+ };
110
+
111
+ export type CartesianRenderArgs<Row extends ChartRow, YK extends readonly string[]> = Omit<
112
+ ChartContextValue,
113
+ "points"
114
+ > & {
115
+ readonly points: { readonly [Key in YK[number]]: readonly ChartPoint[] };
116
+ readonly data: readonly Row[];
117
+ };
118
+
119
+ export type CartesianChartProps<Row extends ChartRow, XK extends XKeyOf<Row>, YK extends readonly YKeyOf<Row>[]> = {
120
+ readonly data: readonly Row[];
121
+ readonly xKey: XK;
122
+ readonly yKeys: YK;
123
+
124
+ /** Space between the canvas edge and the axes. */
125
+ readonly padding?: SidedNumber;
126
+ /** Explicit bounds. Either end of either axis may be omitted. */
127
+ readonly domain?: {
128
+ readonly x?: readonly [number | undefined, number | undefined];
129
+ readonly y?: readonly [number | undefined, number | undefined];
130
+ };
131
+ /**
132
+ * Extra span at each end of the domain.
133
+ *
134
+ * A bare number pads y, as a fraction of the measured extent. An object
135
+ * names each axis; `x` is measured in **steps**, so `{ x: 0.5 }` is half a
136
+ * band each side — what a bar chart needs for its first and last bars to
137
+ * sit inside the plot rather than straddle its edges.
138
+ */
139
+ readonly domainPadding?: DomainPadding;
140
+ /**
141
+ * Which of `yKeys` stack on one another, bottom first.
142
+ *
143
+ * The root owns this rather than the mark because the y domain has to
144
+ * cover the running totals, and only the root builds the y scale. A stacked
145
+ * mark that stacked for itself would draw past the top of the plot.
146
+ */
147
+ readonly stackKeys?: readonly string[];
148
+ /**
149
+ * Categories along x (the default) or along y.
150
+ *
151
+ * Horizontal swaps the axis roles at the model: categories are planned into
152
+ * the y range and values into the x range, so `yTicks` and `yLabels` carry
153
+ * the categories and the left gutter is measured for them. Every mark reads
154
+ * the same flag.
155
+ */
156
+ readonly orientation?: ChartOrientation;
157
+ /** Pull zero into the y domain. What a bar chart needs; a line usually does not. */
158
+ readonly includeZero?: boolean;
159
+ /** Round the y domain out to tick values. */
160
+ readonly niceDomain?: boolean;
161
+
162
+ /** Defaults to `time` when the x field holds dates, `linear` otherwise. */
163
+ readonly xScaleType?: ScaleType;
164
+ readonly yScaleType?: ScaleType;
165
+ /** The interpolator every mark uses unless it overrides it. */
166
+ readonly curve?: CurveType;
167
+ readonly animation?: ChartAnimation;
168
+
169
+ readonly xAxis?: AxisOptions;
170
+ readonly yAxis?: AxisOptions;
171
+ readonly font?: SkFont | null;
172
+
173
+ /** From `useChartScrub`. Without it the chart takes no touches. */
174
+ readonly scrub?: ChartScrubState;
175
+ readonly scrubConfig?: ScrubConfig;
176
+
177
+ readonly onBoundsChange?: (bounds: ChartBounds) => void;
178
+
179
+ /** Skia children, or a function of the resolved chart. */
180
+ readonly children?: ReactNode | ((args: CartesianRenderArgs<Row, YK>) => ReactNode);
181
+ };
@@ -0,0 +1,37 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { LayoutChangeEvent } from "react-native";
3
+ import type { ChartSize } from "../../core/chart.types";
4
+
5
+ export const ZERO_SIZE: ChartSize = { width: 0, height: 0 };
6
+
7
+ export type CanvasSize = {
8
+ readonly size: ChartSize;
9
+ readonly onLayout: (event: LayoutChangeEvent) => void;
10
+ /** False until the first layout pass. Nothing should draw before it. */
11
+ readonly measured: boolean;
12
+ };
13
+
14
+ /**
15
+ * The chart's measured size.
16
+ *
17
+ * Measured on the wrapping React Native view rather than on the canvas.
18
+ * Skia's `<Canvas onLayout>` is deprecated and does not fire on Fabric at all,
19
+ * and its `onSize` alternative reports into a shared value — useful for a
20
+ * worklet, useless for building scales, which happens on the JavaScript
21
+ * thread. A plain `View` reports where the work actually is.
22
+ *
23
+ * The size is held in state, so a resize re-renders and every scale is rebuilt
24
+ * against the new rect. Rounding to whole points keeps a fractional layout
25
+ * from re-rendering the chart on every frame of a parent animation.
26
+ */
27
+ export function useCanvasSize(): CanvasSize {
28
+ const [size, setSize] = useState<ChartSize>(ZERO_SIZE);
29
+
30
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
31
+ const { width, height } = event.nativeEvent.layout;
32
+ const next = { width: Math.round(width), height: Math.round(height) };
33
+ setSize((current) => (current.width === next.width && current.height === next.height ? current : next));
34
+ }, []);
35
+
36
+ return { size, onLayout, measured: size.width > 0 && size.height > 0 };
37
+ }
@@ -0,0 +1,278 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import { useMemo } from "react";
3
+ import type { ChartAnimation } from "../../animation/animation.types";
4
+ import { DEFAULT_CHART_ANIMATION } from "../../animation/animation.types";
5
+ import type { ChartRow, ChartSize } from "../../core/chart.types";
6
+ import type { CurveType } from "../../core/curve/curves";
7
+ import { getChartBounds, hasArea } from "../../core/geometry/chart-bounds";
8
+ import {
9
+ type AxisPlan,
10
+ axisRanges,
11
+ pickAxisRoles,
12
+ placeAxisRoles,
13
+ planAxis,
14
+ resolveChartFrame,
15
+ } from "../../core/geometry/chart-layout";
16
+ import { type DomainPadding, resolveDomain, resolveDomainPadding } from "../../core/geometry/domain";
17
+ import { collectStackedYValues, stackSeries } from "../../core/geometry/stack";
18
+ import { resolveStep } from "../../core/geometry/step";
19
+ import { collectYValues, transformInputData } from "../../core/geometry/transform-input-data";
20
+ import { resolveXValues } from "../../core/geometry/x-values";
21
+ import { scaleValue } from "../../core/scale/scale";
22
+ import type { DomainTuple, RangeTuple, ScaleType } from "../../core/scale/scale.types";
23
+ import { categoricalTickFormat, defaultTickFormat } from "../../core/text/format-tick";
24
+ import { DEFAULT_TICK_COUNT } from "../../core/ticks/tick-count";
25
+ import type { SidedNumber } from "../../core/util/sided-number";
26
+ import type { ChartScrubState } from "../../gesture/gesture.types";
27
+ import { fontMetrics, measureLabelWidths } from "../../skia/font";
28
+ import type { AxisLabelFormatter, AxisOptions, ChartContextValue, ChartOrientation } from "../cartesian-chart.types";
29
+
30
+ export type UseChartModelOptions = {
31
+ readonly data: readonly ChartRow[];
32
+ readonly xKey: string;
33
+ readonly yKeys: readonly string[];
34
+ readonly canvas: ChartSize;
35
+ readonly padding?: SidedNumber;
36
+ readonly domain?: {
37
+ readonly x?: readonly [number | undefined, number | undefined];
38
+ readonly y?: readonly [number | undefined, number | undefined];
39
+ };
40
+ readonly domainPadding?: DomainPadding;
41
+ readonly stackKeys?: readonly string[];
42
+ readonly orientation?: ChartOrientation;
43
+ readonly includeZero?: boolean;
44
+ readonly niceDomain?: boolean;
45
+ readonly xScaleType?: ScaleType;
46
+ readonly yScaleType?: ScaleType;
47
+ readonly curve?: CurveType;
48
+ readonly animation?: ChartAnimation;
49
+ readonly xAxis?: AxisOptions;
50
+ readonly yAxis?: AxisOptions;
51
+ readonly font?: SkFont | null;
52
+ readonly scrub?: ChartScrubState;
53
+ };
54
+
55
+ /**
56
+ * The whole chart, resolved: scales, ticks, plot rect and plotted points.
57
+ *
58
+ * The layout is two passes and the maths for both lives in
59
+ * `core/geometry/chart-layout.ts` — `planAxis` decides tick values against the
60
+ * full canvas, this hook measures those labels with the font, and
61
+ * `resolveChartFrame` turns the measurements into a plot rect and moves the
62
+ * scales into it. The tick values never change between the passes, which is
63
+ * what stops the measure/gutter cycle from iterating.
64
+ *
65
+ * Only the measuring is here. Everything either side of it is pure and tested.
66
+ */
67
+ export function useChartModel(options: UseChartModelOptions): ChartContextValue {
68
+ const {
69
+ data,
70
+ xKey,
71
+ yKeys,
72
+ canvas,
73
+ padding,
74
+ domain,
75
+ domainPadding,
76
+ stackKeys,
77
+ orientation = "vertical",
78
+ includeZero,
79
+ niceDomain,
80
+ xScaleType,
81
+ yScaleType,
82
+ curve = "linear",
83
+ animation = DEFAULT_CHART_ANIMATION,
84
+ xAxis,
85
+ yAxis,
86
+ font = null,
87
+ scrub,
88
+ } = options;
89
+
90
+ const yKeySignature = yKeys.join(" ");
91
+ const stackSignature = (stackKeys ?? []).join(" ");
92
+
93
+ // `yKeys` and `stackKeys` enter the dependency list as joined signatures, so
94
+ // a fresh array of the same keys does not rebuild every scale and path on
95
+ // each render.
96
+ return useMemo<ChartContextValue>(() => {
97
+ const keys = splitSignature(yKeySignature);
98
+ const stackedKeys = splitSignature(stackSignature);
99
+ const unstackedKeys = keys.filter((key) => !stackedKeys.includes(key));
100
+ const { values: xValues, isCategorical, raw } = resolveXValues(data, xKey);
101
+ const pad = resolveDomainPadding(domainPadding);
102
+ const xStepValue = resolveStep(xValues);
103
+
104
+ const xKind = resolveXKind(xScaleType, isCategorical, raw);
105
+ const yKind = yScaleType ?? "linear";
106
+ const shown = pickAxisRoles(xAxis?.show ?? true, yAxis?.show ?? true, orientation);
107
+
108
+ const xDomain = resolveDomain({ values: xValues, domain: domain?.x, absolutePadding: pad.x * xStepValue });
109
+ const yDomain = resolveDomain({
110
+ values: [...collectYValues(data, unstackedKeys), ...collectStackedYValues(data, stackedKeys)],
111
+ domain: domain?.y,
112
+ padding: pad.y,
113
+ includeZero,
114
+ });
115
+
116
+ // The category axis and the value axis are planned by role; the
117
+ // orientation decides which canvas axis each lands on, and from the
118
+ // frame onward `x` and `y` name canvas axes only.
119
+ const outer = getChartBounds(canvas, padding);
120
+ const ranges = axisRanges(outer, orientation);
121
+ const axes = pickAxisRoles(xAxis, yAxis, orientation);
122
+ const plan = placeAxisRoles(
123
+ planCategoryAxis({
124
+ domain: xDomain,
125
+ kind: xKind,
126
+ range: ranges.category,
127
+ axis: axes.category,
128
+ show: shown.category,
129
+ isCategorical,
130
+ xValues,
131
+ raw,
132
+ }),
133
+ planAxis({
134
+ domain: yDomain,
135
+ kind: yKind,
136
+ range: ranges.value,
137
+ tickCount: axes.value?.tickCount ?? DEFAULT_TICK_COUNT,
138
+ tickValues: axes.value?.tickValues,
139
+ format: axes.value?.formatLabel ?? defaultTickFormat(yKind, 0),
140
+ show: shown.value,
141
+ nice: niceDomain,
142
+ }),
143
+ orientation
144
+ );
145
+
146
+ const metrics = fontMetrics(font);
147
+ const lineHeight = metrics.ascent + metrics.descent;
148
+ const frame = resolveChartFrame({
149
+ canvas,
150
+ padding,
151
+ plan,
152
+ xLabelWidths: measureLabelWidths(font, plan.x.labels),
153
+ yLabelWidths: measureLabelWidths(font, plan.y.labels),
154
+ lineHeight,
155
+ showXAxis: xAxis?.show ?? true,
156
+ showYAxis: yAxis?.show ?? true,
157
+ orientation,
158
+ });
159
+
160
+ const { points, xPositions } = transformInputData({
161
+ data,
162
+ yKeys: keys,
163
+ xValues,
164
+ xScale: frame.xScale,
165
+ yScale: frame.yScale,
166
+ orientation,
167
+ });
168
+
169
+ const { category: categoryScale, value: valueScale } = pickAxisRoles(frame.xScale, frame.yScale, orientation);
170
+ const x0 = categoryScale.domain[0];
171
+ const xStep = {
172
+ value: xStepValue,
173
+ px: Math.abs(scaleValue(categoryScale, x0 + xStepValue) - scaleValue(categoryScale, x0)),
174
+ };
175
+
176
+ const stacked = stackSeries({ data, keys: stackedKeys, xValues, xPositions, yScale: valueScale, orientation });
177
+
178
+ return {
179
+ points,
180
+ xPositions,
181
+ xStep,
182
+ stacked,
183
+ orientation,
184
+ bounds: frame.bounds,
185
+ canvas,
186
+ xScale: frame.xScale,
187
+ yScale: frame.yScale,
188
+ xTicks: frame.xTicks,
189
+ yTicks: frame.yTicks,
190
+ xLabels: plan.x.labels,
191
+ yLabels: plan.y.labels,
192
+ yKeys: keys,
193
+ font,
194
+ lineHeight,
195
+ fontMetrics: metrics,
196
+ curve,
197
+ animation,
198
+ scrub: scrub ?? null,
199
+ isCategorical,
200
+ ready: hasArea(frame.bounds) && data.length > 0,
201
+ };
202
+ }, [
203
+ data,
204
+ xKey,
205
+ yKeySignature,
206
+ canvas,
207
+ padding,
208
+ domain,
209
+ domainPadding,
210
+ stackSignature,
211
+ orientation,
212
+ includeZero,
213
+ niceDomain,
214
+ xScaleType,
215
+ yScaleType,
216
+ curve,
217
+ animation,
218
+ xAxis,
219
+ yAxis,
220
+ font,
221
+ scrub,
222
+ ]);
223
+ }
224
+
225
+ type PlanCategoryAxisOptions = {
226
+ readonly domain: DomainTuple;
227
+ readonly kind: ScaleType;
228
+ readonly range: RangeTuple;
229
+ readonly axis: AxisOptions | undefined;
230
+ readonly show: boolean;
231
+ readonly isCategorical: boolean;
232
+ readonly xValues: readonly number[];
233
+ readonly raw: readonly unknown[];
234
+ };
235
+
236
+ /**
237
+ * The category axis' plan. A categorical axis ticks on its own data, not on
238
+ * round numbers — index 2.5 names no month — and prints the original label.
239
+ */
240
+ function planCategoryAxis(options: PlanCategoryAxisOptions): AxisPlan {
241
+ const { domain, kind, range, axis, show, isCategorical, xValues, raw } = options;
242
+ return planAxis({
243
+ domain,
244
+ kind,
245
+ range,
246
+ tickCount: axis?.tickCount ?? DEFAULT_TICK_COUNT,
247
+ tickValues: axis?.tickValues ?? (isCategorical ? xValues : undefined),
248
+ format: axis?.formatLabel ?? categoricalOrValueFormat(isCategorical, raw, kind, domain),
249
+ show,
250
+ });
251
+ }
252
+
253
+ /** A joined key signature back into keys; the empty signature is no keys. */
254
+ function splitSignature(signature: string): string[] {
255
+ return signature === "" ? [] : signature.split(" ");
256
+ }
257
+
258
+ /** A date-valued x field gets a time scale unless the caller says otherwise. */
259
+ function resolveXKind(explicit: ScaleType | undefined, isCategorical: boolean, raw: readonly unknown[]): ScaleType {
260
+ if (explicit !== undefined) return explicit;
261
+ if (isCategorical) return "linear";
262
+ return raw.some((value) => value instanceof Date) ? "time" : "linear";
263
+ }
264
+
265
+ /** A categorical axis prints the original label; anything else prints its value. */
266
+ function categoricalOrValueFormat(
267
+ isCategorical: boolean,
268
+ raw: readonly unknown[],
269
+ kind: ScaleType,
270
+ domain: readonly [number, number]
271
+ ): AxisLabelFormatter {
272
+ if (isCategorical) {
273
+ const label = categoricalTickFormat(raw);
274
+ return (value) => label(value);
275
+ }
276
+ const format = defaultTickFormat(kind, Math.abs(domain[1] - domain[0]));
277
+ return (value) => format(value);
278
+ }
@@ -0,0 +1,41 @@
1
+ export { ChartXAxis, type ChartXAxisProps } from "./axes/chart-x-axis";
2
+ export { ChartYAxis, type ChartYAxisProps } from "./axes/chart-y-axis";
3
+ export { CartesianChart } from "./cartesian-chart";
4
+ export { CartesianChartContext, useChartContext, useOptionalChartContext } from "./cartesian-chart.context";
5
+ export type {
6
+ AxisLabelFormatter,
7
+ AxisOptions,
8
+ CartesianChartProps,
9
+ CartesianRenderArgs,
10
+ ChartContextValue,
11
+ ChartOrientation,
12
+ ChartStep,
13
+ XKeyOf,
14
+ YKeyOf,
15
+ } from "./cartesian-chart.types";
16
+ export { type CanvasSize, useCanvasSize } from "./hooks/use-canvas-size";
17
+ export { type UseChartModelOptions, useChartModel } from "./hooks/use-chart-model";
18
+ export { ChartArea, type ChartAreaProps } from "./marks/chart-area";
19
+ export { ChartBar, type ChartBarLabels, type ChartBarProps } from "./marks/chart-bar";
20
+ export { ChartBarGroup, type ChartBarGroupProps } from "./marks/chart-bar-group";
21
+ export {
22
+ ChartBarStack,
23
+ type ChartBarStackProps,
24
+ type ChartBarStackSegmentInfo,
25
+ type ChartBarStackSegmentOptions,
26
+ } from "./marks/chart-bar-stack";
27
+ export {
28
+ type ChartCandleColors,
29
+ ChartCandlestick,
30
+ type ChartCandlestickKeys,
31
+ type ChartCandlestickProps,
32
+ } from "./marks/chart-candlestick";
33
+ export {
34
+ ChartCursorDot,
35
+ type ChartCursorDotProps,
36
+ ChartCursorLine,
37
+ type ChartCursorLineProps,
38
+ } from "./marks/chart-cursor";
39
+ export { ChartGrid, type ChartGridProps } from "./marks/chart-grid";
40
+ export { ChartLine, type ChartLineProps } from "./marks/chart-line";
41
+ export { ChartScatter, type ChartScatterProps } from "./marks/chart-scatter";