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