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