@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
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @delacour/react-native-charts
|
|
2
|
+
|
|
3
|
+
Headless charting primitives for React Native — Skia for drawing, Reanimated
|
|
4
|
+
for animation, Gesture Handler for touch.
|
|
5
|
+
|
|
6
|
+
No tokens, no `className`, no theme. Every colour, font and size is a value you
|
|
7
|
+
pass in. If you want charts already wearing a design system, use
|
|
8
|
+
[`@delacour/react-native-ui/chart`](https://ui.delacour.co.nz/docs/native/components/chart),
|
|
9
|
+
which is this engine with the tokens attached.
|
|
10
|
+
|
|
11
|
+
> **Alpha.** Both this package and `@delacour/react-native-ui` publish to npm's
|
|
12
|
+
> `alpha` tag, and every alpha is also `latest`.
|
|
13
|
+
|
|
14
|
+
Docs: [ui.delacour.co.nz/docs/charts](https://ui.delacour.co.nz/docs/charts).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @delacour/react-native-charts@alpha
|
|
20
|
+
bunx expo install @shopify/react-native-skia
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Peers: `@shopify/react-native-skia`, `react-native-reanimated`,
|
|
24
|
+
`react-native-gesture-handler`, `react-native-worklets`, `react`,
|
|
25
|
+
`react-native`.
|
|
26
|
+
|
|
27
|
+
Your app needs a `GestureHandlerRootView` at its root. This package does not
|
|
28
|
+
render one — a nested root is dead weight, and every React Native app that
|
|
29
|
+
handles touch already has one.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { CartesianChart, ChartArea, ChartLine } from "@delacour/react-native-charts";
|
|
35
|
+
|
|
36
|
+
<CartesianChart data={rows} xKey="day" yKeys={["revenue"]}>
|
|
37
|
+
{({ points, bounds }) => (
|
|
38
|
+
<>
|
|
39
|
+
<ChartArea baseline={bounds.bottom} color="#0A84FF22" curve="monotone" points={points.revenue} />
|
|
40
|
+
<ChartLine color="#0A84FF" curve="monotone" points={points.revenue} strokeWidth={2} />
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
</CartesianChart>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Marks also read the chart context, so they can be placed rather than called:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<CartesianChart data={rows} xKey="day" yKeys={["revenue"]}>
|
|
50
|
+
<ChartLine color="#0A84FF" yKey="revenue" />
|
|
51
|
+
</CartesianChart>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Bars stand on zero and need half a step of x padding so the outermost ones sit
|
|
55
|
+
inside the plot. Sibling series stack when the root is told which keys to
|
|
56
|
+
stack, because only the root can size the y axis for the totals:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { CartesianChart, ChartBar, ChartBarStack } from "@delacour/react-native-charts";
|
|
60
|
+
|
|
61
|
+
<CartesianChart data={rows} domainPadding={{ x: 0.5 }} includeZero xKey="month" yKeys={["revenue"]}>
|
|
62
|
+
<ChartBar color="#0A84FF" roundedCorners={{ topLeft: 4, topRight: 4 }} yKey="revenue" />
|
|
63
|
+
</CartesianChart>
|
|
64
|
+
|
|
65
|
+
<CartesianChart data={rows} domainPadding={{ x: 0.5 }} stackKeys={["web", "ios", "android"]} xKey="month" yKeys={["web", "ios", "android"]}>
|
|
66
|
+
<ChartBarStack colors={["#0A84FF", "#30D158", "#FF9F0A"]} yKeys={["web", "ios", "android"]} />
|
|
67
|
+
</CartesianChart>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add `orientation="horizontal"` to the root and the categories run down the
|
|
71
|
+
left with the bars growing rightward; nothing else changes.
|
|
72
|
+
|
|
73
|
+
A pie is its own root. Slices, labels and the hairline between them are marks,
|
|
74
|
+
and a tap reports the slice under the finger:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { PieInset, PieLabel, PieSlices, PolarChart } from "@delacour/react-native-charts";
|
|
78
|
+
|
|
79
|
+
<PolarChart data={rows} innerRadius="60%" labelKey="browser" onSlicePress={setSelected} selectedIndex={selected} valueKey="share">
|
|
80
|
+
<PieSlices colors={["#0A84FF", "#30D158", "#FF9F0A", "#FF375F"]} />
|
|
81
|
+
<PieInset color="#FFFFFF" strokeWidth={2} />
|
|
82
|
+
<PieLabel color="#FFFFFF" font={font} formatLabel={(slice) => `${Math.round(slice.fraction * 100)}%`} minSweep={12} />
|
|
83
|
+
</PolarChart>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## The maths on its own
|
|
87
|
+
|
|
88
|
+
`@delacour/react-native-charts/core` is every scale, tick, curve and solver in the package,
|
|
89
|
+
importable with no Skia in the module graph:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { closestIndex, invertValue, makeScale } from "@delacour/react-native-charts/core";
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Licence
|
|
96
|
+
|
|
97
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@delacour/react-native-charts",
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
|
+
"description": "Headless Skia charting primitives for React Native \u2014 no tokens, no className",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/delacournz/delacour-ui.git",
|
|
10
|
+
"directory": "packages/charts"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"skia",
|
|
15
|
+
"charts",
|
|
16
|
+
"reanimated",
|
|
17
|
+
"gesture-handler",
|
|
18
|
+
"expo"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/index.ts",
|
|
30
|
+
"./core": "./src/core/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"fmt": "biome format --write ./src",
|
|
34
|
+
"lint": "biome lint --write ./src",
|
|
35
|
+
"check": "biome check ./src",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "bun test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@types/d3-scale": "^4.0.9",
|
|
41
|
+
"@types/d3-shape": "^3.1.7",
|
|
42
|
+
"d3-scale": "^4.0.2",
|
|
43
|
+
"d3-shape": "^3.2.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@shopify/react-native-skia": ">=2.6",
|
|
47
|
+
"react": ">=19",
|
|
48
|
+
"react-native": ">=0.81",
|
|
49
|
+
"react-native-gesture-handler": ">=2.28",
|
|
50
|
+
"react-native-reanimated": ">=4",
|
|
51
|
+
"react-native-worklets": ">=0.5"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@delacour/biome-config": "workspace:*",
|
|
55
|
+
"@delacour/tsconfig": "workspace:*",
|
|
56
|
+
"@shopify/react-native-skia": "catalog:",
|
|
57
|
+
"@types/bun": "^1.3.8",
|
|
58
|
+
"@types/react": "catalog:",
|
|
59
|
+
"typescript": "^5.9.3"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# animation
|
|
2
|
+
|
|
3
|
+
Path morphing, and the configuration that drives it.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| Path | What |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `animation.types.ts` | `ChartAnimation` — timing, spring, or none |
|
|
10
|
+
| `use-animated-path.ts` | A path that morphs whenever it changes |
|
|
11
|
+
|
|
12
|
+
## Design
|
|
13
|
+
|
|
14
|
+
- **Interpolation is Skia's own `usePathInterpolation`.** One native
|
|
15
|
+
`Path.Interpolate` per frame, with no path allocated on the JavaScript side.
|
|
16
|
+
The alternative — constructing a path inside a `useDerivedValue` — is the
|
|
17
|
+
shape behind a long-standing crash with Skia host objects in shared values.
|
|
18
|
+
|
|
19
|
+
- **It never falls back to snapping, and says so when it does.** Point counts
|
|
20
|
+
are matched in data space before either path is built, so two paths always
|
|
21
|
+
share a verb sequence and `isInterpolatable` always holds. The
|
|
22
|
+
development-only warning exists to make a regression loud: if it fires,
|
|
23
|
+
`chooseMorphStrategy` or `matchPointCounts` has a bug, and silently snapping
|
|
24
|
+
is exactly how that bug would survive a release.
|
|
25
|
+
|
|
26
|
+
- **`none` is not a zero duration.** It skips the interpolation entirely, which
|
|
27
|
+
is what a chart re-rendering off a live feed needs — there is nothing to
|
|
28
|
+
animate towards when the target moves every frame, and starting an animation
|
|
29
|
+
you immediately abandon costs a frame each time.
|
|
30
|
+
|
|
31
|
+
- **The animation config is read through a ref inside the effect.** An inline
|
|
32
|
+
`{ type: "timing" }` prop is a new object every render; in the dependency
|
|
33
|
+
list it would restart the animation on every render, which reads as a line
|
|
34
|
+
that never settles.
|
|
35
|
+
|
|
36
|
+
- **`enterFrom` is read on mount and never again.** It seeds the first morph
|
|
37
|
+
— a pie slice hands over itself at radius zero and grows out of the centre —
|
|
38
|
+
and is then forgotten, so an inline path built on every render cannot restart
|
|
39
|
+
the entrance. Omitted, the hook behaves exactly as it did before the option
|
|
40
|
+
existed: the range starts at the target and nothing is animated on mount.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WithSpringConfig, WithTimingConfig } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a mark moves when its data changes.
|
|
5
|
+
*
|
|
6
|
+
* `none` is not the same as a zero duration: it skips the interpolation
|
|
7
|
+
* entirely, which is what a chart re-rendering sixty times a second off a live
|
|
8
|
+
* feed wants — there is nothing to animate towards when the target moves every
|
|
9
|
+
* frame.
|
|
10
|
+
*/
|
|
11
|
+
export type ChartAnimation =
|
|
12
|
+
| { readonly type: "none" }
|
|
13
|
+
| ({ readonly type: "timing" } & WithTimingConfig)
|
|
14
|
+
| ({ readonly type: "spring" } & WithSpringConfig);
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CHART_ANIMATION: ChartAnimation = { type: "timing", duration: 300 };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type SkPath, usePathInterpolation } from "@shopify/react-native-skia";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { type SharedValue, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
|
|
4
|
+
import { type ChartAnimation, DEFAULT_CHART_ANIMATION } from "./animation.types";
|
|
5
|
+
|
|
6
|
+
export type AnimatedPathOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The path the very first animation starts from. Read on mount only.
|
|
9
|
+
*
|
|
10
|
+
* Without it a mark appears fully drawn, because there is no earlier path
|
|
11
|
+
* to morph from. A pie slice hands over the same slice at radius zero and
|
|
12
|
+
* scales out of the centre; a bar could hand over itself at the baseline.
|
|
13
|
+
* It must share the target's verb sequence, or the mount snaps like any
|
|
14
|
+
* other non-interpolatable change.
|
|
15
|
+
*/
|
|
16
|
+
readonly enterFrom?: SkPath;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A path that morphs to `path` whenever it changes.
|
|
21
|
+
*
|
|
22
|
+
* Interpolation is Skia's own `usePathInterpolation` — one native
|
|
23
|
+
* `Path.Interpolate` per frame, with no path allocated on the JS side. The
|
|
24
|
+
* alternative, building a path inside a `useDerivedValue`, is the shape behind
|
|
25
|
+
* a long-standing crash with Skia host objects in shared values.
|
|
26
|
+
*
|
|
27
|
+
* It never has to fall back to snapping, because point counts are matched in
|
|
28
|
+
* data space before either path is built — see `core/animation`. The
|
|
29
|
+
* development-only warning below exists to say so: if it ever fires, the morph
|
|
30
|
+
* strategy has a bug, and silently snapping is how that bug would survive a
|
|
31
|
+
* release.
|
|
32
|
+
*/
|
|
33
|
+
export function useAnimatedPath(
|
|
34
|
+
path: SkPath,
|
|
35
|
+
animation: ChartAnimation = DEFAULT_CHART_ANIMATION,
|
|
36
|
+
options?: AnimatedPathOptions
|
|
37
|
+
): SharedValue<SkPath> {
|
|
38
|
+
// `enterFrom` is consulted by the initialisers alone, so a later change to
|
|
39
|
+
// it does nothing: the entrance has already happened. Seeding the range and
|
|
40
|
+
// the progress with it, rather than only the ref, is what stops the first
|
|
41
|
+
// frame showing the finished path before the effect below starts the morph.
|
|
42
|
+
const entrance = options?.enterFrom ?? path;
|
|
43
|
+
const progress = useSharedValue(entrance === path ? 1 : 0);
|
|
44
|
+
const previous = useRef<SkPath>(entrance);
|
|
45
|
+
const config = useRef<ChartAnimation>(animation);
|
|
46
|
+
config.current = animation;
|
|
47
|
+
|
|
48
|
+
const [range, setRange] = useState<SkPath[]>(() => [entrance, path]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const from = previous.current;
|
|
52
|
+
previous.current = path;
|
|
53
|
+
|
|
54
|
+
const current = config.current;
|
|
55
|
+
if (current.type === "none" || !from.isInterpolatable(path)) {
|
|
56
|
+
if (__DEV__ && current.type !== "none" && !from.isInterpolatable(path)) {
|
|
57
|
+
console.warn(
|
|
58
|
+
"[@delacour/react-native-charts] two paths were not interpolatable, so the change snapped. " +
|
|
59
|
+
"Point counts are supposed to be matched in data space before the paths are built — " +
|
|
60
|
+
"this means chooseMorphStrategy or matchPointCounts got it wrong."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
setRange([path, path]);
|
|
64
|
+
progress.value = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setRange([from, path]);
|
|
69
|
+
progress.value = 0;
|
|
70
|
+
progress.value = current.type === "spring" ? withSpring(1, current) : withTiming(1, current);
|
|
71
|
+
}, [path, progress]);
|
|
72
|
+
|
|
73
|
+
return usePathInterpolation(progress, [0, 1], range);
|
|
74
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# cartesian
|
|
2
|
+
|
|
3
|
+
The chart root, its context, and the marks and axes drawn inside it.
|
|
4
|
+
|
|
5
|
+
## Where things are mounted, and why
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
View onLayout, and the only thing that knows the chart's size
|
|
9
|
+
Canvas the Skia reconciler starts here
|
|
10
|
+
Provider ← rendered INSIDE the canvas, so marks below can read it
|
|
11
|
+
children
|
|
12
|
+
Overlay an ordinary RN view; the canvas has no touch targets
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The provider's position is the whole design. `<Canvas>` mounts a **second React
|
|
16
|
+
reconciler**, and context does not cross a reconciler boundary — but it
|
|
17
|
+
resolves by which reconciler renders the *provider node*, not by where the
|
|
18
|
+
context object was created. Placing the provider among the canvas' children
|
|
19
|
+
puts it in the Skia tree, and every mark below resolves it normally. No
|
|
20
|
+
`its-fine`, no context bridge, no `FiberProvider` wrapping the host app.
|
|
21
|
+
|
|
22
|
+
**The consequence for anything that skins this package:** every hook that reads
|
|
23
|
+
a theme — `useThemeColor`, anything backed by a CSS variable — has to be called
|
|
24
|
+
*above* the canvas and its result passed down as a plain value. There is
|
|
25
|
+
nowhere inside to call one. That is not a limitation to work around; it is what
|
|
26
|
+
keeps the re-render boundary visible in a diff.
|
|
27
|
+
|
|
28
|
+
## Files
|
|
29
|
+
|
|
30
|
+
| Path | What |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `cartesian-chart.tsx` | The root: measure, model, canvas, provider, gesture overlay |
|
|
33
|
+
| `cartesian-chart.context.tsx` | The context, its hook, and the optional variant |
|
|
34
|
+
| `cartesian-chart.types.ts` | Props, render args, and the key-narrowing helpers |
|
|
35
|
+
| `hooks/use-canvas-size.ts` | `onLayout` on the wrapping view |
|
|
36
|
+
| `hooks/use-chart-model.ts` | The two passes, with the measuring between them; the axis roles, the x step, the stacks |
|
|
37
|
+
| `marks/chart-line.tsx` | A stroked series |
|
|
38
|
+
| `marks/chart-area.tsx` | The region between a series and a baseline |
|
|
39
|
+
| `marks/chart-bar.tsx` | One bar per datum, on zero, with optional value labels |
|
|
40
|
+
| `marks/chart-bar-group.tsx` | Several series side by side within each step, one path per series |
|
|
41
|
+
| `marks/chart-bar-stack.tsx` | Several series stacked, read from the root's `stacked`, one path per series |
|
|
42
|
+
| `marks/chart-scatter.tsx` | A circle, square or star per datum, all in one path |
|
|
43
|
+
| `marks/chart-candlestick.tsx` | Open-high-low-close candles: six paths, wicks under bodies |
|
|
44
|
+
| `marks/chart-grid.tsx` | One path holding every rule |
|
|
45
|
+
| `axes/chart-x-axis.tsx` | Tick labels below the plot |
|
|
46
|
+
| `axes/chart-y-axis.tsx` | Tick labels beside the plot |
|
|
47
|
+
|
|
48
|
+
## Design
|
|
49
|
+
|
|
50
|
+
- **A mark takes `yKey` or `points`, never needing both.** Given a key it reads
|
|
51
|
+
the context; given points it draws exactly those. That is what lets one
|
|
52
|
+
implementation serve the declarative call site and the render-prop one, so
|
|
53
|
+
the two can never drift apart or grow different bugs.
|
|
54
|
+
|
|
55
|
+
- **The model is a hook, and everything it computes is not.** `useChartModel`
|
|
56
|
+
measures labels with the font and does nothing else of consequence — the
|
|
57
|
+
planning either side of the measurement lives in `core/geometry/chart-layout.ts`
|
|
58
|
+
and is unit-tested. `bun test` cannot render a `.tsx`, so logic left in one
|
|
59
|
+
is logic nothing checks.
|
|
60
|
+
|
|
61
|
+
- **Skia text for axis labels; React Native text for legends and tooltips.**
|
|
62
|
+
The distinction is whether it moves *with* the canvas. Under a pan the marks
|
|
63
|
+
move on the UI thread, and an overlaid RN label would move on the JavaScript
|
|
64
|
+
thread and lag by a frame — the most visible bug a chart can have. A legend
|
|
65
|
+
sits beside the canvas and is static relative to it, so it belongs in a view
|
|
66
|
+
where it can have a type scale and a colour token.
|
|
67
|
+
|
|
68
|
+
- **The grid is one path, not one node per rule.** Two axes at eight ticks each
|
|
69
|
+
is sixteen Skia nodes for what is a single stroke, and node count is what a
|
|
70
|
+
Skia tree costs.
|
|
71
|
+
|
|
72
|
+
- **A bar's width comes from the chart, not from a band scale.** The root
|
|
73
|
+
measures `xStep` — the smallest gap between x values — and publishes the x
|
|
74
|
+
positions; `resolveBand` turns those into a step and a bandwidth on the
|
|
75
|
+
canvas. The root also has to pad the x domain by half a step
|
|
76
|
+
(`domainPadding={{ x: 0.5 }}`) or the outermost bars straddle the plot's
|
|
77
|
+
edge; `ChartBar` warns in development when it does not.
|
|
78
|
+
|
|
79
|
+
- **The root stacks; the stack mark only draws.** `stackKeys` names the series
|
|
80
|
+
to stack, the model stacks them in data space so the y domain covers the
|
|
81
|
+
totals, and `ChartBarStack` reads `chart.stacked`. A mark that stacked for
|
|
82
|
+
itself would draw past the top of the plot. `points[key]` stays the raw
|
|
83
|
+
series, which is what a tooltip prints.
|
|
84
|
+
|
|
85
|
+
- **A group and a stack are one path per series, not one per bar.** Node count
|
|
86
|
+
is what a Skia tree costs, and a series' bars morph together anyway. The
|
|
87
|
+
price is that per-datum colour and opacity are not offered — `barOptions` on
|
|
88
|
+
a stack gives per-segment corners, which a path can carry, and nothing else.
|
|
89
|
+
|
|
90
|
+
- **`ChartBarGroup` takes `yKeys`, not child marks.** The group has to know how
|
|
91
|
+
many series there are before any of them can know its offset, and reading
|
|
92
|
+
that off children is the same information arriving later. A Skia tree gains
|
|
93
|
+
nothing from identity matching.
|
|
94
|
+
|
|
95
|
+
- **`orientation` is resolved once, in the model.** The hook plans the
|
|
96
|
+
category axis and the value axis by role, and `placeAxisRoles` puts them on
|
|
97
|
+
the canvas — categories down y, top first, when horizontal. Every mark then
|
|
98
|
+
reads `chart.orientation` and hands it to its shape builder; none of them
|
|
99
|
+
swaps a coordinate itself. `xScale` and `yScale` on the context are always
|
|
100
|
+
the canvas axes, so a mark that wants the value scale asks by role.
|
|
101
|
+
|
|
102
|
+
- **`useChartContext` throws outside a chart.** A mark that silently rendered
|
|
103
|
+
nothing would leave a blank canvas with no error, and a Skia tree has no
|
|
104
|
+
element inspector to look at.
|
|
105
|
+
|
|
106
|
+
- **The scrub model is rebuilt on the JavaScript thread and read on the UI
|
|
107
|
+
one.** The root packs bounds, x positions, the scale descriptor and each
|
|
108
|
+
series' cubic runs into a single shared value whenever the data changes. The
|
|
109
|
+
pan then reads plain numbers every frame, with no bridge hop — which is what
|
|
110
|
+
lets the dot keep up with a fast drag.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Text } from "@shopify/react-native-skia";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { anchorX, anchorY } from "../../core/text/label-anchor";
|
|
4
|
+
import { measureLabelWidth } from "../../skia/font";
|
|
5
|
+
import { useChartContext } from "../cartesian-chart.context";
|
|
6
|
+
|
|
7
|
+
export type ChartXAxisProps = {
|
|
8
|
+
readonly color: string;
|
|
9
|
+
/** Space between the plot rect and the labels. */
|
|
10
|
+
readonly tickPadding?: number;
|
|
11
|
+
readonly opacity?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tick labels below the plot rect.
|
|
16
|
+
*
|
|
17
|
+
* Skia text, not React Native text. Under a pan or a zoom the marks move on
|
|
18
|
+
* the UI thread; overlaid RN labels would move on the JavaScript thread and
|
|
19
|
+
* lag them by a frame, which is the most visible bug a chart can have. Labels
|
|
20
|
+
* that sit *beside* the canvas rather than moving with it — a legend, a
|
|
21
|
+
* tooltip — are the opposite case and belong in RN views.
|
|
22
|
+
*
|
|
23
|
+
* Draws nothing while `font` is `null`. `matchFont` resolves synchronously in
|
|
24
|
+
* practice, but a chart that renders before it would otherwise crash rather
|
|
25
|
+
* than come up a frame late.
|
|
26
|
+
*/
|
|
27
|
+
export function ChartXAxis({ color, tickPadding = 6, opacity }: ChartXAxisProps): ReactElement | null {
|
|
28
|
+
const { xTicks, xLabels, bounds, font, fontMetrics } = useChartContext();
|
|
29
|
+
if (font === null || xLabels.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{xTicks.map((tick, index) => {
|
|
34
|
+
const label = xLabels[index];
|
|
35
|
+
if (label === undefined || label === "" || !Number.isFinite(tick.position)) return null;
|
|
36
|
+
return (
|
|
37
|
+
<Text
|
|
38
|
+
color={color}
|
|
39
|
+
font={font}
|
|
40
|
+
key={`${tick.value}-${label}`}
|
|
41
|
+
opacity={opacity}
|
|
42
|
+
text={label}
|
|
43
|
+
x={anchorX(tick.position, measureLabelWidth(font, label), "middle")}
|
|
44
|
+
y={anchorY(bounds.bottom, fontMetrics, "below", tickPadding)}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ChartXAxis.displayName = "DelacourCharts.ChartXAxis";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Text } from "@shopify/react-native-skia";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { anchorX, anchorY } from "../../core/text/label-anchor";
|
|
4
|
+
import { measureLabelWidth } from "../../skia/font";
|
|
5
|
+
import { useChartContext } from "../cartesian-chart.context";
|
|
6
|
+
|
|
7
|
+
export type ChartYAxisProps = {
|
|
8
|
+
readonly color: string;
|
|
9
|
+
/** Space between the labels and the plot rect. */
|
|
10
|
+
readonly tickPadding?: number;
|
|
11
|
+
readonly side?: "left" | "right";
|
|
12
|
+
readonly opacity?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tick labels beside the plot rect.
|
|
17
|
+
*
|
|
18
|
+
* Right-aligned against the plot's left edge, so the labels form a clean
|
|
19
|
+
* column whatever their widths — a left-aligned y axis puts "5" and "1000"
|
|
20
|
+
* on different vertical lines and reads as ragged.
|
|
21
|
+
*
|
|
22
|
+
* Vertically the glyph box is centred on the tick rather than the baseline;
|
|
23
|
+
* `anchorY`'s `middle` is what handles that, and skipping it puts every label
|
|
24
|
+
* a third of a line-height below its own gridline.
|
|
25
|
+
*/
|
|
26
|
+
export function ChartYAxis({ color, tickPadding = 6, side = "left", opacity }: ChartYAxisProps): ReactElement | null {
|
|
27
|
+
const { yTicks, yLabels, bounds, font, fontMetrics } = useChartContext();
|
|
28
|
+
if (font === null || yLabels.length === 0) return null;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{yTicks.map((tick, index) => {
|
|
33
|
+
const label = yLabels[index];
|
|
34
|
+
if (label === undefined || label === "" || !Number.isFinite(tick.position)) return null;
|
|
35
|
+
const width = measureLabelWidth(font, label);
|
|
36
|
+
return (
|
|
37
|
+
<Text
|
|
38
|
+
color={color}
|
|
39
|
+
font={font}
|
|
40
|
+
key={`${tick.value}-${label}`}
|
|
41
|
+
opacity={opacity}
|
|
42
|
+
text={label}
|
|
43
|
+
x={
|
|
44
|
+
side === "left"
|
|
45
|
+
? anchorX(bounds.left - tickPadding, width, "end")
|
|
46
|
+
: anchorX(bounds.right + tickPadding, width, "start")
|
|
47
|
+
}
|
|
48
|
+
y={anchorY(tick.position, fontMetrics, "middle")}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ChartYAxis.displayName = "DelacourCharts.ChartYAxis";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ChartContextValue } from "./cartesian-chart.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The resolved chart, published to its children.
|
|
6
|
+
*
|
|
7
|
+
* The provider is rendered **inside** the Skia canvas. That is what makes this
|
|
8
|
+
* work at all: `<Canvas>` mounts a second React reconciler, so a provider
|
|
9
|
+
* placed above it is invisible to Skia children below it. Context resolves by
|
|
10
|
+
* which reconciler renders the *provider node*, not by where the context
|
|
11
|
+
* object was created — so mounting the provider as a child of the canvas puts
|
|
12
|
+
* it in the Skia tree and consumers below resolve it normally.
|
|
13
|
+
*
|
|
14
|
+
* It is exported rather than hidden behind its hook because a consumer that
|
|
15
|
+
* wants its own context inside the canvas needs to do exactly the same thing,
|
|
16
|
+
* and needs something to copy.
|
|
17
|
+
*/
|
|
18
|
+
export const CartesianChartContext = createContext<ChartContextValue | null>(null);
|
|
19
|
+
|
|
20
|
+
CartesianChartContext.displayName = "DelacourCharts.CartesianChartContext";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The chart a mark is drawn in.
|
|
24
|
+
*
|
|
25
|
+
* Throws outside a chart rather than returning `null`. A mark that silently
|
|
26
|
+
* renders nothing is a blank canvas with no error, which is among the hardest
|
|
27
|
+
* things to diagnose in a Skia tree — there is no element inspector to look at.
|
|
28
|
+
*/
|
|
29
|
+
export function useChartContext(): ChartContextValue {
|
|
30
|
+
const value = useContext(CartesianChartContext);
|
|
31
|
+
if (value === null) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"[@delacour/react-native-charts] a chart mark was rendered outside a <CartesianChart>. " +
|
|
34
|
+
"Marks read the chart from context, so they have to be its children."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The chart, or `null` outside one. For a part that is legitimately optional. */
|
|
41
|
+
export function useOptionalChartContext(): ChartContextValue | null {
|
|
42
|
+
return useContext(CartesianChartContext);
|
|
43
|
+
}
|