@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,148 @@
1
+ import { Path, type SkPath } from "@shopify/react-native-skia";
2
+ import { type ReactElement, useEffect, useMemo } from "react";
3
+ import type { ChartAnimation } from "../../animation/animation.types";
4
+ import { useAnimatedPath } from "../../animation/use-animated-path";
5
+ import { resolveBand } from "../../core/geometry/band";
6
+ import { buildCandlePaths, candlePoints, SENTIMENTS, type Sentiment } from "../../core/shape/build-candles";
7
+ import { toSkPath } from "../../skia/build-path";
8
+ import { useChartContext } from "../cartesian-chart.context";
9
+ import { categorySpan } from "./chart-bar";
10
+
11
+ export type ChartCandlestickKeys = {
12
+ readonly open: string;
13
+ readonly high: string;
14
+ readonly low: string;
15
+ readonly close: string;
16
+ };
17
+
18
+ export type ChartCandleColors = Readonly<Record<Sentiment, string>>;
19
+
20
+ export type ChartCandlestickProps = {
21
+ /** The four series. Each must be one of the chart's `yKeys`. */
22
+ readonly keys: ChartCandlestickKeys;
23
+ readonly candleColors: ChartCandleColors;
24
+ /** The fraction of each step a body fills. Defaults to 0.6. */
25
+ readonly candleRatio?: number;
26
+ /** An exact body width, instead of a fraction of the step. */
27
+ readonly candleWidth?: number;
28
+ /** Size candles as if the plot held this many, so charts of different lengths match. */
29
+ readonly candleCount?: number;
30
+ /** A body thinner than this is expanded so a flat candle stays visible. Defaults to 1. */
31
+ readonly minBodyHeight?: number;
32
+ readonly wickStrokeWidth?: number;
33
+ readonly opacity?: number;
34
+ readonly animation?: ChartAnimation;
35
+ };
36
+
37
+ /**
38
+ * Open-high-low-close candles.
39
+ *
40
+ * Six `<Path>` nodes: bodies and wicks for each of the three sentiments, and
41
+ * every candle is present in all six, degenerate where its sentiment does not
42
+ * apply. A candle that flips therefore morphs from one colour into the other
43
+ * rather than snapping — see `buildCandlePaths`. Wicks draw first so a body
44
+ * covers the wick that passes through it, and with **butt** caps, because a
45
+ * round cap on a zero-length wick is a dot at every non-matching candle.
46
+ */
47
+ export function ChartCandlestick({
48
+ keys,
49
+ candleColors,
50
+ candleRatio = 0.6,
51
+ candleWidth,
52
+ candleCount,
53
+ minBodyHeight,
54
+ wickStrokeWidth = 1,
55
+ opacity,
56
+ animation,
57
+ }: ChartCandlestickProps): ReactElement {
58
+ const chart = useChartContext();
59
+ const { bounds, xPositions, points, orientation } = chart;
60
+
61
+ useEffect(() => {
62
+ if (!__DEV__) return;
63
+ for (const key of [keys.open, keys.high, keys.low, keys.close]) {
64
+ if (points[key] === undefined) {
65
+ console.warn(
66
+ `[@delacour/react-native-charts] <ChartCandlestick> was given the key "${key}" but the chart has no series ` +
67
+ "by that name. Every candle key has to be one of the chart's yKeys."
68
+ );
69
+ }
70
+ }
71
+ }, [keys, points]);
72
+
73
+ const paths = useMemo(() => {
74
+ const band = resolveBand({
75
+ xPositions,
76
+ plotWidth: categorySpan(bounds, orientation),
77
+ innerPadding: 1 - candleRatio,
78
+ barWidth: candleWidth,
79
+ barCount: candleCount,
80
+ });
81
+ const candles = candlePoints(
82
+ points[keys.open] ?? [],
83
+ points[keys.high] ?? [],
84
+ points[keys.low] ?? [],
85
+ points[keys.close] ?? [],
86
+ orientation
87
+ );
88
+ const built = buildCandlePaths(candles, {
89
+ bandwidth: band.bandwidth,
90
+ minBodyHeight,
91
+ baseline: orientation === "horizontal" ? bounds.left : bounds.bottom,
92
+ orientation,
93
+ });
94
+ const toSk = (sentiment: Sentiment): { bodies: SkPath; wicks: SkPath } => ({
95
+ bodies: toSkPath(built[sentiment].bodies),
96
+ wicks: toSkPath(built[sentiment].wicks),
97
+ });
98
+ return { positive: toSk("positive"), negative: toSk("negative"), neutral: toSk("neutral") };
99
+ }, [xPositions, bounds, points, keys, orientation, candleRatio, candleWidth, candleCount, minBodyHeight]);
100
+
101
+ const motion = animation ?? chart.animation;
102
+
103
+ return (
104
+ <>
105
+ {SENTIMENTS.map((sentiment) => (
106
+ <CandlePath
107
+ animation={motion}
108
+ color={candleColors[sentiment]}
109
+ key={`${sentiment}-wicks`}
110
+ opacity={opacity}
111
+ path={paths[sentiment].wicks}
112
+ strokeWidth={wickStrokeWidth}
113
+ />
114
+ ))}
115
+ {SENTIMENTS.map((sentiment) => (
116
+ <CandlePath
117
+ animation={motion}
118
+ color={candleColors[sentiment]}
119
+ key={`${sentiment}-bodies`}
120
+ opacity={opacity}
121
+ path={paths[sentiment].bodies}
122
+ />
123
+ ))}
124
+ </>
125
+ );
126
+ }
127
+
128
+ ChartCandlestick.displayName = "DelacourCharts.ChartCandlestick";
129
+
130
+ type CandlePathProps = {
131
+ readonly path: SkPath;
132
+ readonly color: string;
133
+ readonly opacity?: number;
134
+ readonly animation: ChartAnimation;
135
+ /** Set for a wick path; a body is filled. */
136
+ readonly strokeWidth?: number;
137
+ };
138
+
139
+ /** One of the six paths. A component of its own so each path owns its animation hook. */
140
+ function CandlePath({ path, color, opacity, animation, strokeWidth }: CandlePathProps): ReactElement {
141
+ const animated = useAnimatedPath(path, animation);
142
+ if (strokeWidth === undefined) return <Path color={color} opacity={opacity} path={animated} style="fill" />;
143
+ return (
144
+ <Path color={color} opacity={opacity} path={animated} strokeCap="butt" strokeWidth={strokeWidth} style="stroke" />
145
+ );
146
+ }
147
+
148
+ CandlePath.displayName = "DelacourCharts.ChartCandlestick.Path";
@@ -0,0 +1,168 @@
1
+ import { Circle, DashPathEffect, Line } from "@shopify/react-native-skia";
2
+ import type { ReactElement } from "react";
3
+ import { useDerivedValue } from "react-native-reanimated";
4
+ import type { ChartBounds } from "../../core/chart.types";
5
+ import type { ChartScrubSeriesState, ChartScrubState } from "../../gesture/gesture.types";
6
+ import { useChartContext } from "../cartesian-chart.context";
7
+
8
+ /** Parked off-canvas, so a mark with nothing to point at is not at the origin. */
9
+ const OFFSCREEN = -10_000;
10
+
11
+ export type ChartCursorDotProps = {
12
+ /** Which series the dot rides. */
13
+ readonly yKey: string;
14
+ readonly color: string;
15
+ readonly radius?: number;
16
+ /**
17
+ * A ring around the dot, usually the chart's own background.
18
+ *
19
+ * Without it a dot in the series colour sitting on a line of the same colour
20
+ * is a slight thickening and nothing more.
21
+ */
22
+ readonly borderColor?: string;
23
+ readonly borderWidth?: number;
24
+ /**
25
+ * Sit on the nearest datum rather than gliding along the curve.
26
+ *
27
+ * On by default, because the readout beside it names a datum. Turn it off
28
+ * for a dot that tracks the finger continuously, and expect it to disagree
29
+ * with any label showing a measured value.
30
+ */
31
+ readonly snap?: boolean;
32
+ readonly opacity?: number;
33
+ };
34
+
35
+ /**
36
+ * A dot marking where a scrub has reached, on one series.
37
+ *
38
+ * The outer component reads the chart and bails when there is no scrub to
39
+ * follow; the inner one is where the hooks live, so a chart with no scrub
40
+ * never runs them. Splitting it is what keeps the hook order fixed — a
41
+ * component that called `useDerivedValue` after a conditional return would
42
+ * break the moment a chart gained or lost its scrub.
43
+ *
44
+ * It reads the series' own `snappedX`/`snappedY`, which the scrub fills in
45
+ * for either orientation, so the dot lands on the bar's end on a horizontal
46
+ * chart without this component knowing there is such a thing.
47
+ */
48
+ export function ChartCursorDot(props: ChartCursorDotProps): ReactElement | null {
49
+ const { scrub } = useChartContext();
50
+ const series = scrub?.series[props.yKey];
51
+ if (!scrub || !series) return null;
52
+ return <CursorDot {...props} scrub={scrub} series={series} />;
53
+ }
54
+
55
+ ChartCursorDot.displayName = "DelacourCharts.ChartCursorDot";
56
+
57
+ function CursorDot({
58
+ color,
59
+ radius = 4,
60
+ borderColor,
61
+ borderWidth = 2,
62
+ snap = true,
63
+ opacity,
64
+ scrub,
65
+ series,
66
+ }: ChartCursorDotProps & {
67
+ readonly scrub: ChartScrubState;
68
+ readonly series: ChartScrubSeriesState;
69
+ }): ReactElement {
70
+ const cx = useDerivedValue(() => {
71
+ const value = snap ? series.snappedX.value : series.x.value;
72
+ return Number.isFinite(value) ? value : OFFSCREEN;
73
+ });
74
+
75
+ const cy = useDerivedValue(() => {
76
+ const value = snap ? series.snappedY.value : series.y.value;
77
+ return Number.isFinite(value) ? value : OFFSCREEN;
78
+ });
79
+
80
+ const alpha = useDerivedValue(() => (scrub.isActive.value ? (opacity ?? 1) : 0));
81
+
82
+ return (
83
+ <>
84
+ {borderColor === undefined ? null : (
85
+ <Circle color={borderColor} cx={cx} cy={cy} opacity={alpha} r={radius + borderWidth} />
86
+ )}
87
+ <Circle color={color} cx={cx} cy={cy} opacity={alpha} r={radius} />
88
+ </>
89
+ );
90
+ }
91
+
92
+ CursorDot.displayName = "DelacourCharts.ChartCursorDot.Inner";
93
+
94
+ export type ChartCursorLineProps = {
95
+ /**
96
+ * `x` is a vertical rule; `y` is a horizontal one. Named for the axis the
97
+ * line reads against, not for the direction it is drawn in.
98
+ *
99
+ * Without a `yKey` the rule sits at the touched category — under the
100
+ * finger on a vertical chart for `x`, beside it on a horizontal chart for
101
+ * `y`. With one, it sits at that series' nearest datum on that axis.
102
+ */
103
+ readonly axis: "x" | "y";
104
+ /** Which series the rule sits at. Without it the rule follows the category. */
105
+ readonly yKey?: string;
106
+ readonly color: string;
107
+ readonly width?: number;
108
+ /** Dash on/off lengths. A dashed rule reads as a guide beside solid gridlines. */
109
+ readonly dash?: readonly [number, number];
110
+ readonly snap?: boolean;
111
+ readonly opacity?: number;
112
+ };
113
+
114
+ /** A crosshair rule spanning the plot at the scrubbed position. */
115
+ export function ChartCursorLine(props: ChartCursorLineProps): ReactElement | null {
116
+ const { scrub, bounds } = useChartContext();
117
+ const series = props.yKey === undefined ? undefined : scrub?.series[props.yKey];
118
+ if (!scrub) return null;
119
+ if (props.yKey !== undefined && !series) return null;
120
+ return <CursorLine {...props} bounds={bounds} scrub={scrub} series={series} />;
121
+ }
122
+
123
+ ChartCursorLine.displayName = "DelacourCharts.ChartCursorLine";
124
+
125
+ function CursorLine({
126
+ axis,
127
+ color,
128
+ width = 1,
129
+ dash,
130
+ snap = true,
131
+ opacity,
132
+ bounds,
133
+ scrub,
134
+ series,
135
+ }: ChartCursorLineProps & {
136
+ readonly bounds: ChartBounds;
137
+ readonly scrub: ChartScrubState;
138
+ readonly series: ChartScrubSeriesState | undefined;
139
+ }): ReactElement {
140
+ // Resolved inline rather than through a component-scope worklet helper.
141
+ // The rule used to call an `at()` worklet from both points' derived values,
142
+ // and on device it never drew — in this chart and every themed tooltip —
143
+ // while the dots, which read their shared values directly, did.
144
+ const position = useDerivedValue(() => {
145
+ const source = series ?? scrub;
146
+ const value =
147
+ axis === "x" ? (snap ? source.snappedX.value : source.x.value) : snap ? source.snappedY.value : source.y.value;
148
+ return Number.isFinite(value) ? value : OFFSCREEN;
149
+ });
150
+
151
+ const p1 = useDerivedValue(() =>
152
+ axis === "x" ? { x: position.value, y: bounds.top } : { x: bounds.left, y: position.value }
153
+ );
154
+
155
+ const p2 = useDerivedValue(() =>
156
+ axis === "x" ? { x: position.value, y: bounds.bottom } : { x: bounds.right, y: position.value }
157
+ );
158
+
159
+ const alpha = useDerivedValue(() => (scrub.isActive.value ? (opacity ?? 1) : 0));
160
+
161
+ return (
162
+ <Line color={color} opacity={alpha} p1={p1} p2={p2} strokeWidth={width} style="stroke">
163
+ {dash === undefined ? null : <DashPathEffect intervals={[dash[0], dash[1]]} />}
164
+ </Line>
165
+ );
166
+ }
167
+
168
+ CursorLine.displayName = "DelacourCharts.ChartCursorLine.Inner";
@@ -0,0 +1,46 @@
1
+ import { Path, Skia } from "@shopify/react-native-skia";
2
+ import { type ReactElement, useMemo } from "react";
3
+ import { type GridAxis, gridSegments } from "../../core/geometry/grid-lines";
4
+ import { useChartContext } from "../cartesian-chart.context";
5
+
6
+ export type ChartGridProps = {
7
+ /** Which ticks get a rule. Defaults to the y ticks alone. */
8
+ readonly axis?: GridAxis;
9
+ readonly color: string;
10
+ readonly lineWidth?: number;
11
+ readonly opacity?: number;
12
+ /** Dash on/off lengths, in points. */
13
+ readonly dash?: readonly [number, number];
14
+ };
15
+
16
+ /**
17
+ * A hairline rule at each tick.
18
+ *
19
+ * Every rule is one path rather than one `<Path>` per line: a chart with two
20
+ * axes and eight ticks each is sixteen Skia nodes for something that is a
21
+ * single stroke, and the node count is what a Skia tree costs.
22
+ *
23
+ * `y` alone by default. Horizontal rules let a reader carry a value across to
24
+ * the axis, which is what a gridline is for; vertical ones mostly repeat what
25
+ * the marks already show.
26
+ */
27
+ export function ChartGrid({ axis = "y", color, lineWidth = 1, opacity, dash }: ChartGridProps): ReactElement {
28
+ const { xTicks, yTicks, bounds } = useChartContext();
29
+
30
+ const path = useMemo(() => {
31
+ // `Skia.PathBuilder`, not `Skia.Path.Make()` — the mutating methods on
32
+ // `SkPath` are deprecated in Skia 2.x and warn on every construction.
33
+ const builder = Skia.PathBuilder.Make();
34
+ for (const [x1, y1, x2, y2] of gridSegments({ bounds, xTicks, yTicks, axis })) {
35
+ builder.moveTo(x1, y1);
36
+ builder.lineTo(x2, y2);
37
+ }
38
+ const built = builder.build();
39
+ if (dash === undefined) return built;
40
+ return Skia.Path.Dash(built, dash[0], dash[1], 0) ?? built;
41
+ }, [axis, xTicks, yTicks, bounds, dash]);
42
+
43
+ return <Path color={color} opacity={opacity} path={path} strokeWidth={lineWidth} style="stroke" />;
44
+ }
45
+
46
+ ChartGrid.displayName = "DelacourCharts.ChartGrid";
@@ -0,0 +1,73 @@
1
+ import { Path } 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 type { ChartPoint } from "../../core/chart.types";
6
+ import { buildLinePath } from "../../core/curve/build-line";
7
+ import type { CurveType } from "../../core/curve/curves";
8
+ import { toSkPath } from "../../skia/build-path";
9
+ import { useChartContext } from "../cartesian-chart.context";
10
+
11
+ export type ChartLineProps = {
12
+ /** Which series to draw. Read from the chart's context. */
13
+ readonly yKey?: string;
14
+ /** The points to draw, when they are not coming from context. */
15
+ readonly points?: readonly ChartPoint[];
16
+ readonly color: string;
17
+ readonly strokeWidth?: number;
18
+ readonly curve?: CurveType;
19
+ readonly connectMissingData?: boolean;
20
+ readonly opacity?: number;
21
+ readonly animation?: ChartAnimation;
22
+ readonly strokeCap?: "butt" | "round" | "square";
23
+ readonly strokeJoin?: "bevel" | "miter" | "round";
24
+ };
25
+
26
+ /**
27
+ * A stroked line through a series.
28
+ *
29
+ * Takes `yKey` or `points`, never needing both: given a key it reads the
30
+ * chart's context, given points it draws exactly those. One implementation
31
+ * serves the declarative call site and the render-prop one, so the two can
32
+ * never drift.
33
+ *
34
+ * The default `round` cap and join are not cosmetic. A `butt` cap on a
35
+ * two-point series leaves the stroke visibly short of its own endpoint, and a
36
+ * `miter` join spikes at a sharp reversal in volatile data.
37
+ */
38
+ export function ChartLine({
39
+ yKey,
40
+ points,
41
+ color,
42
+ strokeWidth = 2,
43
+ curve,
44
+ connectMissingData,
45
+ opacity,
46
+ animation,
47
+ strokeCap = "round",
48
+ strokeJoin = "round",
49
+ }: ChartLineProps): ReactElement {
50
+ const chart = useChartContext();
51
+ const series = points ?? (yKey === undefined ? [] : (chart.points[yKey] ?? []));
52
+
53
+ const path = useMemo(
54
+ () => toSkPath(buildLinePath(series, { curve: curve ?? chart.curve, connectMissingData })),
55
+ [series, curve, chart.curve, connectMissingData]
56
+ );
57
+
58
+ const animated = useAnimatedPath(path, animation ?? chart.animation);
59
+
60
+ return (
61
+ <Path
62
+ color={color}
63
+ opacity={opacity}
64
+ path={animated}
65
+ strokeCap={strokeCap}
66
+ strokeJoin={strokeJoin}
67
+ strokeWidth={strokeWidth}
68
+ style="stroke"
69
+ />
70
+ );
71
+ }
72
+
73
+ ChartLine.displayName = "DelacourCharts.ChartLine";
@@ -0,0 +1,71 @@
1
+ import { Path } 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 type { ChartPoint } from "../../core/chart.types";
6
+ import { resolveBaseline } from "../../core/shape/build-bars";
7
+ import { buildScatterPath, type ScatterShape } from "../../core/shape/build-scatter";
8
+ import { toSkPath } from "../../skia/build-path";
9
+ import { useChartContext } from "../cartesian-chart.context";
10
+
11
+ export type ChartScatterProps = {
12
+ /** Which series to draw. Read from the chart's context. */
13
+ readonly yKey?: string;
14
+ /** The points to draw, when they are not coming from context. */
15
+ readonly points?: readonly ChartPoint[];
16
+ readonly color: string;
17
+ /** One radius for every point, or one per point. Defaults to 4. */
18
+ readonly radius?: number | ((point: ChartPoint, index: number) => number);
19
+ readonly shape?: ScatterShape;
20
+ /** Filled discs, or rings. */
21
+ readonly style?: "fill" | "stroke";
22
+ readonly strokeWidth?: number;
23
+ readonly opacity?: number;
24
+ readonly animation?: ChartAnimation;
25
+ };
26
+
27
+ /**
28
+ * One shape per datum.
29
+ *
30
+ * All the shapes are one `<Path>`, so a hundred points is one Skia node. A
31
+ * gap is the same shape at radius zero on the baseline, which is what keeps
32
+ * the series interpolatable when a datum comes and goes.
33
+ */
34
+ export function ChartScatter({
35
+ yKey,
36
+ points,
37
+ color,
38
+ radius = 4,
39
+ shape = "circle",
40
+ style = "fill",
41
+ strokeWidth = 1,
42
+ opacity,
43
+ animation,
44
+ }: ChartScatterProps): ReactElement {
45
+ const chart = useChartContext();
46
+ const series = points ?? (yKey === undefined ? [] : (chart.points[yKey] ?? []));
47
+ const { orientation } = chart;
48
+ const baseline = resolveBaseline(
49
+ orientation === "horizontal" ? chart.xScale : chart.yScale,
50
+ chart.bounds,
51
+ orientation
52
+ );
53
+
54
+ const path = useMemo(
55
+ () => toSkPath(buildScatterPath(series, { radius, shape, baseline, orientation })),
56
+ [series, radius, shape, baseline, orientation]
57
+ );
58
+ const animated = useAnimatedPath(path, animation ?? chart.animation);
59
+
60
+ return (
61
+ <Path
62
+ color={color}
63
+ opacity={opacity}
64
+ path={animated}
65
+ strokeWidth={style === "stroke" ? strokeWidth : undefined}
66
+ style={style}
67
+ />
68
+ );
69
+ }
70
+
71
+ ChartScatter.displayName = "DelacourCharts.ChartScatter";
@@ -0,0 +1,77 @@
1
+ # core
2
+
3
+ Every number this package computes, and nothing that draws.
4
+
5
+ ## The rule
6
+
7
+ **No runtime import of `react`, `react-native`, `@shopify/react-native-skia`,
8
+ `react-native-reanimated`, `react-native-gesture-handler` or
9
+ `react-native-worklets`.** Type-only imports are fine. `d3-scale` and
10
+ `d3-shape` are fine — they are pure ESM JavaScript that Bun can parse.
11
+
12
+ `src/purity.test.ts` enforces it. See the package `AGENTS.md` for why the
13
+ failure mode makes a test necessary rather than a convention sufficient.
14
+
15
+ ## Files
16
+
17
+ | Path | What |
18
+ | --- | --- |
19
+ | `chart.types.ts` | `ChartPoint`, `ChartSegment`, `ChartBounds`, `ChartSize`, `ChartRow`, `ChartOrientation` |
20
+ | `scale/scale.types.ts` | `ScaleDescriptor` — a scale as plain numbers |
21
+ | `scale/make-scale.ts` | d3 builds and nices; only the descriptor comes back |
22
+ | `scale/scale.ts` | `scaleValue` / `invertValue` — the two flat worklets |
23
+ | `ticks/linear-ticks.ts` | Linear and log tick values, with the log thinning d3 does not do |
24
+ | `ticks/time-ticks.ts` | Calendar-correct tick values, via `scaleTime` |
25
+ | `ticks/tick-count.ts` | Downsampling, and the shared count that aligns two axes' gridlines |
26
+ | `ticks/build-ticks.ts` | Values plus positions, through the same scale the marks use |
27
+ | `curve/curves.ts` | The d3-shape interpolators, and which of them approximate |
28
+ | `curve/build-line.ts` | Points to an SVG path string |
29
+ | `curve/build-area.ts` | The same, closed against a baseline or a lower edge |
30
+ | `geometry/domain.ts` | Extent, explicit overrides, padding by fraction and by step, zero |
31
+ | `geometry/step.ts` | The smallest gap between x values — what a bar's width and the x padding are stated in |
32
+ | `geometry/band.ts` | A datum's band on a scale with no band of its own, and where a group's bars sit inside it |
33
+ | `geometry/stack.ts` | Series stacked in data space, then scaled; the totals a stacked y domain needs |
34
+ | `geometry/category-domain.ts` | Whether the category domain holds the outermost bars — what `ChartBar` warns about |
35
+ | `geometry/chart-bounds.ts` | Canvas minus padding minus gutters |
36
+ | `geometry/chart-layout.ts` | The two layout passes, and the category/value roles onto the canvas axes |
37
+ | `geometry/x-values.ts` | The numeric-or-index decision, made once for the whole series |
38
+ | `geometry/transform-input-data.ts` | Rows to plotted points, on whichever axes the orientation says |
39
+ | `interaction/path-segments.ts` | SVG path to flat cubic runs, for the UI thread |
40
+ | `interaction/closest-index.ts` | Nearest datum, by binary search |
41
+ | `interaction/y-for-x.ts` | `y` on the drawn curve, by bisection |
42
+ | `animation/morph-strategy.ts` | Which end to pad, and when to resample instead |
43
+ | `animation/resample-points.ts` | Two series brought to one length |
44
+ | `text/axis-gutters.ts` | Measured label widths to the space an axis needs |
45
+ | `text/label-anchor.ts` | Baseline-anchored text placed against a point |
46
+ | `text/bar-label.ts` | A label against a bar's value end, base end or side; `top` follows the value end either way round |
47
+ | `util/read-at.ts` | A list read that yields `NaN` for a gap or a missing index — the scrub's flat worklet |
48
+ | `shape/rect-path.ts` | A rectangle as `M C L C L C L C Z`, every corner a cubic |
49
+ | `shape/build-bars.ts` | Points or segments to bar rects and one path, the baseline held inside the plot |
50
+ | `shape/build-scatter.ts` | A circle, square or star per point, each on a fixed verb sequence |
51
+ | `shape/build-candles.ts` | Candles zipped from four series; six paths, every candle in each |
52
+ | `polar/polar.types.ts` | `PieSliceData`, `PolarLayout`, `InnerRadius` — a slice as plain numbers |
53
+ | `polar/polar-point.ts` | Degrees clockwise from 12 o'clock to a canvas point, done once |
54
+ | `polar/pie-input.ts` | Rows to values and labels, nothing dropped |
55
+ | `polar/resolve-layout.ts` | The largest circle the padded canvas holds, and the hole inside it |
56
+ | `polar/resolve-slices.ts` | Values to angles; sweeps sum to the circle exactly |
57
+ | `polar/build-slice-path.ts` | A slice as eleven verbs, whatever its sweep |
58
+ | `polar/label-position.ts` | A label on the bisector, offset across the annulus |
59
+ | `polar/slice-index-at.ts` | Which slice is under a point — the sixth flat worklet |
60
+ | `polar/slice-opacity.ts` | What one slice draws at while another is selected |
61
+
62
+ ## Design
63
+
64
+ - **Pure means testable, and testable is the point.** `bun test` cannot render
65
+ a React Native component, so anything worth asserting has to live here. When
66
+ a decision looks like it belongs in a `.tsx` because it is only used once —
67
+ it is only used once *today*. Put it here.
68
+
69
+ - **Guard the degenerate input at the bottom, not at every call site.** An
70
+ empty series, a single point, a constant series, a zero-size canvas and a
71
+ `NaN` in the source data are all real and all reach these functions. Each
72
+ returns something finite and sensible, which is why nothing upstream has to
73
+ check first.
74
+
75
+ - **A `"worklet"` directive is a string, not an import.** It costs a module
76
+ nothing in purity, which is how the scrub solver and the scale readers stay
77
+ testable while running on the UI thread.
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { chooseMorphStrategy } from "./morph-strategy";
3
+
4
+ describe("chooseMorphStrategy", () => {
5
+ test("does nothing when the counts already match", () => {
6
+ expect(chooseMorphStrategy([0, 1, 2], [0, 1, 2])).toBe("none");
7
+ expect(chooseMorphStrategy([0, 1, 2], [9, 8, 7])).toBe("none");
8
+ });
9
+
10
+ test("pads the end when points were appended — the streaming case", () => {
11
+ expect(chooseMorphStrategy([0, 1, 2], [0, 1, 2, 3, 4])).toBe("pad-end");
12
+ });
13
+
14
+ test("pads the end when points were dropped from the end", () => {
15
+ expect(chooseMorphStrategy([0, 1, 2, 3, 4], [0, 1, 2])).toBe("pad-end");
16
+ });
17
+
18
+ test("pads the start when the head was dropped — a rolling window", () => {
19
+ expect(chooseMorphStrategy([0, 1, 2, 3, 4], [2, 3, 4])).toBe("pad-start");
20
+ });
21
+
22
+ test("pads the start when points were prepended", () => {
23
+ expect(chooseMorphStrategy([2, 3, 4], [0, 1, 2, 3, 4])).toBe("pad-start");
24
+ });
25
+
26
+ test("resamples when the two series are not versions of each other", () => {
27
+ expect(chooseMorphStrategy([0, 1, 2], [10, 20, 30, 40])).toBe("resample");
28
+ });
29
+
30
+ test("resamples when either series is empty", () => {
31
+ expect(chooseMorphStrategy([], [0, 1])).toBe("resample");
32
+ expect(chooseMorphStrategy([0, 1], [])).toBe("resample");
33
+ });
34
+
35
+ test("tolerates floating-point drift in the x values", () => {
36
+ expect(chooseMorphStrategy([0, 1, 2], [1e-9, 1 + 1e-9, 2, 3])).toBe("pad-end");
37
+ });
38
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * How two series of different lengths are brought to a common point count.
3
+ *
4
+ * Named for which end of the shorter array is padded, not for what changed.
5
+ */
6
+ export type MorphStrategy = "none" | "pad-end" | "pad-start" | "resample";
7
+
8
+ /** How closely two x-series must agree to count as the same points. */
9
+ const X_EPSILON = 1e-6;
10
+
11
+ /**
12
+ * The strategy that makes a data change read correctly.
13
+ *
14
+ * The choice is the whole reason this module exists. Every strategy produces
15
+ * two arrays of equal length and so a pair of interpolatable paths; only one
16
+ * of them produces motion that means what the data did.
17
+ *
18
+ * - Points appended to the end — the streaming case — pad the previous series
19
+ * at its end, so the new tail grows out of where the line stopped instead of
20
+ * the whole line sliding left.
21
+ * - Points dropped from the front — a rolling window — pad the next series at
22
+ * its start, so the departing head collapses into the first survivor.
23
+ * - Anything else resamples both, which is the honest answer when the two
24
+ * series are not versions of each other.
25
+ */
26
+ export function chooseMorphStrategy(previousXs: readonly number[], nextXs: readonly number[]): MorphStrategy {
27
+ if (previousXs.length === nextXs.length) return "none";
28
+ if (previousXs.length === 0 || nextXs.length === 0) return "resample";
29
+
30
+ const shorter = previousXs.length < nextXs.length ? previousXs : nextXs;
31
+ const longer = previousXs.length < nextXs.length ? nextXs : previousXs;
32
+
33
+ let prefixMatches = true;
34
+ for (let index = 0; index < shorter.length; index += 1) {
35
+ if (Math.abs((shorter[index] as number) - (longer[index] as number)) > X_EPSILON) {
36
+ prefixMatches = false;
37
+ break;
38
+ }
39
+ }
40
+ if (prefixMatches) return "pad-end";
41
+
42
+ const offset = longer.length - shorter.length;
43
+ let suffixMatches = true;
44
+ for (let index = 0; index < shorter.length; index += 1) {
45
+ if (Math.abs((shorter[index] as number) - (longer[index + offset] as number)) > X_EPSILON) {
46
+ suffixMatches = false;
47
+ break;
48
+ }
49
+ }
50
+ if (suffixMatches) return "pad-start";
51
+
52
+ return "resample";
53
+ }