@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,84 @@
1
+ # gesture
2
+
3
+ The scrub: shared values, the pan that writes them, and the touch surface.
4
+
5
+ ## The contract
6
+
7
+ Every field a consumer reads holds a **`number` or a `boolean`**. Never an
8
+ `SkPath`, never an object, never a function.
9
+
10
+ That is what keeps a themed tooltip free of any Skia import and readable from a
11
+ plain `useAnimatedStyle` on an ordinary React Native view. A shared value
12
+ carrying a Skia host object also has a long history of crashing, which is the
13
+ second reason and would be sufficient on its own.
14
+
15
+ ## Two y values per series
16
+
17
+ `y` is the position **on the drawn curve** at the touched x, solved by
18
+ `getYForX` against the same cubics the renderer drew. A dot bound to it glides
19
+ continuously along the line.
20
+
21
+ `snappedY` is the position of the nearest datum. A dot bound to it lands on
22
+ real measurements.
23
+
24
+ Both come out of one binary search, so offering both costs nothing and the
25
+ consumer picks. Victory-native only does the second; react-native-graph only
26
+ does the first.
27
+
28
+ For a stacked key the root builds `ys` and the curve from the stacked segment
29
+ tops, so the dot sits on the segment that is visible, while `values` stay the
30
+ raw series so the readout prints what was measured.
31
+
32
+ ## A point per series, either way round
33
+
34
+ Each series also carries `snappedX` and `x`: with `snappedY` and `y` they are
35
+ the nearest datum's canvas point on that series, and the gliding equivalent,
36
+ **whichever way the chart is oriented**. A cursor dot reads the pair and
37
+ never asks. On a horizontal chart the touch **y** is what matters — clamped,
38
+ inverted against the category scale and matched to a row — and there is no
39
+ curve to glide along, so the glide values equal the snapped ones.
40
+
41
+ The root's fields say where the category is: `snappedX` on a vertical chart,
42
+ `snappedY` on a horizontal one, `NaN` on the other. A band highlight is
43
+ `xStep.px` wide centred on whichever is finite. `xValue` is the category value
44
+ under the touch on either.
45
+
46
+ ## Files
47
+
48
+ | Path | What |
49
+ | --- | --- |
50
+ | `gesture.types.ts` | The shared-value contract and the scrub's input model |
51
+ | `use-chart-scrub.ts` | Allocates the shared values |
52
+ | `use-scrub-gesture.ts` | The pan, and everything it does on the UI thread |
53
+ | `gesture-overlay.tsx` | An absolute-fill view holding the gesture detector |
54
+
55
+ ## Design
56
+
57
+ - **`makeMutable` in one `useMemo`, not `useSharedValue` in a loop.** The
58
+ series list is data. Calling a hook per series breaks the rules of hooks the
59
+ moment a series is added, and the memo is keyed on the joined key signature
60
+ so a fresh array of the same keys does not reallocate — which would strand
61
+ the gesture writing to values nothing renders any more.
62
+
63
+ - **`onFinalize`, never `onEnd`.** A gesture that fails after activating never
64
+ fires `onEnd`, and the dot is stranded on screen until the next touch. This
65
+ is the bug `react-native-graph` fixed and left a comment about.
66
+
67
+ - **`shouldCancelWhenOutside(false)`.** A finger dragged above or below the
68
+ plot keeps scrubbing. Cancelling there strands the gesture for anyone whose
69
+ thumb drifts off the chart, which is most people.
70
+
71
+ - **`hold` is the default, not `claim`.** A chart usually lives in a scrolling
72
+ feed, and a scrub that activates on a plain drag steals the scroll. Holding
73
+ first is deliberate on the user's part, so nothing is taken by accident. A
74
+ chart that owns its width should be given `claim` explicitly.
75
+
76
+ - **The handlers are built inside the `useMemo`, and that is why they may call
77
+ `getYForX`.** They are hook-scope worklets, captured by ordinary closure. The
78
+ flat-worklet rule in the package `AGENTS.md` constrains module-scope worklets
79
+ only, which is why the solver can be composed here rather than duplicated.
80
+
81
+ - **Touches land on a React Native view, not on the canvas.** A Skia canvas is
82
+ one view however much is drawn on it, so there is nothing to hit-test
83
+ against. An absolute-fill sibling keeps the hit area exactly the chart's
84
+ bounds and keeps gesture composition ordinary.
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { GestureDetector, type GestureType } from "react-native-gesture-handler";
4
+
5
+ /**
6
+ * The touch surface, laid over the canvas.
7
+ *
8
+ * Gestures are handled by an ordinary React Native view rather than by the
9
+ * Skia canvas, because a canvas has no touch targets to speak of — it is one
10
+ * view however much is drawn on it. An absolute-fill sibling keeps the hit
11
+ * area exactly the chart's bounds and keeps gesture composition ordinary.
12
+ */
13
+ export function ChartGestureOverlay({ gesture }: { readonly gesture: GestureType }): ReactElement {
14
+ return (
15
+ <GestureDetector gesture={gesture}>
16
+ <View style={StyleSheet.absoluteFill} />
17
+ </GestureDetector>
18
+ );
19
+ }
20
+
21
+ ChartGestureOverlay.displayName = "DelacourCharts.GestureOverlay";
@@ -0,0 +1,143 @@
1
+ import type { SharedValue } from "react-native-reanimated";
2
+ import type { CurvePath } from "../core/interaction/path-segments";
3
+ import type { ScaleDescriptor } from "../core/scale/scale.types";
4
+
5
+ /**
6
+ * What a scrub knows about one series.
7
+ *
8
+ * `snappedX`/`snappedY` are the canvas point of the nearest datum **on this
9
+ * series**, whichever way the chart is oriented — a dot drawn at them sits on
10
+ * the datum on a vertical chart and at the end of the bar on a horizontal
11
+ * one. `x`/`y` are the gliding equivalent. A consumer that reads the pair
12
+ * never has to know the orientation.
13
+ */
14
+ export type ChartScrubSeriesState = {
15
+ /**
16
+ * Canvas x of the glide point. On a vertical chart the touch x; on a
17
+ * horizontal one the same as `snappedX`, because the glide is disabled
18
+ * there — a bar has no curve to slide along.
19
+ */
20
+ readonly x: SharedValue<number>;
21
+ /**
22
+ * Canvas y **on the drawn curve** at the touched x.
23
+ *
24
+ * A dot bound to this glides continuously along the line. `NaN` inside a
25
+ * gap, where the series has no value. On a horizontal chart, the category's
26
+ * y — the same as `snappedY`.
27
+ */
28
+ readonly y: SharedValue<number>;
29
+ /** Canvas x of the nearest datum: the category's x when vertical, the value's x when horizontal. */
30
+ readonly snappedX: SharedValue<number>;
31
+ /** Canvas y of the nearest datum: the value's y when vertical, the category's y when horizontal. */
32
+ readonly snappedY: SharedValue<number>;
33
+ /** The nearest datum's value in domain units, which is what a label prints. */
34
+ readonly value: SharedValue<number>;
35
+ };
36
+
37
+ /**
38
+ * The scrub, as shared values.
39
+ *
40
+ * Every field holds a `number` or a `boolean` — never an `SkPath`, never an
41
+ * object. That is deliberate: it keeps a consumer's tooltip free of any Skia
42
+ * import and readable from a plain `useAnimatedStyle` on an ordinary React
43
+ * Native view.
44
+ */
45
+ export type ChartScrubState = {
46
+ readonly isActive: SharedValue<boolean>;
47
+ /** Touch x in canvas points, clamped to the plot rect. */
48
+ readonly x: SharedValue<number>;
49
+ /** Touch y in canvas points, clamped to the plot rect. */
50
+ readonly y: SharedValue<number>;
51
+ /**
52
+ * Canvas x of the nearest datum's category, on a vertical chart. `NaN` on
53
+ * a horizontal one, where the category has no x.
54
+ *
55
+ * What a crosshair should sit at whenever the readout beside it names a
56
+ * datum: a vertical line tracking the raw touch while the label snaps says
57
+ * two different things about one gesture.
58
+ */
59
+ readonly snappedX: SharedValue<number>;
60
+ /**
61
+ * Canvas y of the nearest datum's category, on a horizontal chart — where
62
+ * a band highlight `xStep.px` tall is centred. `NaN` on a vertical one.
63
+ */
64
+ readonly snappedY: SharedValue<number>;
65
+ /** The category value under the touch, whichever axis the categories lie on. */
66
+ readonly xValue: SharedValue<number>;
67
+ /** Nearest datum index. `-1` before the first touch. */
68
+ readonly index: SharedValue<number>;
69
+ readonly series: Readonly<Record<string, ChartScrubSeriesState>>;
70
+ };
71
+
72
+ /**
73
+ * One series, as the scrub worklet needs it: plain numbers, nothing to call.
74
+ *
75
+ * `ys` are the canvas positions of each datum's value along the value axis —
76
+ * a y when vertical, an x when horizontal. A stacked key's are its segment
77
+ * tops, so the dot sits on the visible segment; `values` are always the raw
78
+ * series, which is what a readout prints.
79
+ */
80
+ export type ScrubSeries = {
81
+ readonly key: string;
82
+ readonly path: CurvePath;
83
+ readonly ys: readonly (number | null)[];
84
+ readonly values: readonly (number | null)[];
85
+ };
86
+
87
+ /**
88
+ * Everything the scrub worklet reads, in one shared value.
89
+ *
90
+ * `axis` is the canvas axis the categories lie on, and `positions` and
91
+ * `scale` describe them there. `xPositions` and `xScale` are the same two
92
+ * under their older names, kept for a consumer that built against them.
93
+ */
94
+ export type ScrubModel = {
95
+ /** The canvas axis the categories lie on: `x` when vertical, `y` when horizontal. */
96
+ readonly axis: "x" | "y";
97
+ readonly left: number;
98
+ readonly right: number;
99
+ readonly top: number;
100
+ readonly bottom: number;
101
+ /** Canvas position of each row's category along `axis`, ascending. */
102
+ readonly positions: readonly number[];
103
+ /** The category scale, for inverting a touch to a category value. */
104
+ readonly scale: ScaleDescriptor;
105
+ /** Same as `positions`. */
106
+ readonly xPositions: readonly number[];
107
+ /** Same as `scale`. */
108
+ readonly xScale: ScaleDescriptor;
109
+ readonly series: readonly ScrubSeries[];
110
+ };
111
+
112
+ export const EMPTY_SCRUB_MODEL: ScrubModel = {
113
+ axis: "x",
114
+ left: 0,
115
+ right: 0,
116
+ top: 0,
117
+ bottom: 0,
118
+ positions: [],
119
+ scale: { kind: "linear", domain: [0, 1], range: [0, 1] },
120
+ xPositions: [],
121
+ xScale: { kind: "linear", domain: [0, 1], range: [0, 1] },
122
+ series: [],
123
+ };
124
+
125
+ /**
126
+ * How the scrub coexists with a scrolling parent.
127
+ *
128
+ * - `hold` activates after a long press, so a list keeps both axes until the
129
+ * user deliberately holds. The right default for a chart inside a feed.
130
+ * - `claim` gives horizontal drags to the scrub and vertical ones to the
131
+ * scroll. For a chart that owns its width.
132
+ * - `block` wins outright, and needs the scrollable's gesture to block.
133
+ */
134
+ export type ScrubBehaviour = "hold" | "claim" | "block";
135
+
136
+ export type ScrubConfig = {
137
+ readonly enabled?: boolean;
138
+ readonly behaviour?: ScrubBehaviour;
139
+ /** Milliseconds to hold before `hold` activates. */
140
+ readonly holdDuration?: number;
141
+ /** How far a drag must travel before `claim` decides it is horizontal. */
142
+ readonly activationDistance?: number;
143
+ };
@@ -0,0 +1,12 @@
1
+ export {
2
+ type ChartScrubSeriesState,
3
+ type ChartScrubState,
4
+ EMPTY_SCRUB_MODEL,
5
+ type ScrubBehaviour,
6
+ type ScrubConfig,
7
+ type ScrubModel,
8
+ type ScrubSeries,
9
+ } from "./gesture.types";
10
+ export { ChartGestureOverlay } from "./gesture-overlay";
11
+ export { useChartScrub } from "./use-chart-scrub";
12
+ export { type UseScrubGestureOptions, useScrubGesture } from "./use-scrub-gesture";
@@ -0,0 +1,44 @@
1
+ import { useMemo } from "react";
2
+ import { makeMutable } from "react-native-reanimated";
3
+ import type { ChartScrubSeriesState, ChartScrubState } from "./gesture.types";
4
+
5
+ /**
6
+ * Allocates the shared values a scrub writes to.
7
+ *
8
+ * `makeMutable` inside one `useMemo`, rather than a `useSharedValue` per
9
+ * series: the series list is data, and calling a hook in a loop over it breaks
10
+ * the rules of hooks the moment a series is added or removed.
11
+ *
12
+ * Hold the result and pass it to a chart. It stays stable for the life of the
13
+ * component unless the set of keys changes.
14
+ */
15
+ export function useChartScrub(keys: readonly string[]): ChartScrubState {
16
+ const signature = keys.join(" ");
17
+
18
+ // Keyed on the joined signature: a fresh array of the same keys must not
19
+ // reallocate every shared value, which would strand the gesture writing to
20
+ // values nothing renders any more.
21
+ return useMemo<ChartScrubState>(() => {
22
+ const series: Record<string, ChartScrubSeriesState> = {};
23
+ for (const key of signature === "" ? [] : signature.split(" ")) {
24
+ series[key] = {
25
+ x: makeMutable(Number.NaN),
26
+ y: makeMutable(Number.NaN),
27
+ snappedX: makeMutable(Number.NaN),
28
+ snappedY: makeMutable(Number.NaN),
29
+ value: makeMutable(Number.NaN),
30
+ };
31
+ }
32
+
33
+ return {
34
+ isActive: makeMutable(false),
35
+ x: makeMutable(0),
36
+ y: makeMutable(0),
37
+ snappedX: makeMutable(Number.NaN),
38
+ snappedY: makeMutable(Number.NaN),
39
+ xValue: makeMutable(Number.NaN),
40
+ index: makeMutable(-1),
41
+ series,
42
+ };
43
+ }, [signature]);
44
+ }
@@ -0,0 +1,162 @@
1
+ import { useMemo } from "react";
2
+ import { Gesture, type GestureType } from "react-native-gesture-handler";
3
+ import type { SharedValue } from "react-native-reanimated";
4
+ import { closestIndex } from "../core/interaction/closest-index";
5
+ import { getYForX } from "../core/interaction/y-for-x";
6
+ import { invertValue } from "../core/scale/scale";
7
+ import { clamp } from "../core/util/clamp";
8
+ import { readAt } from "../core/util/read-at";
9
+ import type { ChartScrubState, ScrubConfig, ScrubModel, ScrubSeries } from "./gesture.types";
10
+
11
+ const DEFAULT_HOLD_MS = 250;
12
+ const DEFAULT_ACTIVATION_DISTANCE = 8;
13
+
14
+ export type UseScrubGestureOptions = {
15
+ readonly state: ChartScrubState | undefined;
16
+ readonly model: SharedValue<ScrubModel>;
17
+ readonly config?: ScrubConfig;
18
+ /** The scrollable's gesture to out-prioritise, for `behaviour: "block"`. */
19
+ readonly blocks?: GestureType;
20
+ };
21
+
22
+ /**
23
+ * The pan that drives a scrub.
24
+ *
25
+ * Everything happens on the UI thread. The touch x is clamped to the plot
26
+ * rect, inverted through the scale descriptor to a domain value, matched to
27
+ * the nearest datum, and solved against the drawn curve — with no hop to
28
+ * JavaScript, which is what lets the dot keep up with a fast drag.
29
+ *
30
+ * On a horizontal chart the touch **y** is the one that matters: it is
31
+ * clamped, inverted against the category scale and matched to a row, and the
32
+ * series' value positions come back as canvas x. The glide is off there —
33
+ * a bar has no curve to slide along — so every series reports its snapped
34
+ * point twice.
35
+ *
36
+ * The handlers are built inside this `useMemo`, so they close over
37
+ * `closestIndex`, `getYForX`, `invertValue` and `clamp` in the ordinary way.
38
+ * See the package AGENTS.md: that is legal precisely because these are not
39
+ * module-scope worklets.
40
+ */
41
+ export function useScrubGesture(options: UseScrubGestureOptions): GestureType | null {
42
+ const { state, model, config, blocks } = options;
43
+ const enabled = (config?.enabled ?? true) && state !== undefined;
44
+ const behaviour = config?.behaviour ?? "hold";
45
+ const holdDuration = config?.holdDuration ?? DEFAULT_HOLD_MS;
46
+ const activationDistance = config?.activationDistance ?? DEFAULT_ACTIVATION_DISTANCE;
47
+
48
+ const apply = useScrubApply(state, model);
49
+
50
+ return useMemo(() => {
51
+ if (state === undefined || apply === null) return null;
52
+
53
+ const finish = (): void => {
54
+ "worklet";
55
+ state.isActive.value = false;
56
+ };
57
+
58
+ let pan = Gesture.Pan()
59
+ .enabled(enabled)
60
+ .averageTouches(true)
61
+ // A finger dragged above or below the plot keeps scrubbing. Cancelling
62
+ // there strands the dot mid-gesture for anyone whose thumb drifts.
63
+ .shouldCancelWhenOutside(false)
64
+ .onStart((event) => {
65
+ "worklet";
66
+ state.isActive.value = true;
67
+ apply(event.x, event.y);
68
+ })
69
+ .onUpdate((event) => {
70
+ "worklet";
71
+ apply(event.x, event.y);
72
+ })
73
+ // onFinalize, never onEnd: a gesture that fails after activating never
74
+ // fires onEnd, and the dot is stranded on screen until the next touch.
75
+ .onFinalize(finish);
76
+
77
+ if (behaviour === "hold") pan = pan.activateAfterLongPress(holdDuration);
78
+ if (behaviour === "claim") {
79
+ pan = pan
80
+ .activeOffsetX([-activationDistance, activationDistance])
81
+ .failOffsetY([-activationDistance, activationDistance]);
82
+ }
83
+ if (behaviour === "block" && blocks !== undefined) pan = pan.blocksExternalGesture(blocks);
84
+
85
+ return pan;
86
+ }, [state, apply, enabled, behaviour, holdDuration, activationDistance, blocks]);
87
+ }
88
+
89
+ type ScrubApply = (touchX: number, touchY: number) => void;
90
+
91
+ /**
92
+ * The worklet that turns a touch into every scrub value.
93
+ *
94
+ * Built in its own `useMemo` so it closes over `closestIndex`, `getYForX`,
95
+ * `invertValue` and `clamp` in the ordinary way — a hook-scope worklet, which
96
+ * the flat-worklet rule permits — and so the pan above stays small enough to
97
+ * read.
98
+ */
99
+ function useScrubApply(state: ChartScrubState | undefined, model: SharedValue<ScrubModel>): ScrubApply | null {
100
+ const applySeries = useApplySeries(state);
101
+ return useMemo(() => {
102
+ if (state === undefined || applySeries === null) return null;
103
+
104
+ return (touchX: number, touchY: number): void => {
105
+ "worklet";
106
+ const current = model.value;
107
+ // A collapsed plot has nothing to scrub, and would clamp into a NaN.
108
+ if (Math.min(current.right - current.left, current.bottom - current.top) <= 0) return;
109
+
110
+ const x = clamp(touchX, current.left, current.right);
111
+ const y = clamp(touchY, current.top, current.bottom);
112
+ state.x.value = x;
113
+ state.y.value = y;
114
+
115
+ const horizontal = current.axis === "y";
116
+ const along = horizontal ? y : x;
117
+ state.xValue.value = invertValue(current.scale, along);
118
+
119
+ const index = closestIndex(current.positions, along);
120
+ state.index.value = index;
121
+ const category = readAt(current.positions, index);
122
+ state.snappedX.value = horizontal ? Number.NaN : category;
123
+ state.snappedY.value = horizontal ? category : Number.NaN;
124
+
125
+ for (const series of current.series) applySeries(series, index, category, x, horizontal);
126
+ };
127
+ }, [state, model, applySeries]);
128
+ }
129
+
130
+ type ApplySeries = (series: ScrubSeries, index: number, category: number, x: number, horizontal: boolean) => void;
131
+
132
+ /** The per-series half of the scrub, as a hook-scope worklet of its own. */
133
+ function useApplySeries(state: ChartScrubState | undefined): ApplySeries | null {
134
+ return useMemo(() => {
135
+ if (state === undefined) return null;
136
+
137
+ // No glide on a horizontal chart — a bar has no curve to slide along —
138
+ // so the snapped point is reported twice there.
139
+ return (series: ScrubSeries, index: number, category: number, x: number, horizontal: boolean): void => {
140
+ "worklet";
141
+ const target = state.series[series.key];
142
+ if (target === undefined) return;
143
+
144
+ const snapped = readAt(series.ys, index);
145
+ target.value.value = readAt(series.values, index);
146
+ if (horizontal) {
147
+ target.snappedX.value = snapped;
148
+ target.snappedY.value = category;
149
+ target.x.value = snapped;
150
+ target.y.value = category;
151
+ return;
152
+ }
153
+ target.snappedX.value = category;
154
+ target.snappedY.value = snapped;
155
+ target.x.value = x;
156
+ // Inside a gap the curve has no y, so the dot falls back to the nearest
157
+ // datum rather than vanishing to NaN mid-drag.
158
+ const onCurve = getYForX(series.path, x);
159
+ target.y.value = Number.isFinite(onCurve) ? onCurve : snapped;
160
+ };
161
+ }, [state]);
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./animation";
2
+ export * from "./cartesian";
3
+ export * from "./core";
4
+ export * from "./gesture";
5
+ export * from "./polar";
6
+ export * from "./skia";
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { read, relative, SRC, sourceFiles } from "./source-tree.test";
3
+
4
+ /**
5
+ * This package emits no `className`.
6
+ *
7
+ * That is the promise the whole two-package split rests on: colours, fonts and
8
+ * sizes arrive as values, and `@delacour/react-native-ui` is the only place tokens
9
+ * exist. It also has a concrete consequence in the playground — Tailwind's
10
+ * scanner needs no `@source` line pointing here, because there is nothing to
11
+ * scan. A stray `className` would make that missing line a silent bug where a
12
+ * class compiles in development and vanishes from a release build.
13
+ *
14
+ * So the promise is checked rather than hoped for.
15
+ */
16
+ describe("no className", () => {
17
+ const files = sourceFiles(SRC, { skipTests: true });
18
+
19
+ test("finds the source, so a broken walker cannot pass silently", () => {
20
+ expect(files.length).toBeGreaterThan(20);
21
+ });
22
+
23
+ test("no module names className", () => {
24
+ const offenders = files.filter((file) => read(file).includes("className")).map((file) => relative(file));
25
+ expect(offenders).toEqual([]);
26
+ });
27
+ });
@@ -0,0 +1,55 @@
1
+ # polar
2
+
3
+ The pie and donut root, its context, and the marks drawn inside it.
4
+
5
+ Mounted the way `cartesian/` is — a measuring `View`, a `Canvas`, the provider
6
+ **inside** the canvas, an overlay for touch — for the reasons that folder's
7
+ `AGENTS.md` gives. Only the differences are recorded here.
8
+
9
+ ## Files
10
+
11
+ | Path | What |
12
+ | --- | --- |
13
+ | `polar-chart.tsx` | The root: measure, model, canvas, provider, and the tap overlay |
14
+ | `polar-chart.context.tsx` | The context, its hook, and the optional variant |
15
+ | `polar-chart.types.ts` | Props, render args, and the key-narrowing helpers |
16
+ | `hooks/use-polar-model.ts` | Layout and slices, memoised — one pass, nothing measured |
17
+ | `marks/pie-slice.tsx` | One filled slice, growing out of the centre on mount |
18
+ | `marks/pie-slices.tsx` | Every slice, with a cycled palette and selection dimming |
19
+ | `marks/pie-label.tsx` | Skia text on each slice wide enough to hold it |
20
+ | `marks/pie-inset.tsx` | A stroked hairline on each edge between slices |
21
+
22
+ ## Design
23
+
24
+ - **Angles are degrees, 0° at 12 o'clock, clockwise.** The convention every
25
+ pie library shares and the one a designer's "start at the top" maps to
26
+ with no arithmetic. `core/polar/polar-point.ts` does the one conversion to
27
+ trigonometric radians; nothing above it repeats it.
28
+
29
+ - **A slice path is always eleven verbs.** `M C C C C L C C C C Z`, whatever
30
+ the sweep and whether there is a hole — see `core/polar/build-slice-path.ts`.
31
+ That is what lets a data change morph rather than snap, and it is why the
32
+ mount animation is free: the entrance path is the same slice at radius zero.
33
+
34
+ - **Colours are mark props, never a data field.** The root reads a value and a
35
+ label from each row and nothing else. A colour column would be a token in
36
+ disguise, and this package has none.
37
+
38
+ - **The tap runs on the JavaScript thread.** `Gesture.Tap().runOnJS(true)`
39
+ calls `sliceIndexAt` and hands the index to React state. A scrub reads a
40
+ shared value every frame of a drag and has to stay on the UI thread; a tap
41
+ fires once and nothing on the UI side wants its result. `sliceIndexAt` is
42
+ still written as a flat worklet so a scrub over a pie could reuse it.
43
+
44
+ - **The overlay mounts only with `onSlicePress`.** A pie in a scrolling feed
45
+ with no tap handler should take no touches at all, and a gesture detector
46
+ with nothing to do still claims them.
47
+
48
+ - **`selectedIndex` is the caller's, not the chart's.** The root carries it
49
+ into context and the marks read it; the root never changes it. Holding
50
+ selection state here would put a second copy of it beside the one a themed
51
+ wrapper already keeps, and the two would drift.
52
+
53
+ - **`PieInset` draws edges, not outlines.** Stroking each slice would outline
54
+ its arcs too, and a stroked arc under a fill of the same colour reads as a
55
+ slice slightly too big. One `M L` per edge is exactly the hairline wanted.
@@ -0,0 +1,88 @@
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 { collectPieInput } from "../../core/polar/pie-input";
7
+ import type { InnerRadius } from "../../core/polar/polar.types";
8
+ import { resolvePolarLayout } from "../../core/polar/resolve-layout";
9
+ import { resolveSlices } from "../../core/polar/resolve-slices";
10
+ import type { SidedNumber } from "../../core/util/sided-number";
11
+ import { fontMetrics } from "../../skia/font";
12
+ import type { PolarContextValue } from "../polar-chart.types";
13
+
14
+ export type UsePolarModelOptions = {
15
+ readonly data: readonly ChartRow[];
16
+ readonly valueKey: string;
17
+ readonly labelKey?: string;
18
+ readonly canvas: ChartSize;
19
+ readonly padding?: SidedNumber;
20
+ readonly size?: number;
21
+ readonly innerRadius?: InnerRadius;
22
+ readonly startAngle?: number;
23
+ readonly circleSweepDegrees?: number;
24
+ readonly animation?: ChartAnimation;
25
+ readonly font?: SkFont | null;
26
+ readonly selectedIndex?: number | null;
27
+ };
28
+
29
+ /**
30
+ * The whole pie, resolved: the circle's place on the canvas and every slice.
31
+ *
32
+ * Nothing here is measured, so unlike the cartesian model there is a single
33
+ * pass. The hook exists to memoise it — every mark below reads `slices`, and a
34
+ * fresh array on each render would rebuild every slice path and restart every
35
+ * morph while nothing had changed.
36
+ */
37
+ export function usePolarModel(options: UsePolarModelOptions): PolarContextValue {
38
+ const {
39
+ data,
40
+ valueKey,
41
+ labelKey,
42
+ canvas,
43
+ padding,
44
+ size,
45
+ innerRadius,
46
+ startAngle = 0,
47
+ circleSweepDegrees = 360,
48
+ animation = DEFAULT_CHART_ANIMATION,
49
+ font = null,
50
+ selectedIndex = null,
51
+ } = options;
52
+
53
+ return useMemo<PolarContextValue>(() => {
54
+ const layout = resolvePolarLayout({ canvas, padding, size, innerRadius });
55
+ const { values, labels } = collectPieInput(data, valueKey, labelKey);
56
+ const slices = resolveSlices({ values, labels, layout, startAngle, circleSweepDegrees });
57
+ const metrics = fontMetrics(font);
58
+
59
+ return {
60
+ slices,
61
+ center: layout.center,
62
+ radius: layout.radius,
63
+ innerRadius: layout.innerRadius,
64
+ startAngle,
65
+ circleSweepDegrees,
66
+ canvas,
67
+ font,
68
+ lineHeight: metrics.ascent + metrics.descent,
69
+ fontMetrics: metrics,
70
+ animation,
71
+ selectedIndex,
72
+ ready: layout.radius > 0 && data.length > 0,
73
+ };
74
+ }, [
75
+ data,
76
+ valueKey,
77
+ labelKey,
78
+ canvas,
79
+ padding,
80
+ size,
81
+ innerRadius,
82
+ startAngle,
83
+ circleSweepDegrees,
84
+ animation,
85
+ font,
86
+ selectedIndex,
87
+ ]);
88
+ }
@@ -0,0 +1,14 @@
1
+ export { type UsePolarModelOptions, usePolarModel } from "./hooks/use-polar-model";
2
+ export { PieInset, type PieInsetProps } from "./marks/pie-inset";
3
+ export { PieLabel, type PieLabelProps } from "./marks/pie-label";
4
+ export { PieSlice, type PieSliceProps } from "./marks/pie-slice";
5
+ export { PieSlices, type PieSlicesProps } from "./marks/pie-slices";
6
+ export { PolarChart } from "./polar-chart";
7
+ export { PolarChartContext, useOptionalPolarContext, usePolarContext } from "./polar-chart.context";
8
+ export type {
9
+ LabelKeyOf,
10
+ PolarChartProps,
11
+ PolarContextValue,
12
+ PolarRenderArgs,
13
+ ValueKeyOf,
14
+ } from "./polar-chart.types";