@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,132 @@
|
|
|
1
|
+
import { Canvas } from "@shopify/react-native-skia";
|
|
2
|
+
import { type ReactElement, useEffect, useMemo } from "react";
|
|
3
|
+
import { StyleSheet, View } from "react-native";
|
|
4
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
5
|
+
import type { ChartRow } from "../core/chart.types";
|
|
6
|
+
import { buildLinePath } from "../core/curve/build-line";
|
|
7
|
+
import { toCurvePath } from "../core/interaction/path-segments";
|
|
8
|
+
import { EMPTY_SCRUB_MODEL, type ScrubModel, type ScrubSeries } from "../gesture/gesture.types";
|
|
9
|
+
import { ChartGestureOverlay } from "../gesture/gesture-overlay";
|
|
10
|
+
import { useScrubGesture } from "../gesture/use-scrub-gesture";
|
|
11
|
+
import { CartesianChartContext } from "./cartesian-chart.context";
|
|
12
|
+
import type { CartesianChartProps, CartesianRenderArgs, XKeyOf, YKeyOf } from "./cartesian-chart.types";
|
|
13
|
+
import { useCanvasSize } from "./hooks/use-canvas-size";
|
|
14
|
+
import { useChartModel } from "./hooks/use-chart-model";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A cartesian chart: scales, ticks, a plot rect, and whatever marks you place.
|
|
18
|
+
*
|
|
19
|
+
* ## Where things are mounted, and why
|
|
20
|
+
*
|
|
21
|
+
* ```
|
|
22
|
+
* View onLayout, and the only thing that knows the chart's size
|
|
23
|
+
* Canvas the Skia reconciler starts here
|
|
24
|
+
* Provider ← rendered INSIDE the canvas, so marks below can read it
|
|
25
|
+
* children
|
|
26
|
+
* Overlay an ordinary RN view; the canvas has no touch targets
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* The provider's position is the whole trick. `<Canvas>` mounts a second React
|
|
30
|
+
* reconciler and context does not cross a reconciler boundary — but it
|
|
31
|
+
* resolves by which reconciler renders the *provider node*, so placing the
|
|
32
|
+
* provider among the canvas' children puts it in the Skia tree and consumers
|
|
33
|
+
* below it resolve normally. No `its-fine`, no context bridge, no
|
|
34
|
+
* `FiberProvider` wrapping the host app.
|
|
35
|
+
*
|
|
36
|
+
* The consequence for a themed wrapper is that every hook — `useThemeColor`,
|
|
37
|
+
* anything reading a CSS variable — must be called **above** the canvas and
|
|
38
|
+
* its result passed down as a plain value. There is nowhere inside to call one.
|
|
39
|
+
*
|
|
40
|
+
* ## Both child forms
|
|
41
|
+
*
|
|
42
|
+
* `children` may be Skia nodes, which read the chart from context, or a
|
|
43
|
+
* function of the resolved chart. The same mark components serve both: given
|
|
44
|
+
* `yKey` they read context, given `points` they use what they are handed.
|
|
45
|
+
*/
|
|
46
|
+
export function CartesianChart<
|
|
47
|
+
Row extends ChartRow,
|
|
48
|
+
const XK extends XKeyOf<Row>,
|
|
49
|
+
const YK extends readonly YKeyOf<Row>[],
|
|
50
|
+
>(props: CartesianChartProps<Row, XK, YK>): ReactElement {
|
|
51
|
+
const { data, xKey, yKeys, children, scrub, scrubConfig, onBoundsChange, ...rest } = props;
|
|
52
|
+
|
|
53
|
+
const { size, onLayout } = useCanvasSize();
|
|
54
|
+
|
|
55
|
+
const model = useChartModel({
|
|
56
|
+
...rest,
|
|
57
|
+
data: data as readonly ChartRow[],
|
|
58
|
+
xKey,
|
|
59
|
+
yKeys,
|
|
60
|
+
canvas: size,
|
|
61
|
+
scrub,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const { bounds, points, xPositions, stacked, xScale, yScale, curve, orientation } = model;
|
|
65
|
+
|
|
66
|
+
// Everything the scrub worklet reads, as plain data in one shared value.
|
|
67
|
+
// Rebuilt on the JavaScript thread whenever the chart changes; read on the
|
|
68
|
+
// UI thread every frame of a drag.
|
|
69
|
+
const scrubModel = useSharedValue<ScrubModel>(EMPTY_SCRUB_MODEL);
|
|
70
|
+
|
|
71
|
+
// A stacked key's `ys` are the tops of its segments, so the cursor dot sits
|
|
72
|
+
// on the segment that is visible; its `values` stay the raw series, which is
|
|
73
|
+
// what a readout should print. On a horizontal chart the value position is
|
|
74
|
+
// the point's x, and there is no curve to glide along.
|
|
75
|
+
const nextScrubModel = useMemo<ScrubModel>(() => {
|
|
76
|
+
const horizontal = orientation === "horizontal";
|
|
77
|
+
const series: ScrubSeries[] = model.yKeys.map((key) => {
|
|
78
|
+
const raw = points[key] ?? [];
|
|
79
|
+
const drawn = stacked[key] ?? raw;
|
|
80
|
+
return {
|
|
81
|
+
key,
|
|
82
|
+
path: horizontal ? [] : toCurvePath(buildLinePath(drawn, { curve })),
|
|
83
|
+
ys: drawn.map((point) => (horizontal ? (point.yValue === null ? null : point.x) : point.y)),
|
|
84
|
+
values: raw.map((point) => point.yValue),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const scale = horizontal ? yScale : xScale;
|
|
89
|
+
return {
|
|
90
|
+
axis: horizontal ? "y" : "x",
|
|
91
|
+
left: bounds.left,
|
|
92
|
+
right: bounds.right,
|
|
93
|
+
top: bounds.top,
|
|
94
|
+
bottom: bounds.bottom,
|
|
95
|
+
positions: xPositions,
|
|
96
|
+
scale,
|
|
97
|
+
xPositions,
|
|
98
|
+
xScale: scale,
|
|
99
|
+
series,
|
|
100
|
+
};
|
|
101
|
+
}, [bounds, points, stacked, xPositions, xScale, yScale, curve, model.yKeys, orientation]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
scrubModel.value = nextScrubModel;
|
|
105
|
+
}, [nextScrubModel, scrubModel]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
onBoundsChange?.(bounds);
|
|
109
|
+
}, [bounds, onBoundsChange]);
|
|
110
|
+
|
|
111
|
+
const gesture = useScrubGesture({ state: scrub, model: scrubModel, config: scrubConfig });
|
|
112
|
+
|
|
113
|
+
const content =
|
|
114
|
+
typeof children === "function"
|
|
115
|
+
? children({ ...model, points: model.points, data } as CartesianRenderArgs<Row, YK>)
|
|
116
|
+
: children;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<View onLayout={onLayout} style={styles.root}>
|
|
120
|
+
<Canvas style={StyleSheet.absoluteFill}>
|
|
121
|
+
<CartesianChartContext.Provider value={model}>{content}</CartesianChartContext.Provider>
|
|
122
|
+
</Canvas>
|
|
123
|
+
{gesture === null ? null : <ChartGestureOverlay gesture={gesture} />}
|
|
124
|
+
</View>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
CartesianChart.displayName = "DelacourCharts.CartesianChart";
|
|
129
|
+
|
|
130
|
+
const styles = StyleSheet.create({
|
|
131
|
+
root: { flex: 1, overflow: "hidden" },
|
|
132
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
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 { ChartBounds, ChartOrientation, ChartPoint, ChartRow, ChartSegment, ChartSize } from "../core/chart.types";
|
|
5
|
+
import type { CurveType } from "../core/curve/curves";
|
|
6
|
+
import type { DomainPadding } from "../core/geometry/domain";
|
|
7
|
+
import type { ScaleDescriptor, ScaleType } from "../core/scale/scale.types";
|
|
8
|
+
import type { LabelMetrics } from "../core/text/label-anchor";
|
|
9
|
+
import type { ChartTick } from "../core/ticks/tick.types";
|
|
10
|
+
import type { SidedNumber } from "../core/util/sided-number";
|
|
11
|
+
import type { ChartScrubState, ScrubConfig } from "../gesture/gesture.types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fields that can position a point along x — a number, a date, or a label.
|
|
15
|
+
*
|
|
16
|
+
* A row typed only as `Record<string, unknown>` knows nothing about its own
|
|
17
|
+
* fields, so narrowing it would produce `never` and reject every key. The
|
|
18
|
+
* `string extends keyof Row` guard detects exactly that case — it holds only
|
|
19
|
+
* for an index signature — and widens back to `string`. Without it a caller
|
|
20
|
+
* who has not typed their rows cannot name a key at all, which is a type error
|
|
21
|
+
* on correct code.
|
|
22
|
+
*/
|
|
23
|
+
export type XKeyOf<Row extends ChartRow> = string extends keyof Row
|
|
24
|
+
? string
|
|
25
|
+
: {
|
|
26
|
+
[Key in keyof Row]-?: Row[Key] extends number | string | Date | null | undefined ? Key : never;
|
|
27
|
+
}[keyof Row] &
|
|
28
|
+
string;
|
|
29
|
+
|
|
30
|
+
/** Fields that can be measured up y. Widens the same way — see `XKeyOf`. */
|
|
31
|
+
export type YKeyOf<Row extends ChartRow> = string extends keyof Row
|
|
32
|
+
? string
|
|
33
|
+
: {
|
|
34
|
+
[Key in keyof Row]-?: Row[Key] extends number | null | undefined ? Key : never;
|
|
35
|
+
}[keyof Row] &
|
|
36
|
+
string;
|
|
37
|
+
|
|
38
|
+
export type AxisLabelFormatter = (value: number, index: number) => string;
|
|
39
|
+
|
|
40
|
+
export type AxisOptions = {
|
|
41
|
+
/** Draw this axis at all. Ticks and labels both. */
|
|
42
|
+
readonly show?: boolean;
|
|
43
|
+
/** How many ticks to aim for. The generator rounds it to human values. */
|
|
44
|
+
readonly tickCount?: number;
|
|
45
|
+
/** Explicit tick values, instead of generating any. */
|
|
46
|
+
readonly tickValues?: readonly number[];
|
|
47
|
+
readonly formatLabel?: AxisLabelFormatter;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type { ChartOrientation };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The width one datum owns along the category axis.
|
|
54
|
+
*
|
|
55
|
+
* `value` is in domain units — one for a categorical axis, a month's worth of
|
|
56
|
+
* milliseconds on a monthly time scale. `px` is the same distance on the
|
|
57
|
+
* canvas. A bar's band and the tooltip's highlight are both stated in it.
|
|
58
|
+
*
|
|
59
|
+
* It is the **category** step whichever axis the categories lie on: on a
|
|
60
|
+
* horizontal chart `px` is a distance along y. The name keeps the vertical
|
|
61
|
+
* case, like `xPositions` and `xValue`.
|
|
62
|
+
*/
|
|
63
|
+
export type ChartStep = {
|
|
64
|
+
readonly value: number;
|
|
65
|
+
readonly px: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Everything a mark, an axis or a render prop can read about the chart. */
|
|
69
|
+
export type ChartContextValue = {
|
|
70
|
+
/** One array per y key, in the order the keys were given. */
|
|
71
|
+
readonly points: Readonly<Record<string, readonly ChartPoint[]>>;
|
|
72
|
+
/** Canvas position of each row's category, shared by every series — a y on a horizontal chart. */
|
|
73
|
+
readonly xPositions: readonly number[];
|
|
74
|
+
readonly xStep: ChartStep;
|
|
75
|
+
/**
|
|
76
|
+
* The stacked series, one entry per key in `stackKeys`.
|
|
77
|
+
*
|
|
78
|
+
* `points[key]` still holds the raw series; a stacked mark reads here for
|
|
79
|
+
* the edges it draws between, and a tooltip reads there for the value it
|
|
80
|
+
* prints.
|
|
81
|
+
*/
|
|
82
|
+
readonly stacked: Readonly<Record<string, readonly ChartSegment[]>>;
|
|
83
|
+
readonly orientation: ChartOrientation;
|
|
84
|
+
/** The plot rect — the canvas minus padding and axis gutters. */
|
|
85
|
+
readonly bounds: ChartBounds;
|
|
86
|
+
/** The whole canvas, including the gutters. */
|
|
87
|
+
readonly canvas: ChartSize;
|
|
88
|
+
/** The scale on the canvas' x axis: categories when vertical, values when horizontal. */
|
|
89
|
+
readonly xScale: ScaleDescriptor;
|
|
90
|
+
/** The scale on the canvas' y axis: values when vertical, categories when horizontal. */
|
|
91
|
+
readonly yScale: ScaleDescriptor;
|
|
92
|
+
readonly xTicks: readonly ChartTick[];
|
|
93
|
+
readonly yTicks: readonly ChartTick[];
|
|
94
|
+
readonly xLabels: readonly string[];
|
|
95
|
+
readonly yLabels: readonly string[];
|
|
96
|
+
readonly yKeys: readonly string[];
|
|
97
|
+
/** `null` until the font resolves; an axis then draws ticks and no labels. */
|
|
98
|
+
readonly font: SkFont | null;
|
|
99
|
+
readonly lineHeight: number;
|
|
100
|
+
/** The font's ascent and descent, for placing a label by its glyph box. */
|
|
101
|
+
readonly fontMetrics: LabelMetrics;
|
|
102
|
+
readonly curve: CurveType;
|
|
103
|
+
readonly animation: ChartAnimation;
|
|
104
|
+
readonly scrub: ChartScrubState | null;
|
|
105
|
+
/** Whether x holds positions rather than measurements. */
|
|
106
|
+
readonly isCategorical: boolean;
|
|
107
|
+
/** Whether there is room to draw in. */
|
|
108
|
+
readonly ready: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type CartesianRenderArgs<Row extends ChartRow, YK extends readonly string[]> = Omit<
|
|
112
|
+
ChartContextValue,
|
|
113
|
+
"points"
|
|
114
|
+
> & {
|
|
115
|
+
readonly points: { readonly [Key in YK[number]]: readonly ChartPoint[] };
|
|
116
|
+
readonly data: readonly Row[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type CartesianChartProps<Row extends ChartRow, XK extends XKeyOf<Row>, YK extends readonly YKeyOf<Row>[]> = {
|
|
120
|
+
readonly data: readonly Row[];
|
|
121
|
+
readonly xKey: XK;
|
|
122
|
+
readonly yKeys: YK;
|
|
123
|
+
|
|
124
|
+
/** Space between the canvas edge and the axes. */
|
|
125
|
+
readonly padding?: SidedNumber;
|
|
126
|
+
/** Explicit bounds. Either end of either axis may be omitted. */
|
|
127
|
+
readonly domain?: {
|
|
128
|
+
readonly x?: readonly [number | undefined, number | undefined];
|
|
129
|
+
readonly y?: readonly [number | undefined, number | undefined];
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Extra span at each end of the domain.
|
|
133
|
+
*
|
|
134
|
+
* A bare number pads y, as a fraction of the measured extent. An object
|
|
135
|
+
* names each axis; `x` is measured in **steps**, so `{ x: 0.5 }` is half a
|
|
136
|
+
* band each side — what a bar chart needs for its first and last bars to
|
|
137
|
+
* sit inside the plot rather than straddle its edges.
|
|
138
|
+
*/
|
|
139
|
+
readonly domainPadding?: DomainPadding;
|
|
140
|
+
/**
|
|
141
|
+
* Which of `yKeys` stack on one another, bottom first.
|
|
142
|
+
*
|
|
143
|
+
* The root owns this rather than the mark because the y domain has to
|
|
144
|
+
* cover the running totals, and only the root builds the y scale. A stacked
|
|
145
|
+
* mark that stacked for itself would draw past the top of the plot.
|
|
146
|
+
*/
|
|
147
|
+
readonly stackKeys?: readonly string[];
|
|
148
|
+
/**
|
|
149
|
+
* Categories along x (the default) or along y.
|
|
150
|
+
*
|
|
151
|
+
* Horizontal swaps the axis roles at the model: categories are planned into
|
|
152
|
+
* the y range and values into the x range, so `yTicks` and `yLabels` carry
|
|
153
|
+
* the categories and the left gutter is measured for them. Every mark reads
|
|
154
|
+
* the same flag.
|
|
155
|
+
*/
|
|
156
|
+
readonly orientation?: ChartOrientation;
|
|
157
|
+
/** Pull zero into the y domain. What a bar chart needs; a line usually does not. */
|
|
158
|
+
readonly includeZero?: boolean;
|
|
159
|
+
/** Round the y domain out to tick values. */
|
|
160
|
+
readonly niceDomain?: boolean;
|
|
161
|
+
|
|
162
|
+
/** Defaults to `time` when the x field holds dates, `linear` otherwise. */
|
|
163
|
+
readonly xScaleType?: ScaleType;
|
|
164
|
+
readonly yScaleType?: ScaleType;
|
|
165
|
+
/** The interpolator every mark uses unless it overrides it. */
|
|
166
|
+
readonly curve?: CurveType;
|
|
167
|
+
readonly animation?: ChartAnimation;
|
|
168
|
+
|
|
169
|
+
readonly xAxis?: AxisOptions;
|
|
170
|
+
readonly yAxis?: AxisOptions;
|
|
171
|
+
readonly font?: SkFont | null;
|
|
172
|
+
|
|
173
|
+
/** From `useChartScrub`. Without it the chart takes no touches. */
|
|
174
|
+
readonly scrub?: ChartScrubState;
|
|
175
|
+
readonly scrubConfig?: ScrubConfig;
|
|
176
|
+
|
|
177
|
+
readonly onBoundsChange?: (bounds: ChartBounds) => void;
|
|
178
|
+
|
|
179
|
+
/** Skia children, or a function of the resolved chart. */
|
|
180
|
+
readonly children?: ReactNode | ((args: CartesianRenderArgs<Row, YK>) => ReactNode);
|
|
181
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import type { ChartSize } from "../../core/chart.types";
|
|
4
|
+
|
|
5
|
+
export const ZERO_SIZE: ChartSize = { width: 0, height: 0 };
|
|
6
|
+
|
|
7
|
+
export type CanvasSize = {
|
|
8
|
+
readonly size: ChartSize;
|
|
9
|
+
readonly onLayout: (event: LayoutChangeEvent) => void;
|
|
10
|
+
/** False until the first layout pass. Nothing should draw before it. */
|
|
11
|
+
readonly measured: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The chart's measured size.
|
|
16
|
+
*
|
|
17
|
+
* Measured on the wrapping React Native view rather than on the canvas.
|
|
18
|
+
* Skia's `<Canvas onLayout>` is deprecated and does not fire on Fabric at all,
|
|
19
|
+
* and its `onSize` alternative reports into a shared value — useful for a
|
|
20
|
+
* worklet, useless for building scales, which happens on the JavaScript
|
|
21
|
+
* thread. A plain `View` reports where the work actually is.
|
|
22
|
+
*
|
|
23
|
+
* The size is held in state, so a resize re-renders and every scale is rebuilt
|
|
24
|
+
* against the new rect. Rounding to whole points keeps a fractional layout
|
|
25
|
+
* from re-rendering the chart on every frame of a parent animation.
|
|
26
|
+
*/
|
|
27
|
+
export function useCanvasSize(): CanvasSize {
|
|
28
|
+
const [size, setSize] = useState<ChartSize>(ZERO_SIZE);
|
|
29
|
+
|
|
30
|
+
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
31
|
+
const { width, height } = event.nativeEvent.layout;
|
|
32
|
+
const next = { width: Math.round(width), height: Math.round(height) };
|
|
33
|
+
setSize((current) => (current.width === next.width && current.height === next.height ? current : next));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
return { size, onLayout, measured: size.width > 0 && size.height > 0 };
|
|
37
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { SkFont } from "@shopify/react-native-skia";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import type { ChartAnimation } from "../../animation/animation.types";
|
|
4
|
+
import { DEFAULT_CHART_ANIMATION } from "../../animation/animation.types";
|
|
5
|
+
import type { ChartRow, ChartSize } from "../../core/chart.types";
|
|
6
|
+
import type { CurveType } from "../../core/curve/curves";
|
|
7
|
+
import { getChartBounds, hasArea } from "../../core/geometry/chart-bounds";
|
|
8
|
+
import {
|
|
9
|
+
type AxisPlan,
|
|
10
|
+
axisRanges,
|
|
11
|
+
pickAxisRoles,
|
|
12
|
+
placeAxisRoles,
|
|
13
|
+
planAxis,
|
|
14
|
+
resolveChartFrame,
|
|
15
|
+
} from "../../core/geometry/chart-layout";
|
|
16
|
+
import { type DomainPadding, resolveDomain, resolveDomainPadding } from "../../core/geometry/domain";
|
|
17
|
+
import { collectStackedYValues, stackSeries } from "../../core/geometry/stack";
|
|
18
|
+
import { resolveStep } from "../../core/geometry/step";
|
|
19
|
+
import { collectYValues, transformInputData } from "../../core/geometry/transform-input-data";
|
|
20
|
+
import { resolveXValues } from "../../core/geometry/x-values";
|
|
21
|
+
import { scaleValue } from "../../core/scale/scale";
|
|
22
|
+
import type { DomainTuple, RangeTuple, ScaleType } from "../../core/scale/scale.types";
|
|
23
|
+
import { categoricalTickFormat, defaultTickFormat } from "../../core/text/format-tick";
|
|
24
|
+
import { DEFAULT_TICK_COUNT } from "../../core/ticks/tick-count";
|
|
25
|
+
import type { SidedNumber } from "../../core/util/sided-number";
|
|
26
|
+
import type { ChartScrubState } from "../../gesture/gesture.types";
|
|
27
|
+
import { fontMetrics, measureLabelWidths } from "../../skia/font";
|
|
28
|
+
import type { AxisLabelFormatter, AxisOptions, ChartContextValue, ChartOrientation } from "../cartesian-chart.types";
|
|
29
|
+
|
|
30
|
+
export type UseChartModelOptions = {
|
|
31
|
+
readonly data: readonly ChartRow[];
|
|
32
|
+
readonly xKey: string;
|
|
33
|
+
readonly yKeys: readonly string[];
|
|
34
|
+
readonly canvas: ChartSize;
|
|
35
|
+
readonly padding?: SidedNumber;
|
|
36
|
+
readonly domain?: {
|
|
37
|
+
readonly x?: readonly [number | undefined, number | undefined];
|
|
38
|
+
readonly y?: readonly [number | undefined, number | undefined];
|
|
39
|
+
};
|
|
40
|
+
readonly domainPadding?: DomainPadding;
|
|
41
|
+
readonly stackKeys?: readonly string[];
|
|
42
|
+
readonly orientation?: ChartOrientation;
|
|
43
|
+
readonly includeZero?: boolean;
|
|
44
|
+
readonly niceDomain?: boolean;
|
|
45
|
+
readonly xScaleType?: ScaleType;
|
|
46
|
+
readonly yScaleType?: ScaleType;
|
|
47
|
+
readonly curve?: CurveType;
|
|
48
|
+
readonly animation?: ChartAnimation;
|
|
49
|
+
readonly xAxis?: AxisOptions;
|
|
50
|
+
readonly yAxis?: AxisOptions;
|
|
51
|
+
readonly font?: SkFont | null;
|
|
52
|
+
readonly scrub?: ChartScrubState;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The whole chart, resolved: scales, ticks, plot rect and plotted points.
|
|
57
|
+
*
|
|
58
|
+
* The layout is two passes and the maths for both lives in
|
|
59
|
+
* `core/geometry/chart-layout.ts` — `planAxis` decides tick values against the
|
|
60
|
+
* full canvas, this hook measures those labels with the font, and
|
|
61
|
+
* `resolveChartFrame` turns the measurements into a plot rect and moves the
|
|
62
|
+
* scales into it. The tick values never change between the passes, which is
|
|
63
|
+
* what stops the measure/gutter cycle from iterating.
|
|
64
|
+
*
|
|
65
|
+
* Only the measuring is here. Everything either side of it is pure and tested.
|
|
66
|
+
*/
|
|
67
|
+
export function useChartModel(options: UseChartModelOptions): ChartContextValue {
|
|
68
|
+
const {
|
|
69
|
+
data,
|
|
70
|
+
xKey,
|
|
71
|
+
yKeys,
|
|
72
|
+
canvas,
|
|
73
|
+
padding,
|
|
74
|
+
domain,
|
|
75
|
+
domainPadding,
|
|
76
|
+
stackKeys,
|
|
77
|
+
orientation = "vertical",
|
|
78
|
+
includeZero,
|
|
79
|
+
niceDomain,
|
|
80
|
+
xScaleType,
|
|
81
|
+
yScaleType,
|
|
82
|
+
curve = "linear",
|
|
83
|
+
animation = DEFAULT_CHART_ANIMATION,
|
|
84
|
+
xAxis,
|
|
85
|
+
yAxis,
|
|
86
|
+
font = null,
|
|
87
|
+
scrub,
|
|
88
|
+
} = options;
|
|
89
|
+
|
|
90
|
+
const yKeySignature = yKeys.join(" ");
|
|
91
|
+
const stackSignature = (stackKeys ?? []).join(" ");
|
|
92
|
+
|
|
93
|
+
// `yKeys` and `stackKeys` enter the dependency list as joined signatures, so
|
|
94
|
+
// a fresh array of the same keys does not rebuild every scale and path on
|
|
95
|
+
// each render.
|
|
96
|
+
return useMemo<ChartContextValue>(() => {
|
|
97
|
+
const keys = splitSignature(yKeySignature);
|
|
98
|
+
const stackedKeys = splitSignature(stackSignature);
|
|
99
|
+
const unstackedKeys = keys.filter((key) => !stackedKeys.includes(key));
|
|
100
|
+
const { values: xValues, isCategorical, raw } = resolveXValues(data, xKey);
|
|
101
|
+
const pad = resolveDomainPadding(domainPadding);
|
|
102
|
+
const xStepValue = resolveStep(xValues);
|
|
103
|
+
|
|
104
|
+
const xKind = resolveXKind(xScaleType, isCategorical, raw);
|
|
105
|
+
const yKind = yScaleType ?? "linear";
|
|
106
|
+
const shown = pickAxisRoles(xAxis?.show ?? true, yAxis?.show ?? true, orientation);
|
|
107
|
+
|
|
108
|
+
const xDomain = resolveDomain({ values: xValues, domain: domain?.x, absolutePadding: pad.x * xStepValue });
|
|
109
|
+
const yDomain = resolveDomain({
|
|
110
|
+
values: [...collectYValues(data, unstackedKeys), ...collectStackedYValues(data, stackedKeys)],
|
|
111
|
+
domain: domain?.y,
|
|
112
|
+
padding: pad.y,
|
|
113
|
+
includeZero,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The category axis and the value axis are planned by role; the
|
|
117
|
+
// orientation decides which canvas axis each lands on, and from the
|
|
118
|
+
// frame onward `x` and `y` name canvas axes only.
|
|
119
|
+
const outer = getChartBounds(canvas, padding);
|
|
120
|
+
const ranges = axisRanges(outer, orientation);
|
|
121
|
+
const axes = pickAxisRoles(xAxis, yAxis, orientation);
|
|
122
|
+
const plan = placeAxisRoles(
|
|
123
|
+
planCategoryAxis({
|
|
124
|
+
domain: xDomain,
|
|
125
|
+
kind: xKind,
|
|
126
|
+
range: ranges.category,
|
|
127
|
+
axis: axes.category,
|
|
128
|
+
show: shown.category,
|
|
129
|
+
isCategorical,
|
|
130
|
+
xValues,
|
|
131
|
+
raw,
|
|
132
|
+
}),
|
|
133
|
+
planAxis({
|
|
134
|
+
domain: yDomain,
|
|
135
|
+
kind: yKind,
|
|
136
|
+
range: ranges.value,
|
|
137
|
+
tickCount: axes.value?.tickCount ?? DEFAULT_TICK_COUNT,
|
|
138
|
+
tickValues: axes.value?.tickValues,
|
|
139
|
+
format: axes.value?.formatLabel ?? defaultTickFormat(yKind, 0),
|
|
140
|
+
show: shown.value,
|
|
141
|
+
nice: niceDomain,
|
|
142
|
+
}),
|
|
143
|
+
orientation
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const metrics = fontMetrics(font);
|
|
147
|
+
const lineHeight = metrics.ascent + metrics.descent;
|
|
148
|
+
const frame = resolveChartFrame({
|
|
149
|
+
canvas,
|
|
150
|
+
padding,
|
|
151
|
+
plan,
|
|
152
|
+
xLabelWidths: measureLabelWidths(font, plan.x.labels),
|
|
153
|
+
yLabelWidths: measureLabelWidths(font, plan.y.labels),
|
|
154
|
+
lineHeight,
|
|
155
|
+
showXAxis: xAxis?.show ?? true,
|
|
156
|
+
showYAxis: yAxis?.show ?? true,
|
|
157
|
+
orientation,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const { points, xPositions } = transformInputData({
|
|
161
|
+
data,
|
|
162
|
+
yKeys: keys,
|
|
163
|
+
xValues,
|
|
164
|
+
xScale: frame.xScale,
|
|
165
|
+
yScale: frame.yScale,
|
|
166
|
+
orientation,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const { category: categoryScale, value: valueScale } = pickAxisRoles(frame.xScale, frame.yScale, orientation);
|
|
170
|
+
const x0 = categoryScale.domain[0];
|
|
171
|
+
const xStep = {
|
|
172
|
+
value: xStepValue,
|
|
173
|
+
px: Math.abs(scaleValue(categoryScale, x0 + xStepValue) - scaleValue(categoryScale, x0)),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const stacked = stackSeries({ data, keys: stackedKeys, xValues, xPositions, yScale: valueScale, orientation });
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
points,
|
|
180
|
+
xPositions,
|
|
181
|
+
xStep,
|
|
182
|
+
stacked,
|
|
183
|
+
orientation,
|
|
184
|
+
bounds: frame.bounds,
|
|
185
|
+
canvas,
|
|
186
|
+
xScale: frame.xScale,
|
|
187
|
+
yScale: frame.yScale,
|
|
188
|
+
xTicks: frame.xTicks,
|
|
189
|
+
yTicks: frame.yTicks,
|
|
190
|
+
xLabels: plan.x.labels,
|
|
191
|
+
yLabels: plan.y.labels,
|
|
192
|
+
yKeys: keys,
|
|
193
|
+
font,
|
|
194
|
+
lineHeight,
|
|
195
|
+
fontMetrics: metrics,
|
|
196
|
+
curve,
|
|
197
|
+
animation,
|
|
198
|
+
scrub: scrub ?? null,
|
|
199
|
+
isCategorical,
|
|
200
|
+
ready: hasArea(frame.bounds) && data.length > 0,
|
|
201
|
+
};
|
|
202
|
+
}, [
|
|
203
|
+
data,
|
|
204
|
+
xKey,
|
|
205
|
+
yKeySignature,
|
|
206
|
+
canvas,
|
|
207
|
+
padding,
|
|
208
|
+
domain,
|
|
209
|
+
domainPadding,
|
|
210
|
+
stackSignature,
|
|
211
|
+
orientation,
|
|
212
|
+
includeZero,
|
|
213
|
+
niceDomain,
|
|
214
|
+
xScaleType,
|
|
215
|
+
yScaleType,
|
|
216
|
+
curve,
|
|
217
|
+
animation,
|
|
218
|
+
xAxis,
|
|
219
|
+
yAxis,
|
|
220
|
+
font,
|
|
221
|
+
scrub,
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type PlanCategoryAxisOptions = {
|
|
226
|
+
readonly domain: DomainTuple;
|
|
227
|
+
readonly kind: ScaleType;
|
|
228
|
+
readonly range: RangeTuple;
|
|
229
|
+
readonly axis: AxisOptions | undefined;
|
|
230
|
+
readonly show: boolean;
|
|
231
|
+
readonly isCategorical: boolean;
|
|
232
|
+
readonly xValues: readonly number[];
|
|
233
|
+
readonly raw: readonly unknown[];
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The category axis' plan. A categorical axis ticks on its own data, not on
|
|
238
|
+
* round numbers — index 2.5 names no month — and prints the original label.
|
|
239
|
+
*/
|
|
240
|
+
function planCategoryAxis(options: PlanCategoryAxisOptions): AxisPlan {
|
|
241
|
+
const { domain, kind, range, axis, show, isCategorical, xValues, raw } = options;
|
|
242
|
+
return planAxis({
|
|
243
|
+
domain,
|
|
244
|
+
kind,
|
|
245
|
+
range,
|
|
246
|
+
tickCount: axis?.tickCount ?? DEFAULT_TICK_COUNT,
|
|
247
|
+
tickValues: axis?.tickValues ?? (isCategorical ? xValues : undefined),
|
|
248
|
+
format: axis?.formatLabel ?? categoricalOrValueFormat(isCategorical, raw, kind, domain),
|
|
249
|
+
show,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A joined key signature back into keys; the empty signature is no keys. */
|
|
254
|
+
function splitSignature(signature: string): string[] {
|
|
255
|
+
return signature === "" ? [] : signature.split(" ");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A date-valued x field gets a time scale unless the caller says otherwise. */
|
|
259
|
+
function resolveXKind(explicit: ScaleType | undefined, isCategorical: boolean, raw: readonly unknown[]): ScaleType {
|
|
260
|
+
if (explicit !== undefined) return explicit;
|
|
261
|
+
if (isCategorical) return "linear";
|
|
262
|
+
return raw.some((value) => value instanceof Date) ? "time" : "linear";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A categorical axis prints the original label; anything else prints its value. */
|
|
266
|
+
function categoricalOrValueFormat(
|
|
267
|
+
isCategorical: boolean,
|
|
268
|
+
raw: readonly unknown[],
|
|
269
|
+
kind: ScaleType,
|
|
270
|
+
domain: readonly [number, number]
|
|
271
|
+
): AxisLabelFormatter {
|
|
272
|
+
if (isCategorical) {
|
|
273
|
+
const label = categoricalTickFormat(raw);
|
|
274
|
+
return (value) => label(value);
|
|
275
|
+
}
|
|
276
|
+
const format = defaultTickFormat(kind, Math.abs(domain[1] - domain[0]));
|
|
277
|
+
return (value) => format(value);
|
|
278
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export { ChartXAxis, type ChartXAxisProps } from "./axes/chart-x-axis";
|
|
2
|
+
export { ChartYAxis, type ChartYAxisProps } from "./axes/chart-y-axis";
|
|
3
|
+
export { CartesianChart } from "./cartesian-chart";
|
|
4
|
+
export { CartesianChartContext, useChartContext, useOptionalChartContext } from "./cartesian-chart.context";
|
|
5
|
+
export type {
|
|
6
|
+
AxisLabelFormatter,
|
|
7
|
+
AxisOptions,
|
|
8
|
+
CartesianChartProps,
|
|
9
|
+
CartesianRenderArgs,
|
|
10
|
+
ChartContextValue,
|
|
11
|
+
ChartOrientation,
|
|
12
|
+
ChartStep,
|
|
13
|
+
XKeyOf,
|
|
14
|
+
YKeyOf,
|
|
15
|
+
} from "./cartesian-chart.types";
|
|
16
|
+
export { type CanvasSize, useCanvasSize } from "./hooks/use-canvas-size";
|
|
17
|
+
export { type UseChartModelOptions, useChartModel } from "./hooks/use-chart-model";
|
|
18
|
+
export { ChartArea, type ChartAreaProps } from "./marks/chart-area";
|
|
19
|
+
export { ChartBar, type ChartBarLabels, type ChartBarProps } from "./marks/chart-bar";
|
|
20
|
+
export { ChartBarGroup, type ChartBarGroupProps } from "./marks/chart-bar-group";
|
|
21
|
+
export {
|
|
22
|
+
ChartBarStack,
|
|
23
|
+
type ChartBarStackProps,
|
|
24
|
+
type ChartBarStackSegmentInfo,
|
|
25
|
+
type ChartBarStackSegmentOptions,
|
|
26
|
+
} from "./marks/chart-bar-stack";
|
|
27
|
+
export {
|
|
28
|
+
type ChartCandleColors,
|
|
29
|
+
ChartCandlestick,
|
|
30
|
+
type ChartCandlestickKeys,
|
|
31
|
+
type ChartCandlestickProps,
|
|
32
|
+
} from "./marks/chart-candlestick";
|
|
33
|
+
export {
|
|
34
|
+
ChartCursorDot,
|
|
35
|
+
type ChartCursorDotProps,
|
|
36
|
+
ChartCursorLine,
|
|
37
|
+
type ChartCursorLineProps,
|
|
38
|
+
} from "./marks/chart-cursor";
|
|
39
|
+
export { ChartGrid, type ChartGridProps } from "./marks/chart-grid";
|
|
40
|
+
export { ChartLine, type ChartLineProps } from "./marks/chart-line";
|
|
41
|
+
export { ChartScatter, type ChartScatterProps } from "./marks/chart-scatter";
|