@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
@@ -0,0 +1,70 @@
1
+ import { Path, type SkPath } from "@shopify/react-native-skia";
2
+ import { type ReactElement, useMemo } from "react";
3
+ import type { ChartAnimation } from "../../animation/animation.types";
4
+ import { useAnimatedPath } from "../../animation/use-animated-path";
5
+ import { buildSliceEdgePath } from "../../core/polar/build-slice-path";
6
+ import { toSkPath } from "../../skia/build-path";
7
+ import { usePolarContext } from "../polar-chart.context";
8
+
9
+ export type PieInsetProps = {
10
+ readonly color: string;
11
+ readonly strokeWidth?: number;
12
+ readonly opacity?: number;
13
+ readonly animation?: ChartAnimation;
14
+ };
15
+
16
+ /**
17
+ * A hairline between neighbouring slices.
18
+ *
19
+ * One stroked `M L` per slice's start edge, plus the last slice's end edge
20
+ * when the slices do not close the circle. Drawn in the background colour it
21
+ * reads as a gap; drawn in a foreground colour it reads as a border.
22
+ *
23
+ * Nothing is drawn for fewer than two slices or for a slice that is the whole
24
+ * circle: there is no neighbour to separate, and a single line from the
25
+ * centre to 12 o'clock on a full disc is a scratch, not an inset.
26
+ */
27
+ export function PieInset({ color, strokeWidth = 1, opacity, animation }: PieInsetProps): ReactElement | null {
28
+ const { slices, circleSweepDegrees, animation: chartAnimation } = usePolarContext();
29
+
30
+ const edges = useMemo<SkPath[]>(() => {
31
+ if (slices.length < 2 || slices.some((slice) => slice.sliceIsEntireCircle)) return [];
32
+ const paths = slices.map((slice) => toSkPath(buildSliceEdgePath(slice, "start")));
33
+ const last = slices[slices.length - 1];
34
+ if (last !== undefined && circleSweepDegrees < 360) paths.push(toSkPath(buildSliceEdgePath(last, "end")));
35
+ return paths;
36
+ }, [slices, circleSweepDegrees]);
37
+
38
+ if (edges.length === 0) return null;
39
+
40
+ return (
41
+ <>
42
+ {edges.map((edge, index) => (
43
+ <InsetEdge
44
+ animation={animation ?? chartAnimation}
45
+ color={color}
46
+ key={`${edges.length}-${index}`}
47
+ opacity={opacity}
48
+ path={edge}
49
+ strokeWidth={strokeWidth}
50
+ />
51
+ ))}
52
+ </>
53
+ );
54
+ }
55
+
56
+ PieInset.displayName = "DelacourCharts.PieInset";
57
+
58
+ type InsetEdgeProps = {
59
+ readonly path: SkPath;
60
+ readonly color: string;
61
+ readonly strokeWidth: number;
62
+ readonly opacity: number | undefined;
63
+ readonly animation: ChartAnimation;
64
+ };
65
+
66
+ /** One edge, morphing with its slice. A component so each edge owns a hook. */
67
+ function InsetEdge({ path, color, strokeWidth, opacity, animation }: InsetEdgeProps): ReactElement {
68
+ const animated = useAnimatedPath(path, animation);
69
+ return <Path color={color} opacity={opacity} path={animated} strokeWidth={strokeWidth} style="stroke" />;
70
+ }
@@ -0,0 +1,71 @@
1
+ import { type SkFont, Text } from "@shopify/react-native-skia";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { sliceLabelPosition } from "../../core/polar/label-position";
4
+ import type { PieSliceData } from "../../core/polar/polar.types";
5
+ import { anchorX, anchorY } from "../../core/text/label-anchor";
6
+ import { fontMetrics, measureLabelWidth } from "../../skia/font";
7
+ import { usePolarContext } from "../polar-chart.context";
8
+
9
+ export type PieLabelProps = {
10
+ readonly color: string;
11
+ /** Overrides the chart's font. */
12
+ readonly font?: SkFont | null;
13
+ /** Across the annulus: 0 the inner edge, 1 the outer, more than 1 outside. */
14
+ readonly radiusOffset?: number;
15
+ /** The text for a slice. Defaults to its label. */
16
+ readonly formatLabel?: (slice: PieSliceData) => string;
17
+ /** Slices narrower than this, in degrees, get no label. */
18
+ readonly minSweep?: number;
19
+ readonly opacity?: number;
20
+ /** Skia paint children — a shader for the text. */
21
+ readonly children?: ReactNode;
22
+ };
23
+
24
+ /**
25
+ * A text label on every slice wide enough to hold one.
26
+ *
27
+ * Skia text, for the reason the axes use it: the labels move with the canvas.
28
+ * Draws nothing while the font is `null`, and nothing on a slice narrower than
29
+ * `minSweep` — a label on a two-degree slice lands on its neighbours, and
30
+ * three overlapping labels are less legible than none.
31
+ */
32
+ export function PieLabel({
33
+ color,
34
+ font,
35
+ radiusOffset = 0.5,
36
+ formatLabel,
37
+ minSweep = 0,
38
+ opacity,
39
+ children,
40
+ }: PieLabelProps): ReactElement | null {
41
+ const chart = usePolarContext();
42
+ const face = font === undefined ? chart.font : font;
43
+ if (face === null) return null;
44
+ const metrics = font === undefined ? chart.fontMetrics : fontMetrics(face);
45
+
46
+ return (
47
+ <>
48
+ {chart.slices.map((slice) => {
49
+ if (slice.sweepAngle <= 0 || slice.sweepAngle < minSweep) return null;
50
+ const text = formatLabel === undefined ? slice.label : formatLabel(slice);
51
+ if (text === "") return null;
52
+ const position = sliceLabelPosition(slice, radiusOffset);
53
+ return (
54
+ <Text
55
+ color={color}
56
+ font={face}
57
+ key={slice.index}
58
+ opacity={opacity}
59
+ text={text}
60
+ x={anchorX(position.x, measureLabelWidth(face, text), "middle")}
61
+ y={anchorY(position.y, metrics, "middle")}
62
+ >
63
+ {children}
64
+ </Text>
65
+ );
66
+ })}
67
+ </>
68
+ );
69
+ }
70
+
71
+ PieLabel.displayName = "DelacourCharts.PieLabel";
@@ -0,0 +1,82 @@
1
+ import { Path, type SkPath } from "@shopify/react-native-skia";
2
+ import { type ReactElement, type ReactNode, useMemo } from "react";
3
+ import type { ChartAnimation } from "../../animation/animation.types";
4
+ import { useAnimatedPath } from "../../animation/use-animated-path";
5
+ import { buildSlicePath } from "../../core/polar/build-slice-path";
6
+ import type { PieSliceData } from "../../core/polar/polar.types";
7
+ import { toSkPath } from "../../skia/build-path";
8
+ import { usePolarContext } from "../polar-chart.context";
9
+
10
+ export type PieSliceProps = {
11
+ /** Which slice to draw. Read from the chart's context. */
12
+ readonly index?: number;
13
+ /** The slice to draw, when it is not coming from context. */
14
+ readonly slice?: PieSliceData;
15
+ /** A flat fill. Leave it out when a child shader supplies the paint. */
16
+ readonly color?: string;
17
+ readonly opacity?: number;
18
+ readonly animation?: ChartAnimation;
19
+ /** Skia paint children — a gradient, a shader. */
20
+ readonly children?: ReactNode;
21
+ };
22
+
23
+ /**
24
+ * One filled slice.
25
+ *
26
+ * Takes `index` or `slice`, never needing both, the way a cartesian mark takes
27
+ * `yKey` or `points`. Fill only: a stroke on a slice would outline its arcs as
28
+ * well as its edges, and the hairline between slices is `PieInset`'s job.
29
+ *
30
+ * On mount it grows out of the centre — the entrance path is the same slice
31
+ * at radius zero, which shares its eleven verbs and so interpolates. Without
32
+ * that a pie would simply be there on the first frame, and a pie that morphs
33
+ * on every later change but not on its first looks like the mount is broken.
34
+ */
35
+ export function PieSlice({ index, slice, color, opacity, animation, children }: PieSliceProps): ReactElement | null {
36
+ const chart = usePolarContext();
37
+ const target = slice ?? (index === undefined ? undefined : chart.slices[index]);
38
+
39
+ const path = useMemo(() => (target === undefined ? null : toSkPath(buildSlicePath(target))), [target]);
40
+ const entrance = useMemo(
41
+ () => (target === undefined ? null : toSkPath(buildSlicePath({ ...target, radius: 0, innerRadius: 0 }))),
42
+ [target]
43
+ );
44
+
45
+ if (path === null || entrance === null) return null;
46
+
47
+ return (
48
+ <AnimatedSlice
49
+ animation={animation ?? chart.animation}
50
+ color={color}
51
+ entrance={entrance}
52
+ opacity={opacity}
53
+ path={path}
54
+ >
55
+ {children}
56
+ </AnimatedSlice>
57
+ );
58
+ }
59
+
60
+ PieSlice.displayName = "DelacourCharts.PieSlice";
61
+
62
+ type AnimatedSliceProps = {
63
+ readonly path: SkPath;
64
+ readonly entrance: SkPath;
65
+ readonly animation: ChartAnimation;
66
+ readonly color: string | undefined;
67
+ readonly opacity: number | undefined;
68
+ readonly children: ReactNode;
69
+ };
70
+
71
+ /**
72
+ * Split from `PieSlice` so the hook order holds: a slice with no target
73
+ * returns early, and `useAnimatedPath` cannot sit after an early return.
74
+ */
75
+ function AnimatedSlice({ path, entrance, animation, color, opacity, children }: AnimatedSliceProps): ReactElement {
76
+ const animated = useAnimatedPath(path, animation, { enterFrom: entrance });
77
+ return (
78
+ <Path color={color} opacity={opacity} path={animated} style="fill">
79
+ {children}
80
+ </Path>
81
+ );
82
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { ChartAnimation } from "../../animation/animation.types";
3
+ import type { PieSliceData } from "../../core/polar/polar.types";
4
+ import { sliceOpacity } from "../../core/polar/slice-opacity";
5
+ import { usePolarContext } from "../polar-chart.context";
6
+ import { PieSlice } from "./pie-slice";
7
+
8
+ export type PieSlicesProps = {
9
+ /** One per slice, cycled when there are more slices than colours. */
10
+ readonly colors: readonly string[];
11
+ readonly opacity?: number;
12
+ /** Opacity of every slice but the selected one, while one is selected. */
13
+ readonly dimUnselected?: number;
14
+ readonly animation?: ChartAnimation;
15
+ /** Skia paint children for each slice — a gradient, a shader. */
16
+ readonly children?: (slice: PieSliceData, color: string) => ReactNode;
17
+ };
18
+
19
+ /**
20
+ * Every slice of the chart, one `PieSlice` each.
21
+ *
22
+ * Colours cycle rather than stop: a palette of five under twenty slices
23
+ * colours all twenty, and a slice with no colour would be an invisible wedge
24
+ * that still takes taps.
25
+ *
26
+ * While `selectedIndex` is set, the other slices draw at `dimUnselected`. The
27
+ * default dims them, because a selection that changes nothing on screen looks
28
+ * like a tap that missed. `1` turns it off.
29
+ */
30
+ export function PieSlices({
31
+ colors,
32
+ opacity = 1,
33
+ dimUnselected = 0.4,
34
+ animation,
35
+ children,
36
+ }: PieSlicesProps): ReactElement | null {
37
+ const { slices, selectedIndex } = usePolarContext();
38
+ if (colors.length === 0) return null;
39
+
40
+ return (
41
+ <>
42
+ {slices.map((slice) => {
43
+ const color = colors[slice.index % colors.length] as string;
44
+ return (
45
+ <PieSlice
46
+ animation={animation}
47
+ color={color}
48
+ key={slice.index}
49
+ opacity={sliceOpacity(slice.index, selectedIndex, opacity, dimUnselected)}
50
+ slice={slice}
51
+ >
52
+ {children?.(slice, color)}
53
+ </PieSlice>
54
+ );
55
+ })}
56
+ </>
57
+ );
58
+ }
59
+
60
+ PieSlices.displayName = "DelacourCharts.PieSlices";
@@ -0,0 +1,37 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { PolarContextValue } from "./polar-chart.types";
3
+
4
+ /**
5
+ * The resolved polar chart, published to its children.
6
+ *
7
+ * Rendered **inside** the Skia canvas, for the reason `CartesianChartContext`
8
+ * documents: context resolves by which reconciler renders the provider node,
9
+ * so a provider among the canvas' children is visible to the Skia marks below
10
+ * it and one above the canvas is not.
11
+ */
12
+ export const PolarChartContext = createContext<PolarContextValue | null>(null);
13
+
14
+ PolarChartContext.displayName = "DelacourCharts.PolarChartContext";
15
+
16
+ /**
17
+ * The chart a pie mark is drawn in.
18
+ *
19
+ * Throws outside one rather than returning `null`: a mark that silently draws
20
+ * nothing is a blank canvas with no error, and a Skia tree has no element
21
+ * inspector to look at.
22
+ */
23
+ export function usePolarContext(): PolarContextValue {
24
+ const value = useContext(PolarChartContext);
25
+ if (value === null) {
26
+ throw new Error(
27
+ "[@delacour/react-native-charts] a pie mark was rendered outside a <PolarChart>. " +
28
+ "Marks read the chart from context, so they have to be its children."
29
+ );
30
+ }
31
+ return value;
32
+ }
33
+
34
+ /** The chart, or `null` outside one. For a part that is legitimately optional. */
35
+ export function useOptionalPolarContext(): PolarContextValue | null {
36
+ return useContext(PolarChartContext);
37
+ }
@@ -0,0 +1,91 @@
1
+ import { Canvas } from "@shopify/react-native-skia";
2
+ import { type ReactElement, useMemo, useRef } from "react";
3
+ import { StyleSheet, View } from "react-native";
4
+ import { Gesture, type GestureType } from "react-native-gesture-handler";
5
+ import { useCanvasSize } from "../cartesian/hooks/use-canvas-size";
6
+ import type { ChartRow } from "../core/chart.types";
7
+ import { sliceIndexAt } from "../core/polar/slice-index-at";
8
+ import { ChartGestureOverlay } from "../gesture/gesture-overlay";
9
+ import { usePolarModel } from "./hooks/use-polar-model";
10
+ import { PolarChartContext } from "./polar-chart.context";
11
+ import type { LabelKeyOf, PolarChartProps, PolarContextValue, PolarRenderArgs, ValueKeyOf } from "./polar-chart.types";
12
+
13
+ /**
14
+ * A polar chart: a circle on the canvas, cut into slices, and whatever marks
15
+ * you place.
16
+ *
17
+ * Mounted the way `CartesianChart` is — measuring view, canvas, the provider
18
+ * **inside** the canvas, an overlay for touch — and for the same reasons; see
19
+ * that root. The one difference is the gesture: a pie has no scrub, so the
20
+ * overlay appears only when `onSlicePress` is given, and it carries a tap.
21
+ *
22
+ * The tap runs its callback on the JavaScript thread (`runOnJS(true)`). A
23
+ * scrub reads a shared value every frame of a drag and has to stay on the UI
24
+ * thread; a tap fires once, its result goes straight into React state, and
25
+ * hopping threads to compute an index nobody reads on the UI side would be a
26
+ * hop for nothing.
27
+ */
28
+ export function PolarChart<Row extends ChartRow, const VK extends ValueKeyOf<Row>, const LK extends LabelKeyOf<Row>>(
29
+ props: PolarChartProps<Row, VK, LK>
30
+ ): ReactElement {
31
+ const { data, valueKey, labelKey, children, onSlicePress, ...rest } = props;
32
+
33
+ const { size, onLayout } = useCanvasSize();
34
+
35
+ const model = usePolarModel({
36
+ ...rest,
37
+ data: data as readonly ChartRow[],
38
+ valueKey,
39
+ labelKey,
40
+ canvas: size,
41
+ });
42
+
43
+ const gesture = useSliceTap(model, onSlicePress);
44
+
45
+ const content = typeof children === "function" ? children({ ...model, data } as PolarRenderArgs<Row>) : children;
46
+
47
+ return (
48
+ <View onLayout={onLayout} style={styles.root}>
49
+ <Canvas style={StyleSheet.absoluteFill}>
50
+ <PolarChartContext.Provider value={model}>{content}</PolarChartContext.Provider>
51
+ </Canvas>
52
+ {gesture === null ? null : <ChartGestureOverlay gesture={gesture} />}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ PolarChart.displayName = "DelacourCharts.PolarChart";
58
+
59
+ /**
60
+ * A tap that reports the slice under the finger, or `null` when there is none.
61
+ *
62
+ * The callback is read through a ref so an inline arrow — the usual way to
63
+ * pass it — does not rebuild the gesture on every render, and the gesture is
64
+ * keyed on the model so a data change is picked up by the next tap.
65
+ */
66
+ function useSliceTap(
67
+ model: PolarContextValue,
68
+ onSlicePress: ((index: number | null) => void) | undefined
69
+ ): GestureType | null {
70
+ const handler = useRef(onSlicePress);
71
+ handler.current = onSlicePress;
72
+ const enabled = onSlicePress !== undefined;
73
+
74
+ return useMemo(() => {
75
+ if (!enabled) return null;
76
+ const { center, innerRadius, radius, slices } = model;
77
+ const starts = slices.map((slice) => slice.startAngle);
78
+ const sweeps = slices.map((slice) => slice.sweepAngle);
79
+ return Gesture.Tap()
80
+ .runOnJS(true)
81
+ .onEnd((event, success) => {
82
+ if (!success) return;
83
+ const index = sliceIndexAt(event.x, event.y, center.x, center.y, innerRadius, radius, starts, sweeps);
84
+ handler.current?.(index === -1 ? null : index);
85
+ });
86
+ }, [enabled, model]);
87
+ }
88
+
89
+ const styles = StyleSheet.create({
90
+ root: { flex: 1, overflow: "hidden" },
91
+ });
@@ -0,0 +1,82 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import type { ReactNode } from "react";
3
+ import type { ChartAnimation } from "../animation/animation.types";
4
+ import type { ChartRow, ChartSize } from "../core/chart.types";
5
+ import type { InnerRadius, PieSliceData, PolarPoint } from "../core/polar/polar.types";
6
+ import type { LabelMetrics } from "../core/text/label-anchor";
7
+ import type { SidedNumber } from "../core/util/sided-number";
8
+
9
+ /**
10
+ * Fields that can size a slice — a number, or nothing.
11
+ *
12
+ * The `string extends keyof Row` guard widens back to `string` for a row typed
13
+ * only as an index signature; see `XKeyOf` in the cartesian types for why a
14
+ * caller who has not typed their rows would otherwise be unable to name a key.
15
+ */
16
+ export type ValueKeyOf<Row extends ChartRow> = string extends keyof Row
17
+ ? string
18
+ : {
19
+ [Key in keyof Row]-?: Row[Key] extends number | null | undefined ? Key : never;
20
+ }[keyof Row] &
21
+ string;
22
+
23
+ /** Fields that can name a slice. Widens the same way. */
24
+ export type LabelKeyOf<Row extends ChartRow> = string extends keyof Row
25
+ ? string
26
+ : {
27
+ [Key in keyof Row]-?: Row[Key] extends string | number | null | undefined ? Key : never;
28
+ }[keyof Row] &
29
+ string;
30
+
31
+ /** Everything a mark or a render prop can read about the chart. */
32
+ export type PolarContextValue = {
33
+ /** One per row, in data order, zero-sweep slices included. */
34
+ readonly slices: readonly PieSliceData[];
35
+ readonly center: PolarPoint;
36
+ readonly radius: number;
37
+ readonly innerRadius: number;
38
+ readonly startAngle: number;
39
+ readonly circleSweepDegrees: number;
40
+ /** The whole canvas, including the padding. */
41
+ readonly canvas: ChartSize;
42
+ /** `null` until the font resolves; a label mark then draws nothing. */
43
+ readonly font: SkFont | null;
44
+ readonly lineHeight: number;
45
+ /** The font's ascent and descent, for placing a label by its glyph box. */
46
+ readonly fontMetrics: LabelMetrics;
47
+ readonly animation: ChartAnimation;
48
+ /** The slice the caller has singled out, or `null`. */
49
+ readonly selectedIndex: number | null;
50
+ /** Whether there is room to draw in. */
51
+ readonly ready: boolean;
52
+ };
53
+
54
+ export type PolarRenderArgs<Row extends ChartRow> = PolarContextValue & {
55
+ readonly data: readonly Row[];
56
+ };
57
+
58
+ export type PolarChartProps<Row extends ChartRow, VK extends ValueKeyOf<Row>, LK extends LabelKeyOf<Row>> = {
59
+ readonly data: readonly Row[];
60
+ /** The field a slice's size comes from. */
61
+ readonly valueKey: VK;
62
+ /** The field a slice's label comes from. Without it, the row's index. */
63
+ readonly labelKey?: LK;
64
+ /** The donut hole — points, or a percentage of the radius. */
65
+ readonly innerRadius?: InnerRadius;
66
+ /** Where the first slice begins. Degrees clockwise from 12 o'clock. */
67
+ readonly startAngle?: number;
68
+ /** How much of the circle the slices fill. 180 is a half-pie gauge. */
69
+ readonly circleSweepDegrees?: number;
70
+ /** An explicit diameter. Otherwise the largest circle the canvas holds. */
71
+ readonly size?: number;
72
+ /** Space between the canvas edge and the circle. */
73
+ readonly padding?: SidedNumber;
74
+ readonly animation?: ChartAnimation;
75
+ readonly font?: SkFont | null;
76
+ /** The slice to single out. Marks read it; the root does nothing with it. */
77
+ readonly selectedIndex?: number | null;
78
+ /** Called with the tapped slice's index, or `null` for a tap outside every slice. */
79
+ readonly onSlicePress?: (index: number | null) => void;
80
+ /** Skia children, or a function of the resolved chart. */
81
+ readonly children?: ReactNode | ((args: PolarRenderArgs<Row>) => ReactNode);
82
+ };
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { read, relative, SRC, sourceFiles } from "./source-tree.test";
4
+
5
+ /**
6
+ * Modules `src/core` may not import a runtime value from.
7
+ *
8
+ * React Native ships Flow-typed source that Bun's transpiler cannot parse, so
9
+ * one of these anywhere under `core/` takes the *entire* suite down — and the
10
+ * error names whichever file happened to import the offender first, not the
11
+ * file that broke the rule. Hence this test: it fails first, and it fails by
12
+ * name.
13
+ *
14
+ * Type-only imports are fine. Types are erased before Bun sees them.
15
+ */
16
+ const FORBIDDEN = [
17
+ "react",
18
+ "react-native",
19
+ "react-dom",
20
+ "@shopify/react-native-skia",
21
+ "react-native-reanimated",
22
+ "react-native-gesture-handler",
23
+ "react-native-worklets",
24
+ ];
25
+
26
+ const IMPORT = /^\s*import\s+([\s\S]*?)\s*from\s*["']([^"']+)["']/gm;
27
+
28
+ /** Whether an import clause brings in nothing but types. */
29
+ function isTypeOnly(clause: string): boolean {
30
+ const trimmed = clause.trim();
31
+ if (trimmed.startsWith("type ")) return true;
32
+
33
+ const named = trimmed.match(/^\{([\s\S]*)\}$/);
34
+ if (!named) return false;
35
+
36
+ return (named[1] as string)
37
+ .split(",")
38
+ .map((specifier) => specifier.trim())
39
+ .filter((specifier) => specifier !== "")
40
+ .every((specifier) => specifier.startsWith("type "));
41
+ }
42
+
43
+ describe("core purity", () => {
44
+ const files = sourceFiles(join(SRC, "core"));
45
+
46
+ test("finds the core modules, so a broken walker cannot pass silently", () => {
47
+ expect(files.length).toBeGreaterThan(20);
48
+ });
49
+
50
+ test("no module under src/core imports a React Native runtime value", () => {
51
+ const offenders: string[] = [];
52
+
53
+ for (const file of files) {
54
+ const source = read(file);
55
+ IMPORT.lastIndex = 0;
56
+ for (let match = IMPORT.exec(source); match !== null; match = IMPORT.exec(source)) {
57
+ const clause = match[1] as string;
58
+ const specifier = match[2] as string;
59
+ const root = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
60
+ if (!FORBIDDEN.includes(root as string)) continue;
61
+ if (isTypeOnly(clause)) continue;
62
+ offenders.push(`${relative(file)} imports ${specifier}`);
63
+ }
64
+ }
65
+
66
+ expect(offenders).toEqual([]);
67
+ });
68
+
69
+ test("no module under src/core reaches sideways into src/skia", () => {
70
+ const offenders = files
71
+ .filter((file) => /from\s+["'][^"']*\/skia\//.test(read(file)))
72
+ .map((file) => relative(file));
73
+ expect(offenders).toEqual([]);
74
+ });
75
+ });
@@ -0,0 +1,39 @@
1
+ # skia
2
+
3
+ The only folder that imports Skia values. Four small translators.
4
+
5
+ Everything below `core/` computes numbers; this turns them into things Skia can
6
+ draw. Keeping the surface this narrow is deliberate — Skia 2.x renamed and
7
+ restructured a good deal of the path API, so every future migration is these
8
+ files and nothing else.
9
+
10
+ ## Files
11
+
12
+ | Path | What |
13
+ | --- | --- |
14
+ | `build-path.ts` | An SVG path string from `core/curve` into an `SkPath` |
15
+ | `font.ts` | A system font, label advance widths, and line height |
16
+
17
+ ## Design
18
+
19
+ - **A path Skia cannot parse becomes an empty path, not `null`.** Every caller
20
+ renders the result, and an empty path draws nothing — which is a chart with a
21
+ missing series rather than a crash.
22
+
23
+ - **No font ships with this package.** `matchFont` resolves against the
24
+ operating system's own families, so a chart is legible with no `expo-font`,
25
+ no config plugin and no Metro asset resolver in the consuming app. The trade
26
+ is that iOS' system font is not Android's, so the same labels measure
27
+ differently and the plot rect differs by a few points across platforms. That
28
+ is correct, but it does mean a screenshot baseline has to be per-platform.
29
+
30
+ - **Label width is the sum of glyph advances, not `measureText`.**
31
+ `measureText` returns the *drawn* bounds, which exclude side bearings, so a
32
+ tick reading "10" measures narrower than the space it occupies and every
33
+ axis gutter comes out a point or two too tight. `getTextWidth` gives the
34
+ advance directly but is deprecated in Skia 2.x, so the advances are summed
35
+ from `getGlyphWidths`.
36
+
37
+ - **Line height comes from the font's metrics, not its point size.** An axis
38
+ gutter has to reserve the full glyph box — ascent plus descent — and the
39
+ nominal size is neither.
@@ -0,0 +1,22 @@
1
+ import { Skia, type SkPath } from "@shopify/react-native-skia";
2
+
3
+ /** A path with nothing in it — what an empty series draws. */
4
+ export function emptyPath(): SkPath {
5
+ return Skia.Path.Make();
6
+ }
7
+
8
+ /**
9
+ * An SVG path string as a Skia path.
10
+ *
11
+ * The string comes from `d3-shape` in `core/curve`, which is the one place
12
+ * this package pays for taking d3: the geometry is serialised to text and
13
+ * parsed straight back. It happens once per data change, not per frame.
14
+ *
15
+ * A string Skia cannot parse yields an empty path rather than `null`. Every
16
+ * caller renders it, and an empty path draws nothing — which is a chart with a
17
+ * missing series, not a crash.
18
+ */
19
+ export function toSkPath(svg: string): SkPath {
20
+ if (svg === "") return Skia.Path.Make();
21
+ return Skia.Path.MakeFromSVGString(svg) ?? Skia.Path.Make();
22
+ }