@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.
- package/README.md +97 -0
- package/package.json +61 -0
- package/src/animation/AGENTS.md +40 -0
- package/src/animation/animation.types.ts +16 -0
- package/src/animation/index.ts +2 -0
- package/src/animation/use-animated-path.ts +74 -0
- package/src/cartesian/AGENTS.md +110 -0
- package/src/cartesian/axes/chart-x-axis.tsx +52 -0
- package/src/cartesian/axes/chart-y-axis.tsx +56 -0
- package/src/cartesian/cartesian-chart.context.tsx +43 -0
- package/src/cartesian/cartesian-chart.tsx +132 -0
- package/src/cartesian/cartesian-chart.types.ts +181 -0
- package/src/cartesian/hooks/use-canvas-size.ts +37 -0
- package/src/cartesian/hooks/use-chart-model.ts +278 -0
- package/src/cartesian/index.ts +41 -0
- package/src/cartesian/marks/chart-area.tsx +83 -0
- package/src/cartesian/marks/chart-bar-group.tsx +132 -0
- package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
- package/src/cartesian/marks/chart-bar.tsx +195 -0
- package/src/cartesian/marks/chart-candlestick.tsx +148 -0
- package/src/cartesian/marks/chart-cursor.tsx +168 -0
- package/src/cartesian/marks/chart-grid.tsx +46 -0
- package/src/cartesian/marks/chart-line.tsx +73 -0
- package/src/cartesian/marks/chart-scatter.tsx +71 -0
- package/src/core/AGENTS.md +77 -0
- package/src/core/animation/morph-strategy.test.ts +38 -0
- package/src/core/animation/morph-strategy.ts +53 -0
- package/src/core/animation/resample-points.test.ts +68 -0
- package/src/core/animation/resample-points.ts +64 -0
- package/src/core/chart.types.ts +62 -0
- package/src/core/curve/build-area.test.ts +48 -0
- package/src/core/curve/build-area.ts +58 -0
- package/src/core/curve/build-line.test.ts +64 -0
- package/src/core/curve/build-line.ts +53 -0
- package/src/core/curve/curves.test.ts +20 -0
- package/src/core/curve/curves.ts +48 -0
- package/src/core/geometry/band.test.ts +79 -0
- package/src/core/geometry/band.ts +111 -0
- package/src/core/geometry/category-domain.test.ts +33 -0
- package/src/core/geometry/category-domain.ts +18 -0
- package/src/core/geometry/chart-bounds.test.ts +36 -0
- package/src/core/geometry/chart-bounds.ts +47 -0
- package/src/core/geometry/chart-layout.test.ts +230 -0
- package/src/core/geometry/chart-layout.ts +162 -0
- package/src/core/geometry/domain.test.ts +87 -0
- package/src/core/geometry/domain.ts +94 -0
- package/src/core/geometry/grid-lines.test.ts +48 -0
- package/src/core/geometry/grid-lines.ts +43 -0
- package/src/core/geometry/stack.test.ts +151 -0
- package/src/core/geometry/stack.ts +109 -0
- package/src/core/geometry/step.test.ts +34 -0
- package/src/core/geometry/step.ts +21 -0
- package/src/core/geometry/transform-input-data.test.ts +105 -0
- package/src/core/geometry/transform-input-data.ts +88 -0
- package/src/core/geometry/x-values.test.ts +37 -0
- package/src/core/geometry/x-values.ts +36 -0
- package/src/core/index.ts +109 -0
- package/src/core/interaction/closest-index.test.ts +36 -0
- package/src/core/interaction/closest-index.ts +27 -0
- package/src/core/interaction/path-segments.test.ts +60 -0
- package/src/core/interaction/path-segments.ts +155 -0
- package/src/core/interaction/y-for-x.test.ts +135 -0
- package/src/core/interaction/y-for-x.ts +106 -0
- package/src/core/polar/build-slice-path.test.ts +173 -0
- package/src/core/polar/build-slice-path.ts +98 -0
- package/src/core/polar/label-position.test.ts +41 -0
- package/src/core/polar/label-position.ts +18 -0
- package/src/core/polar/pie-input.test.ts +36 -0
- package/src/core/polar/pie-input.ts +32 -0
- package/src/core/polar/polar-point.test.ts +55 -0
- package/src/core/polar/polar-point.ts +29 -0
- package/src/core/polar/polar.types.ts +47 -0
- package/src/core/polar/resolve-layout.test.ts +63 -0
- package/src/core/polar/resolve-layout.ts +55 -0
- package/src/core/polar/resolve-slices.test.ts +89 -0
- package/src/core/polar/resolve-slices.ts +58 -0
- package/src/core/polar/slice-index-at.test.ts +52 -0
- package/src/core/polar/slice-index-at.ts +42 -0
- package/src/core/polar/slice-opacity.test.ts +29 -0
- package/src/core/polar/slice-opacity.ts +12 -0
- package/src/core/scale/make-scale.test.ts +55 -0
- package/src/core/scale/make-scale.ts +78 -0
- package/src/core/scale/scale.test.ts +76 -0
- package/src/core/scale/scale.ts +65 -0
- package/src/core/scale/scale.types.ts +30 -0
- package/src/core/shape/build-bars.test.ts +203 -0
- package/src/core/shape/build-bars.ts +167 -0
- package/src/core/shape/build-candles.test.ts +156 -0
- package/src/core/shape/build-candles.ts +153 -0
- package/src/core/shape/build-scatter.test.ts +103 -0
- package/src/core/shape/build-scatter.ts +81 -0
- package/src/core/shape/rect-path.test.ts +61 -0
- package/src/core/shape/rect-path.ts +69 -0
- package/src/core/text/axis-gutters.test.ts +70 -0
- package/src/core/text/axis-gutters.ts +70 -0
- package/src/core/text/bar-label.test.ts +92 -0
- package/src/core/text/bar-label.ts +61 -0
- package/src/core/text/format-tick.test.ts +87 -0
- package/src/core/text/format-tick.ts +76 -0
- package/src/core/text/label-anchor.test.ts +59 -0
- package/src/core/text/label-anchor.ts +62 -0
- package/src/core/ticks/build-ticks.test.ts +38 -0
- package/src/core/ticks/build-ticks.ts +36 -0
- package/src/core/ticks/linear-ticks.test.ts +56 -0
- package/src/core/ticks/linear-ticks.ts +67 -0
- package/src/core/ticks/tick-count.test.ts +46 -0
- package/src/core/ticks/tick-count.ts +35 -0
- package/src/core/ticks/tick.types.ts +7 -0
- package/src/core/ticks/time-ticks.test.ts +61 -0
- package/src/core/ticks/time-ticks.ts +22 -0
- package/src/core/util/as-number.test.ts +45 -0
- package/src/core/util/as-number.ts +22 -0
- package/src/core/util/clamp.test.ts +21 -0
- package/src/core/util/clamp.ts +16 -0
- package/src/core/util/extent.test.ts +33 -0
- package/src/core/util/extent.ts +37 -0
- package/src/core/util/read-at.test.ts +14 -0
- package/src/core/util/read-at.ts +14 -0
- package/src/core/util/sided-number.test.ts +28 -0
- package/src/core/util/sided-number.ts +34 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +84 -0
- package/src/gesture/gesture-overlay.tsx +21 -0
- package/src/gesture/gesture.types.ts +143 -0
- package/src/gesture/index.ts +12 -0
- package/src/gesture/use-chart-scrub.ts +44 -0
- package/src/gesture/use-scrub-gesture.ts +162 -0
- package/src/index.ts +6 -0
- package/src/no-classname.test.ts +27 -0
- package/src/polar/AGENTS.md +55 -0
- package/src/polar/hooks/use-polar-model.ts +88 -0
- package/src/polar/index.ts +14 -0
- package/src/polar/marks/pie-inset.tsx +70 -0
- package/src/polar/marks/pie-label.tsx +71 -0
- package/src/polar/marks/pie-slice.tsx +82 -0
- package/src/polar/marks/pie-slices.tsx +60 -0
- package/src/polar/polar-chart.context.tsx +37 -0
- package/src/polar/polar-chart.tsx +91 -0
- package/src/polar/polar-chart.types.ts +82 -0
- package/src/purity.test.ts +75 -0
- package/src/skia/AGENTS.md +39 -0
- package/src/skia/build-path.ts +22 -0
- package/src/skia/font.ts +78 -0
- package/src/skia/index.ts +9 -0
- 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,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";
|