@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,83 @@
|
|
|
1
|
+
import { LinearGradient, Path, vec } 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, ChartSegment } from "../../core/chart.types";
|
|
6
|
+
import { buildAreaPath } from "../../core/curve/build-area";
|
|
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 ChartAreaProps = {
|
|
12
|
+
readonly yKey?: string;
|
|
13
|
+
readonly points?: readonly ChartPoint[];
|
|
14
|
+
/**
|
|
15
|
+
* Stacked segments to draw as a band, from each one's `y0` up to its `y`.
|
|
16
|
+
*
|
|
17
|
+
* Usually `chart.stacked[key]`. Takes precedence over `yKey` and `points`,
|
|
18
|
+
* and a gap in the band closes against `baseline` as a gap in a fill does.
|
|
19
|
+
*/
|
|
20
|
+
readonly segments?: readonly ChartSegment[];
|
|
21
|
+
/** A flat fill. Give this or `gradient`, not both. */
|
|
22
|
+
readonly color?: string;
|
|
23
|
+
/** Top-to-bottom stops. Two or more; the last is usually transparent. */
|
|
24
|
+
readonly gradient?: readonly string[];
|
|
25
|
+
/** Canvas y the fill closes against — an x on a horizontal chart. Defaults to the plot rect's bottom, or its left. */
|
|
26
|
+
readonly baseline?: number;
|
|
27
|
+
readonly curve?: CurveType;
|
|
28
|
+
readonly connectMissingData?: boolean;
|
|
29
|
+
readonly opacity?: number;
|
|
30
|
+
readonly animation?: ChartAnimation;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The region between a series and a baseline.
|
|
35
|
+
*
|
|
36
|
+
* The gradient runs the height of the **plot rect**, not of the path's own
|
|
37
|
+
* bounds. Anchoring it to the path would make the same data fade differently
|
|
38
|
+
* as the domain changed, so two charts side by side would disagree about what
|
|
39
|
+
* a colour meant.
|
|
40
|
+
*/
|
|
41
|
+
export function ChartArea({
|
|
42
|
+
yKey,
|
|
43
|
+
points,
|
|
44
|
+
segments,
|
|
45
|
+
color,
|
|
46
|
+
gradient,
|
|
47
|
+
baseline,
|
|
48
|
+
curve,
|
|
49
|
+
connectMissingData,
|
|
50
|
+
opacity,
|
|
51
|
+
animation,
|
|
52
|
+
}: ChartAreaProps): ReactElement {
|
|
53
|
+
const chart = useChartContext();
|
|
54
|
+
const series = segments ?? points ?? (yKey === undefined ? [] : (chart.points[yKey] ?? []));
|
|
55
|
+
const { orientation } = chart;
|
|
56
|
+
const floor = baseline ?? (orientation === "horizontal" ? chart.bounds.left : chart.bounds.bottom);
|
|
57
|
+
|
|
58
|
+
const path = useMemo(
|
|
59
|
+
() =>
|
|
60
|
+
toSkPath(
|
|
61
|
+
buildAreaPath(series, {
|
|
62
|
+
baseline: floor,
|
|
63
|
+
curve: curve ?? chart.curve,
|
|
64
|
+
connectMissingData,
|
|
65
|
+
lower: segments?.map((segment) => segment.y0),
|
|
66
|
+
orientation,
|
|
67
|
+
})
|
|
68
|
+
),
|
|
69
|
+
[series, segments, floor, curve, chart.curve, connectMissingData, orientation]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const animated = useAnimatedPath(path, animation ?? chart.animation);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Path color={gradient ? undefined : color} opacity={opacity} path={animated} style="fill">
|
|
76
|
+
{gradient === undefined ? null : (
|
|
77
|
+
<LinearGradient colors={[...gradient]} end={vec(0, chart.bounds.bottom)} start={vec(0, chart.bounds.top)} />
|
|
78
|
+
)}
|
|
79
|
+
</Path>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ChartArea.displayName = "DelacourCharts.ChartArea";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Path } 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 type { ChartOrientation, ChartPoint } from "../../core/chart.types";
|
|
6
|
+
import { type Band, groupLayout, resolveBand } from "../../core/geometry/band";
|
|
7
|
+
import { buildBarsPath, resolveBaseline } from "../../core/shape/build-bars";
|
|
8
|
+
import type { CornerRadii } from "../../core/shape/rect-path";
|
|
9
|
+
import { toSkPath } from "../../skia/build-path";
|
|
10
|
+
import { useChartContext } from "../cartesian-chart.context";
|
|
11
|
+
import { categorySpan } from "./chart-bar";
|
|
12
|
+
|
|
13
|
+
export type ChartBarGroupProps = {
|
|
14
|
+
/** The series to draw side by side, in order. */
|
|
15
|
+
readonly yKeys: readonly string[];
|
|
16
|
+
/** One colour per key, cycled when there are fewer. */
|
|
17
|
+
readonly colors: readonly string[];
|
|
18
|
+
/** The fraction of each step left empty between groups. Defaults to 0.2. */
|
|
19
|
+
readonly betweenGroupPadding?: number;
|
|
20
|
+
/** The fraction of each bar's slot left empty. Defaults to 0.2. */
|
|
21
|
+
readonly withinGroupPadding?: number;
|
|
22
|
+
/** An exact bar width, instead of a fraction of the slot. */
|
|
23
|
+
readonly barWidth?: number;
|
|
24
|
+
/** Size groups as if the plot held this many, so charts of different lengths match. */
|
|
25
|
+
readonly barCount?: number;
|
|
26
|
+
readonly roundedCorners?: CornerRadii;
|
|
27
|
+
readonly opacity?: number;
|
|
28
|
+
readonly animation?: ChartAnimation;
|
|
29
|
+
/** Reports the resolved step and bar width, for a legend or a label that wants them. */
|
|
30
|
+
readonly onBarSizeChange?: (band: Band) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Several series as bars side by side within each step.
|
|
35
|
+
*
|
|
36
|
+
* Takes `yKeys` rather than child marks because a Skia tree gains nothing
|
|
37
|
+
* from identity matching: the group has to know how many series there are
|
|
38
|
+
* before any of them can know its offset, and reading that off children is
|
|
39
|
+
* the same information arriving later.
|
|
40
|
+
*
|
|
41
|
+
* One `<Path>` per series, each with its own animation, so a series that is
|
|
42
|
+
* added or removed morphs on its own without disturbing its neighbours.
|
|
43
|
+
*/
|
|
44
|
+
export function ChartBarGroup({
|
|
45
|
+
yKeys,
|
|
46
|
+
colors,
|
|
47
|
+
betweenGroupPadding,
|
|
48
|
+
withinGroupPadding,
|
|
49
|
+
barWidth,
|
|
50
|
+
barCount,
|
|
51
|
+
roundedCorners,
|
|
52
|
+
opacity,
|
|
53
|
+
animation,
|
|
54
|
+
onBarSizeChange,
|
|
55
|
+
}: ChartBarGroupProps): ReactElement {
|
|
56
|
+
const chart = useChartContext();
|
|
57
|
+
const { bounds, xPositions, orientation } = chart;
|
|
58
|
+
const valueScale = orientation === "horizontal" ? chart.xScale : chart.yScale;
|
|
59
|
+
|
|
60
|
+
const layout = useMemo(() => {
|
|
61
|
+
const band = resolveBand({ xPositions, plotWidth: categorySpan(bounds, orientation), barCount });
|
|
62
|
+
const group = groupLayout({
|
|
63
|
+
step: band.step,
|
|
64
|
+
seriesCount: yKeys.length,
|
|
65
|
+
betweenGroupPadding,
|
|
66
|
+
withinGroupPadding,
|
|
67
|
+
barWidth,
|
|
68
|
+
});
|
|
69
|
+
return { step: band.step, bandwidth: group.bandwidth, offsets: group.offsets };
|
|
70
|
+
}, [xPositions, bounds, orientation, barCount, yKeys.length, betweenGroupPadding, withinGroupPadding, barWidth]);
|
|
71
|
+
|
|
72
|
+
const baseline = resolveBaseline(valueScale, bounds, orientation);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
onBarSizeChange?.({ step: layout.step, bandwidth: layout.bandwidth });
|
|
76
|
+
}, [layout.step, layout.bandwidth, onBarSizeChange]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
{yKeys.map((key, index) => (
|
|
81
|
+
<GroupedBarSeries
|
|
82
|
+
animation={animation ?? chart.animation}
|
|
83
|
+
bandwidth={layout.bandwidth}
|
|
84
|
+
baseline={baseline}
|
|
85
|
+
color={colors[index % colors.length] ?? colors[0] ?? "transparent"}
|
|
86
|
+
key={key}
|
|
87
|
+
offset={layout.offsets[index] ?? 0}
|
|
88
|
+
opacity={opacity}
|
|
89
|
+
orientation={orientation}
|
|
90
|
+
points={chart.points[key] ?? []}
|
|
91
|
+
roundedCorners={roundedCorners}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ChartBarGroup.displayName = "DelacourCharts.ChartBarGroup";
|
|
99
|
+
|
|
100
|
+
type GroupedBarSeriesProps = {
|
|
101
|
+
readonly points: readonly ChartPoint[];
|
|
102
|
+
readonly bandwidth: number;
|
|
103
|
+
readonly baseline: number;
|
|
104
|
+
readonly offset: number;
|
|
105
|
+
readonly orientation: ChartOrientation;
|
|
106
|
+
readonly roundedCorners?: CornerRadii;
|
|
107
|
+
readonly color: string;
|
|
108
|
+
readonly opacity?: number;
|
|
109
|
+
readonly animation: ChartAnimation;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** One series of the group. A component of its own so each path owns its hooks. */
|
|
113
|
+
function GroupedBarSeries({
|
|
114
|
+
points,
|
|
115
|
+
bandwidth,
|
|
116
|
+
baseline,
|
|
117
|
+
offset,
|
|
118
|
+
orientation,
|
|
119
|
+
roundedCorners,
|
|
120
|
+
color,
|
|
121
|
+
opacity,
|
|
122
|
+
animation,
|
|
123
|
+
}: GroupedBarSeriesProps): ReactElement {
|
|
124
|
+
const path = useMemo(
|
|
125
|
+
() => toSkPath(buildBarsPath(points, { bandwidth, baseline, offset, roundedCorners, orientation })),
|
|
126
|
+
[points, bandwidth, baseline, offset, roundedCorners, orientation]
|
|
127
|
+
);
|
|
128
|
+
const animated = useAnimatedPath(path, animation);
|
|
129
|
+
return <Path color={color} opacity={opacity} path={animated} style="fill" />;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
GroupedBarSeries.displayName = "DelacourCharts.ChartBarGroup.Series";
|
|
@@ -0,0 +1,209 @@
|
|
|
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 { ChartOrientation, ChartSegment } from "../../core/chart.types";
|
|
6
|
+
import { resolveBand } from "../../core/geometry/band";
|
|
7
|
+
import { barRects, barsPathFromRects, resolveBaseline } from "../../core/shape/build-bars";
|
|
8
|
+
import type { CornerRadii } from "../../core/shape/rect-path";
|
|
9
|
+
import { toSkPath } from "../../skia/build-path";
|
|
10
|
+
import { useChartContext } from "../cartesian-chart.context";
|
|
11
|
+
import { categorySpan } from "./chart-bar";
|
|
12
|
+
|
|
13
|
+
/** Where one segment sits in its stack, for `barOptions`. */
|
|
14
|
+
export type ChartBarStackSegmentInfo = {
|
|
15
|
+
readonly seriesIndex: number;
|
|
16
|
+
readonly datumIndex: number;
|
|
17
|
+
/** The segment furthest from zero on its side of the axis — the stack's outer end. */
|
|
18
|
+
readonly isTop: boolean;
|
|
19
|
+
/** The segment nearest zero on its side of the axis. */
|
|
20
|
+
readonly isBottom: boolean;
|
|
21
|
+
/** The first datum. */
|
|
22
|
+
readonly isStart: boolean;
|
|
23
|
+
/** The last datum. */
|
|
24
|
+
readonly isEnd: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ChartBarStackSegmentOptions = {
|
|
28
|
+
/** `topLeft`/`topRight` are the value end, which for a negative segment is its canvas bottom. */
|
|
29
|
+
readonly roundedCorners?: CornerRadii;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ChartBarStackProps = {
|
|
33
|
+
/** The series to stack, bottom first. Each must be in the chart's `stackKeys`. */
|
|
34
|
+
readonly yKeys: readonly string[];
|
|
35
|
+
/** One colour per key, cycled when there are fewer. */
|
|
36
|
+
readonly colors: readonly string[];
|
|
37
|
+
/** The fraction of each step left empty. Defaults to 0.25. */
|
|
38
|
+
readonly innerPadding?: number;
|
|
39
|
+
/** An exact bar width, instead of a fraction of the step. */
|
|
40
|
+
readonly barWidth?: number;
|
|
41
|
+
/** Size bars as if the plot held this many, so charts of different lengths match. */
|
|
42
|
+
readonly barCount?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Per-segment options — the way to round only the outermost segment.
|
|
45
|
+
*
|
|
46
|
+
* Corners are the only per-segment option because a series is one `<Path>`
|
|
47
|
+
* node, and a path has one colour and one opacity. Per-datum colour would
|
|
48
|
+
* need a node per bar, which is what this design exists to avoid.
|
|
49
|
+
*/
|
|
50
|
+
readonly barOptions?: (info: ChartBarStackSegmentInfo) => ChartBarStackSegmentOptions;
|
|
51
|
+
readonly opacity?: number;
|
|
52
|
+
readonly animation?: ChartAnimation;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Several series stacked on one another within each step.
|
|
57
|
+
*
|
|
58
|
+
* Reads `chart.stacked`, not `chart.points`: the root stacks in data space so
|
|
59
|
+
* that the y domain covers the totals, and a mark that stacked for itself
|
|
60
|
+
* would draw past the top of the plot. A key missing from the root's
|
|
61
|
+
* `stackKeys` has no stacked series and draws nothing.
|
|
62
|
+
*
|
|
63
|
+
* One `<Path>` per series, so the segments of one series morph together and
|
|
64
|
+
* a series can be added or removed without re-keying the others.
|
|
65
|
+
*/
|
|
66
|
+
export function ChartBarStack({
|
|
67
|
+
yKeys,
|
|
68
|
+
colors,
|
|
69
|
+
innerPadding = 0.25,
|
|
70
|
+
barWidth,
|
|
71
|
+
barCount,
|
|
72
|
+
barOptions,
|
|
73
|
+
opacity,
|
|
74
|
+
animation,
|
|
75
|
+
}: ChartBarStackProps): ReactElement {
|
|
76
|
+
const chart = useChartContext();
|
|
77
|
+
const { bounds, xPositions, stacked, orientation } = chart;
|
|
78
|
+
const valueScale = orientation === "horizontal" ? chart.xScale : chart.yScale;
|
|
79
|
+
|
|
80
|
+
const bandwidth = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
resolveBand({ xPositions, plotWidth: categorySpan(bounds, orientation), innerPadding, barWidth, barCount })
|
|
83
|
+
.bandwidth,
|
|
84
|
+
[xPositions, bounds, orientation, innerPadding, barWidth, barCount]
|
|
85
|
+
);
|
|
86
|
+
const baseline = resolveBaseline(valueScale, bounds, orientation);
|
|
87
|
+
|
|
88
|
+
// Which series is the outer end of each column, on each side of zero. A
|
|
89
|
+
// null segment is skipped, so the series above a gap is the outer end.
|
|
90
|
+
const ends = useMemo(() => stackEnds(yKeys.map((key) => stacked[key] ?? [])), [yKeys, stacked]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{yKeys.map((key, seriesIndex) => (
|
|
95
|
+
<StackedBarSeries
|
|
96
|
+
animation={animation ?? chart.animation}
|
|
97
|
+
bandwidth={bandwidth}
|
|
98
|
+
barOptions={barOptions}
|
|
99
|
+
baseline={baseline}
|
|
100
|
+
color={colors[seriesIndex % colors.length] ?? colors[0] ?? "transparent"}
|
|
101
|
+
ends={ends}
|
|
102
|
+
key={key}
|
|
103
|
+
opacity={opacity}
|
|
104
|
+
orientation={orientation}
|
|
105
|
+
segments={stacked[key] ?? []}
|
|
106
|
+
seriesIndex={seriesIndex}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ChartBarStack.displayName = "DelacourCharts.ChartBarStack";
|
|
114
|
+
|
|
115
|
+
/** Per datum: the series index nearest and furthest from zero, per sign. */
|
|
116
|
+
type StackEnds = readonly {
|
|
117
|
+
readonly firstPositive: number;
|
|
118
|
+
readonly lastPositive: number;
|
|
119
|
+
readonly firstNegative: number;
|
|
120
|
+
readonly lastNegative: number;
|
|
121
|
+
}[];
|
|
122
|
+
|
|
123
|
+
/** Which side of zero a segment lies on, or `null` for a gap. */
|
|
124
|
+
function segmentSign(segment: ChartSegment | undefined): "positive" | "negative" | null {
|
|
125
|
+
if (segment === undefined || segment.yValue === null || segment.y0Value === null) return null;
|
|
126
|
+
return segment.yValue < segment.y0Value ? "negative" : "positive";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stackEnds(series: readonly (readonly ChartSegment[])[]): StackEnds {
|
|
130
|
+
const length = Math.max(0, ...series.map((segments) => segments.length));
|
|
131
|
+
const ends: {
|
|
132
|
+
firstPositive: number;
|
|
133
|
+
lastPositive: number;
|
|
134
|
+
firstNegative: number;
|
|
135
|
+
lastNegative: number;
|
|
136
|
+
}[] = [];
|
|
137
|
+
for (let datum = 0; datum < length; datum += 1) {
|
|
138
|
+
const end = { firstPositive: -1, lastPositive: -1, firstNegative: -1, lastNegative: -1 };
|
|
139
|
+
series.forEach((segments, index) => {
|
|
140
|
+
const sign = segmentSign(segments[datum]);
|
|
141
|
+
if (sign === "negative") {
|
|
142
|
+
if (end.firstNegative === -1) end.firstNegative = index;
|
|
143
|
+
end.lastNegative = index;
|
|
144
|
+
} else if (sign === "positive") {
|
|
145
|
+
if (end.firstPositive === -1) end.firstPositive = index;
|
|
146
|
+
end.lastPositive = index;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
ends.push(end);
|
|
150
|
+
}
|
|
151
|
+
return ends;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type StackedBarSeriesProps = {
|
|
155
|
+
readonly segments: readonly ChartSegment[];
|
|
156
|
+
readonly seriesIndex: number;
|
|
157
|
+
readonly ends: StackEnds;
|
|
158
|
+
readonly bandwidth: number;
|
|
159
|
+
readonly baseline: number;
|
|
160
|
+
readonly orientation: ChartOrientation;
|
|
161
|
+
readonly barOptions?: (info: ChartBarStackSegmentInfo) => ChartBarStackSegmentOptions;
|
|
162
|
+
readonly color: string;
|
|
163
|
+
readonly opacity?: number;
|
|
164
|
+
readonly animation: ChartAnimation;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** One series of the stack. A component of its own so each path owns its hooks. */
|
|
168
|
+
function StackedBarSeries({
|
|
169
|
+
segments,
|
|
170
|
+
seriesIndex,
|
|
171
|
+
ends,
|
|
172
|
+
bandwidth,
|
|
173
|
+
baseline,
|
|
174
|
+
orientation,
|
|
175
|
+
barOptions,
|
|
176
|
+
color,
|
|
177
|
+
opacity,
|
|
178
|
+
animation,
|
|
179
|
+
}: StackedBarSeriesProps): ReactElement {
|
|
180
|
+
const path = useMemo(() => {
|
|
181
|
+
const rects = barRects(segments, { bandwidth, baseline, orientation });
|
|
182
|
+
const corners = barOptions === undefined ? undefined : rects.map((rect) => barOptions(segmentInfo(rect.index)));
|
|
183
|
+
return toSkPath(
|
|
184
|
+
barsPathFromRects(
|
|
185
|
+
rects,
|
|
186
|
+
corners?.map((options) => options.roundedCorners),
|
|
187
|
+
orientation
|
|
188
|
+
)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
function segmentInfo(datumIndex: number): ChartBarStackSegmentInfo {
|
|
192
|
+
const end = ends[datumIndex];
|
|
193
|
+
const negative = segmentSign(segments[datumIndex]) === "negative";
|
|
194
|
+
return {
|
|
195
|
+
seriesIndex,
|
|
196
|
+
datumIndex,
|
|
197
|
+
isTop: end !== undefined && (negative ? end.lastNegative : end.lastPositive) === seriesIndex,
|
|
198
|
+
isBottom: end !== undefined && (negative ? end.firstNegative : end.firstPositive) === seriesIndex,
|
|
199
|
+
isStart: datumIndex === 0,
|
|
200
|
+
isEnd: datumIndex === segments.length - 1,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}, [segments, seriesIndex, ends, bandwidth, baseline, orientation, barOptions]);
|
|
204
|
+
|
|
205
|
+
const animated = useAnimatedPath(path, animation);
|
|
206
|
+
return <Path color={color} opacity={opacity} path={animated} style="fill" />;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
StackedBarSeries.displayName = "DelacourCharts.ChartBarStack.Series";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Path, type SkFont, Text } from "@shopify/react-native-skia";
|
|
2
|
+
import { type ReactElement, type ReactNode, useEffect, useMemo } from "react";
|
|
3
|
+
import type { ChartAnimation } from "../../animation/animation.types";
|
|
4
|
+
import { useAnimatedPath } from "../../animation/use-animated-path";
|
|
5
|
+
import type { ChartBounds, ChartOrientation, ChartPoint } from "../../core/chart.types";
|
|
6
|
+
import { resolveBand } from "../../core/geometry/band";
|
|
7
|
+
import { categoryDomainCovers } from "../../core/geometry/category-domain";
|
|
8
|
+
import { pickAxisRoles } from "../../core/geometry/chart-layout";
|
|
9
|
+
import { type BarRect, barRects, barsPathFromRects, resolveBaseline } from "../../core/shape/build-bars";
|
|
10
|
+
import type { CornerRadii } from "../../core/shape/rect-path";
|
|
11
|
+
import { type BarLabelPosition, barLabelAnchor } from "../../core/text/bar-label";
|
|
12
|
+
import type { LabelMetrics } from "../../core/text/label-anchor";
|
|
13
|
+
import { toSkPath } from "../../skia/build-path";
|
|
14
|
+
import { fontMetrics, measureLabelWidth } from "../../skia/font";
|
|
15
|
+
import { useChartContext } from "../cartesian-chart.context";
|
|
16
|
+
import type { ChartContextValue } from "../cartesian-chart.types";
|
|
17
|
+
|
|
18
|
+
export type ChartBarLabels = {
|
|
19
|
+
/** Defaults to `top` — the value end, which flips below a negative bar. */
|
|
20
|
+
readonly position?: BarLabelPosition;
|
|
21
|
+
/** Defaults to the chart's font. Nothing draws while it is `null`. */
|
|
22
|
+
readonly font?: SkFont | null;
|
|
23
|
+
readonly color: string;
|
|
24
|
+
/** Defaults to the value as a string. Return `""` to skip a bar. */
|
|
25
|
+
readonly formatLabel?: (point: ChartPoint, index: number) => string;
|
|
26
|
+
/** Space between the bar and the label. */
|
|
27
|
+
readonly gap?: number;
|
|
28
|
+
readonly opacity?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ChartBarProps = {
|
|
32
|
+
/** Which series to draw. Read from the chart's context. */
|
|
33
|
+
readonly yKey?: string;
|
|
34
|
+
/** The points to draw, when they are not coming from context. */
|
|
35
|
+
readonly points?: readonly ChartPoint[];
|
|
36
|
+
readonly color?: string;
|
|
37
|
+
readonly opacity?: number;
|
|
38
|
+
/** The fraction of each step left empty. Defaults to 0.2. */
|
|
39
|
+
readonly innerPadding?: number;
|
|
40
|
+
/** An exact bar width, instead of a fraction of the step. */
|
|
41
|
+
readonly barWidth?: number;
|
|
42
|
+
/** Size bars as if the plot held this many, so charts of different lengths match. */
|
|
43
|
+
readonly barCount?: number;
|
|
44
|
+
/** `topLeft`/`topRight` are the value end; a negative bar rounds its bottom instead. */
|
|
45
|
+
readonly roundedCorners?: CornerRadii;
|
|
46
|
+
readonly animation?: ChartAnimation;
|
|
47
|
+
/** A value printed against each bar. */
|
|
48
|
+
readonly labels?: ChartBarLabels;
|
|
49
|
+
/** Skia children of the path — a gradient, a shader. */
|
|
50
|
+
readonly children?: ReactNode;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One bar per datum, standing on zero.
|
|
55
|
+
*
|
|
56
|
+
* Every bar in the series is one `<Path>`, and every corner of every bar is a
|
|
57
|
+
* cubic, so a change of data morphs rather than snaps — see `rectPath`. A gap
|
|
58
|
+
* is a zero-height bar at the baseline for the same reason.
|
|
59
|
+
*
|
|
60
|
+
* The width comes from `resolveBand` against the chart's x positions: there is
|
|
61
|
+
* no band scale here, so the step is measured. The chart's x domain has to be
|
|
62
|
+
* padded by half a step each side for the first and last bars to sit inside
|
|
63
|
+
* the plot; the development-only warning below names the prop that does it.
|
|
64
|
+
*/
|
|
65
|
+
export function ChartBar({
|
|
66
|
+
yKey,
|
|
67
|
+
points,
|
|
68
|
+
color,
|
|
69
|
+
opacity,
|
|
70
|
+
innerPadding,
|
|
71
|
+
barWidth,
|
|
72
|
+
barCount,
|
|
73
|
+
roundedCorners,
|
|
74
|
+
animation,
|
|
75
|
+
labels,
|
|
76
|
+
children,
|
|
77
|
+
}: ChartBarProps): ReactElement {
|
|
78
|
+
const chart = useChartContext();
|
|
79
|
+
const series = points ?? (yKey === undefined ? [] : (chart.points[yKey] ?? []));
|
|
80
|
+
const { bounds, xPositions, orientation } = chart;
|
|
81
|
+
const valueScale = orientation === "horizontal" ? chart.xScale : chart.yScale;
|
|
82
|
+
|
|
83
|
+
const rects = useMemo(() => {
|
|
84
|
+
const band = resolveBand({
|
|
85
|
+
xPositions,
|
|
86
|
+
plotWidth: categorySpan(bounds, orientation),
|
|
87
|
+
innerPadding,
|
|
88
|
+
barWidth,
|
|
89
|
+
barCount,
|
|
90
|
+
});
|
|
91
|
+
return barRects(series, {
|
|
92
|
+
bandwidth: band.bandwidth,
|
|
93
|
+
baseline: resolveBaseline(valueScale, bounds, orientation),
|
|
94
|
+
orientation,
|
|
95
|
+
});
|
|
96
|
+
}, [series, xPositions, bounds, valueScale, orientation, innerPadding, barWidth, barCount]);
|
|
97
|
+
|
|
98
|
+
const path = useMemo(
|
|
99
|
+
() => toSkPath(barsPathFromRects(rects, roundedCorners, orientation)),
|
|
100
|
+
[rects, roundedCorners, orientation]
|
|
101
|
+
);
|
|
102
|
+
const animated = useAnimatedPath(path, animation ?? chart.animation);
|
|
103
|
+
|
|
104
|
+
useBarDomainWarning(series, chart);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<Path color={color} opacity={opacity} path={animated} style="fill">
|
|
109
|
+
{children}
|
|
110
|
+
</Path>
|
|
111
|
+
{labels === undefined ? null : <BarLabels chart={chart} labels={labels} rects={rects} series={series} />}
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ChartBar.displayName = "DelacourCharts.ChartBar";
|
|
117
|
+
|
|
118
|
+
/** The plot's extent along the category axis — its width, or its height when horizontal. */
|
|
119
|
+
export function categorySpan(bounds: ChartBounds, orientation: ChartOrientation): number {
|
|
120
|
+
return orientation === "horizontal" ? bounds.bottom - bounds.top : bounds.right - bounds.left;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Warns, in development, when the x domain cannot hold the first or last bar.
|
|
125
|
+
*
|
|
126
|
+
* The domain the root measures ends exactly at the outermost data, so a bar
|
|
127
|
+
* centred there is half off the plot. There is no way for a mark to widen the
|
|
128
|
+
* domain — the root owns it — so this says which prop to reach for.
|
|
129
|
+
*/
|
|
130
|
+
function useBarDomainWarning(series: readonly ChartPoint[], chart: ChartContextValue): void {
|
|
131
|
+
const { xStep, orientation } = chart;
|
|
132
|
+
// The category scale is `yScale` on a horizontal chart; asking by role is
|
|
133
|
+
// what stopped this warning firing on every horizontal bar.
|
|
134
|
+
const categoryScale = pickAxisRoles(chart.xScale, chart.yScale, orientation).category;
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!__DEV__ || categoryDomainCovers(series, categoryScale, xStep.value)) return;
|
|
138
|
+
console.warn(
|
|
139
|
+
"[@delacour/react-native-charts] the x domain does not cover the first or last bar, so it is drawn half " +
|
|
140
|
+
"outside the plot. Pass domainPadding={{ x: 0.5 }} to the chart to pad the domain by half a step each side."
|
|
141
|
+
);
|
|
142
|
+
}, [series, categoryScale, xStep.value]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type BarLabelsProps = {
|
|
146
|
+
readonly chart: ChartContextValue;
|
|
147
|
+
readonly labels: ChartBarLabels;
|
|
148
|
+
readonly rects: readonly BarRect[];
|
|
149
|
+
readonly series: readonly ChartPoint[];
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** One Skia `<Text>` per bar with a value. Draws nothing while the font is `null`. */
|
|
153
|
+
function BarLabels({ chart, labels, rects, series }: BarLabelsProps): ReactElement | null {
|
|
154
|
+
const font = labels.font === undefined ? chart.font : labels.font;
|
|
155
|
+
const metrics: LabelMetrics = labels.font === undefined ? chart.fontMetrics : fontMetrics(labels.font);
|
|
156
|
+
if (font === null) return null;
|
|
157
|
+
const position = labels.position ?? "top";
|
|
158
|
+
const format = labels.formatLabel ?? defaultBarLabel;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
{rects.map((rect, index) => {
|
|
163
|
+
const point = series[index];
|
|
164
|
+
if (point === undefined || point.yValue === null) return null;
|
|
165
|
+
const text = format(point, index);
|
|
166
|
+
if (text === "") return null;
|
|
167
|
+
const anchor = barLabelAnchor({
|
|
168
|
+
rect,
|
|
169
|
+
position,
|
|
170
|
+
width: measureLabelWidth(font, text),
|
|
171
|
+
metrics,
|
|
172
|
+
gap: labels.gap,
|
|
173
|
+
orientation: chart.orientation,
|
|
174
|
+
});
|
|
175
|
+
return (
|
|
176
|
+
<Text
|
|
177
|
+
color={labels.color}
|
|
178
|
+
font={font}
|
|
179
|
+
key={`${index}-${text}`}
|
|
180
|
+
opacity={labels.opacity}
|
|
181
|
+
text={text}
|
|
182
|
+
x={anchor.x}
|
|
183
|
+
y={anchor.y}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
BarLabels.displayName = "DelacourCharts.ChartBar.Labels";
|
|
192
|
+
|
|
193
|
+
function defaultBarLabel(point: ChartPoint): string {
|
|
194
|
+
return point.yValue === null ? "" : String(point.yValue);
|
|
195
|
+
}
|