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

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 +98 -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
package/README.md ADDED
@@ -0,0 +1,98 @@
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
20
+ bunx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets
21
+ ```
22
+
23
+ Peers: `@shopify/react-native-skia`, `react-native-reanimated`,
24
+ `react-native-gesture-handler`, `react-native-worklets`, `react`,
25
+ `react-native`. The native four go through `expo install`, so the SDK picks
26
+ versions it can build; `react` and `react-native` are already in any app.
27
+
28
+ Your app needs a `GestureHandlerRootView` at its root. This package does not
29
+ render one — a nested root is dead weight, and every React Native app that
30
+ handles touch already has one.
31
+
32
+ ## Use
33
+
34
+ ```tsx
35
+ import { CartesianChart, ChartArea, ChartLine } from "@delacour/react-native-charts";
36
+
37
+ <CartesianChart data={rows} xKey="day" yKeys={["revenue"]}>
38
+ {({ points, bounds }) => (
39
+ <>
40
+ <ChartArea baseline={bounds.bottom} color="#0A84FF22" curve="monotone" points={points.revenue} />
41
+ <ChartLine color="#0A84FF" curve="monotone" points={points.revenue} strokeWidth={2} />
42
+ </>
43
+ )}
44
+ </CartesianChart>
45
+ ```
46
+
47
+ Marks also read the chart context, so they can be placed rather than called:
48
+
49
+ ```tsx
50
+ <CartesianChart data={rows} xKey="day" yKeys={["revenue"]}>
51
+ <ChartLine color="#0A84FF" yKey="revenue" />
52
+ </CartesianChart>
53
+ ```
54
+
55
+ Bars stand on zero and need half a step of x padding so the outermost ones sit
56
+ inside the plot. Sibling series stack when the root is told which keys to
57
+ stack, because only the root can size the y axis for the totals:
58
+
59
+ ```tsx
60
+ import { CartesianChart, ChartBar, ChartBarStack } from "@delacour/react-native-charts";
61
+
62
+ <CartesianChart data={rows} domainPadding={{ x: 0.5 }} includeZero xKey="month" yKeys={["revenue"]}>
63
+ <ChartBar color="#0A84FF" roundedCorners={{ topLeft: 4, topRight: 4 }} yKey="revenue" />
64
+ </CartesianChart>
65
+
66
+ <CartesianChart data={rows} domainPadding={{ x: 0.5 }} stackKeys={["web", "ios", "android"]} xKey="month" yKeys={["web", "ios", "android"]}>
67
+ <ChartBarStack colors={["#0A84FF", "#30D158", "#FF9F0A"]} yKeys={["web", "ios", "android"]} />
68
+ </CartesianChart>
69
+ ```
70
+
71
+ Add `orientation="horizontal"` to the root and the categories run down the
72
+ left with the bars growing rightward; nothing else changes.
73
+
74
+ A pie is its own root. Slices, labels and the hairline between them are marks,
75
+ and a tap reports the slice under the finger:
76
+
77
+ ```tsx
78
+ import { PieInset, PieLabel, PieSlices, PolarChart } from "@delacour/react-native-charts";
79
+
80
+ <PolarChart data={rows} innerRadius="60%" labelKey="browser" onSlicePress={setSelected} selectedIndex={selected} valueKey="share">
81
+ <PieSlices colors={["#0A84FF", "#30D158", "#FF9F0A", "#FF375F"]} />
82
+ <PieInset color="#FFFFFF" strokeWidth={2} />
83
+ <PieLabel color="#FFFFFF" font={font} formatLabel={(slice) => `${Math.round(slice.fraction * 100)}%`} minSweep={12} />
84
+ </PolarChart>
85
+ ```
86
+
87
+ ## The maths on its own
88
+
89
+ `@delacour/react-native-charts/core` is every scale, tick, curve and solver in the package,
90
+ importable with no Skia in the module graph:
91
+
92
+ ```ts
93
+ import { closestIndex, invertValue, makeScale } from "@delacour/react-native-charts/core";
94
+ ```
95
+
96
+ ## Licence
97
+
98
+ MIT
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@delacour/react-native-charts",
3
+ "version": "0.1.0-alpha.20260925053522",
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/react-native-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,2 @@
1
+ export { type ChartAnimation, DEFAULT_CHART_ANIMATION } from "./animation.types";
2
+ export { type AnimatedPathOptions, useAnimatedPath } from "./use-animated-path";
@@ -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
+ }