@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,88 @@
|
|
|
1
|
+
import type { ChartOrientation, ChartPoint, ChartRow } from "../chart.types";
|
|
2
|
+
import { scaleValue } from "../scale/scale";
|
|
3
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
4
|
+
import { asNumber } from "../util/as-number";
|
|
5
|
+
|
|
6
|
+
export type TransformInputOptions = {
|
|
7
|
+
readonly data: readonly ChartRow[];
|
|
8
|
+
readonly yKeys: readonly string[];
|
|
9
|
+
/** x in domain units, already resolved — see `resolveXValues`. */
|
|
10
|
+
readonly xValues: readonly number[];
|
|
11
|
+
/** The scale on the canvas' x axis — categories when vertical, values when horizontal. */
|
|
12
|
+
readonly xScale: ScaleDescriptor;
|
|
13
|
+
/** The scale on the canvas' y axis. */
|
|
14
|
+
readonly yScale: ScaleDescriptor;
|
|
15
|
+
/** Defaults to `vertical`. */
|
|
16
|
+
readonly orientation?: ChartOrientation;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TransformedData = {
|
|
20
|
+
/** One array per y key, in the order the keys were given. */
|
|
21
|
+
readonly points: Readonly<Record<string, readonly ChartPoint[]>>;
|
|
22
|
+
/**
|
|
23
|
+
* Canvas position of each row's category, shared by every series — the
|
|
24
|
+
* scrub's search space. An x on a vertical chart, a y on a horizontal one;
|
|
25
|
+
* the name is the vertical case, like `xValue`.
|
|
26
|
+
*/
|
|
27
|
+
readonly xPositions: readonly number[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rows into plotted points, one series per y key.
|
|
32
|
+
*
|
|
33
|
+
* A row whose y field is missing, `null` or unparseable becomes a point with
|
|
34
|
+
* `y: null` rather than being dropped. Dropping it would shift every later
|
|
35
|
+
* point one place left against the x positions, and the line would silently
|
|
36
|
+
* draw the wrong shape.
|
|
37
|
+
*/
|
|
38
|
+
export function transformInputData(options: TransformInputOptions): TransformedData {
|
|
39
|
+
const { data, yKeys, xValues, xScale, yScale, orientation = "vertical" } = options;
|
|
40
|
+
const horizontal = orientation === "horizontal";
|
|
41
|
+
const categoryScale = horizontal ? yScale : xScale;
|
|
42
|
+
const valueScale = horizontal ? xScale : yScale;
|
|
43
|
+
|
|
44
|
+
const xPositions = xValues.map((value) => scaleValue(categoryScale, value));
|
|
45
|
+
const points: Record<string, ChartPoint[]> = {};
|
|
46
|
+
|
|
47
|
+
for (const key of yKeys) {
|
|
48
|
+
const series: ChartPoint[] = new Array(data.length);
|
|
49
|
+
for (let index = 0; index < data.length; index += 1) {
|
|
50
|
+
const yValue = asNumber((data[index] as ChartRow)[key]);
|
|
51
|
+
const plottable = Number.isFinite(yValue);
|
|
52
|
+
series[index] = plotPoint(
|
|
53
|
+
horizontal,
|
|
54
|
+
xPositions[index] as number,
|
|
55
|
+
xValues[index] as number,
|
|
56
|
+
plottable ? yValue : null,
|
|
57
|
+
plottable ? scaleValue(valueScale, yValue) : null
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
points[key] = series;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { points, xPositions };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One point, its category and value positions on whichever canvas axes the orientation says. */
|
|
67
|
+
function plotPoint(
|
|
68
|
+
horizontal: boolean,
|
|
69
|
+
category: number,
|
|
70
|
+
xValue: number,
|
|
71
|
+
yValue: number | null,
|
|
72
|
+
valuePosition: number | null
|
|
73
|
+
): ChartPoint {
|
|
74
|
+
if (horizontal) return { x: valuePosition ?? Number.NaN, y: category, xValue, yValue };
|
|
75
|
+
return { x: category, y: valuePosition, xValue, yValue };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every y value across every series, for measuring one shared domain. */
|
|
79
|
+
export function collectYValues(data: readonly ChartRow[], yKeys: readonly string[]): number[] {
|
|
80
|
+
const values: number[] = [];
|
|
81
|
+
for (const row of data) {
|
|
82
|
+
for (const key of yKeys) {
|
|
83
|
+
const value = asNumber(row[key]);
|
|
84
|
+
if (Number.isFinite(value)) values.push(value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return values;
|
|
88
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveXValues } from "./x-values";
|
|
3
|
+
|
|
4
|
+
describe("resolveXValues", () => {
|
|
5
|
+
test("reads a numeric field as itself", () => {
|
|
6
|
+
const resolved = resolveXValues([{ t: 10 }, { t: 20 }], "t");
|
|
7
|
+
expect(resolved.values).toEqual([10, 20]);
|
|
8
|
+
expect(resolved.isCategorical).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("reads a Date field as epoch milliseconds", () => {
|
|
12
|
+
const resolved = resolveXValues([{ t: new Date(1000) }, { t: new Date(2000) }], "t");
|
|
13
|
+
expect(resolved.values).toEqual([1000, 2000]);
|
|
14
|
+
expect(resolved.isCategorical).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("substitutes indices for a label field", () => {
|
|
18
|
+
const resolved = resolveXValues([{ m: "Jan" }, { m: "Feb" }, { m: "Mar" }], "m");
|
|
19
|
+
expect(resolved.values).toEqual([0, 1, 2]);
|
|
20
|
+
expect(resolved.isCategorical).toBe(true);
|
|
21
|
+
expect(resolved.raw).toEqual(["Jan", "Feb", "Mar"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("falls back for the whole series when a single row is unreadable", () => {
|
|
25
|
+
// Mixing measured values with indices breaks the ascending order every
|
|
26
|
+
// binary search here assumes, and a scrub lands on the wrong datum.
|
|
27
|
+
const resolved = resolveXValues([{ t: 10 }, { t: "n/a" }, { t: 30 }], "t");
|
|
28
|
+
expect(resolved.values).toEqual([0, 1, 2]);
|
|
29
|
+
expect(resolved.isCategorical).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("handles an empty dataset", () => {
|
|
33
|
+
const resolved = resolveXValues([], "t");
|
|
34
|
+
expect(resolved.values).toEqual([]);
|
|
35
|
+
expect(resolved.isCategorical).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ChartRow } from "../chart.types";
|
|
2
|
+
import { asNumber } from "../util/as-number";
|
|
3
|
+
|
|
4
|
+
export type ResolvedXValues = {
|
|
5
|
+
readonly values: readonly number[];
|
|
6
|
+
/**
|
|
7
|
+
* Whether the values are positions rather than measurements.
|
|
8
|
+
*
|
|
9
|
+
* True when the field held labels and the index stood in for it. An axis
|
|
10
|
+
* should then print the original label, never the number.
|
|
11
|
+
*/
|
|
12
|
+
readonly isCategorical: boolean;
|
|
13
|
+
/** The original field values, for a label formatter. */
|
|
14
|
+
readonly raw: readonly unknown[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The x values a scale can be built from.
|
|
19
|
+
*
|
|
20
|
+
* A chart's x field is often a month name or a category. When *any* row fails
|
|
21
|
+
* to read as a number the whole series falls back to indices — all of it, not
|
|
22
|
+
* just the bad rows. Mixing the two would break the ascending order every
|
|
23
|
+
* binary search in this package assumes, and a scrub would land on the wrong
|
|
24
|
+
* datum in a way that looks like a rendering bug.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveXValues(data: readonly ChartRow[], xKey: string): ResolvedXValues {
|
|
27
|
+
const raw = data.map((row) => row[xKey]);
|
|
28
|
+
const numeric = raw.map(asNumber);
|
|
29
|
+
const isCategorical = numeric.some((value) => !Number.isFinite(value));
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
values: isCategorical ? raw.map((_, index) => index) : numeric,
|
|
33
|
+
isCategorical,
|
|
34
|
+
raw,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export { chooseMorphStrategy, type MorphStrategy } from "./animation/morph-strategy";
|
|
2
|
+
export { matchPointCounts, resamplePoints } from "./animation/resample-points";
|
|
3
|
+
export type { ChartBounds, ChartOrientation, ChartPoint, ChartRow, ChartSegment, ChartSize } from "./chart.types";
|
|
4
|
+
export { type AreaPathOptions, buildAreaPath } from "./curve/build-area";
|
|
5
|
+
export { buildLinePath, isDrawable, type LinePathOptions } from "./curve/build-line";
|
|
6
|
+
export { APPROXIMATING_CURVES, CURVE_TYPES, CURVES, type CurveType } from "./curve/curves";
|
|
7
|
+
export {
|
|
8
|
+
type Band,
|
|
9
|
+
type GroupLayout,
|
|
10
|
+
type GroupLayoutOptions,
|
|
11
|
+
groupLayout,
|
|
12
|
+
type ResolveBandOptions,
|
|
13
|
+
resolveBand,
|
|
14
|
+
} from "./geometry/band";
|
|
15
|
+
export { categoryDomainCovers } from "./geometry/category-domain";
|
|
16
|
+
export { type ChartGutters, getChartBounds, hasArea, NO_GUTTERS } from "./geometry/chart-bounds";
|
|
17
|
+
export {
|
|
18
|
+
type AxisPlan,
|
|
19
|
+
axisRanges,
|
|
20
|
+
type ChartFrame,
|
|
21
|
+
type ChartPlan,
|
|
22
|
+
type PlanAxisOptions,
|
|
23
|
+
pickAxisRoles,
|
|
24
|
+
placeAxisRoles,
|
|
25
|
+
planAxis,
|
|
26
|
+
type ResolveChartFrameOptions,
|
|
27
|
+
resolveChartFrame,
|
|
28
|
+
} from "./geometry/chart-layout";
|
|
29
|
+
export {
|
|
30
|
+
type DomainPadding,
|
|
31
|
+
EMPTY_DOMAIN,
|
|
32
|
+
type ResolveDomainOptions,
|
|
33
|
+
resolveDomain,
|
|
34
|
+
resolveDomainPadding,
|
|
35
|
+
} from "./geometry/domain";
|
|
36
|
+
export { type GridAxis, type GridSegment, type GridSegmentOptions, gridSegments } from "./geometry/grid-lines";
|
|
37
|
+
export { collectStackedYValues, type StackSeriesOptions, stackSeries } from "./geometry/stack";
|
|
38
|
+
export { resolveStep } from "./geometry/step";
|
|
39
|
+
export {
|
|
40
|
+
collectYValues,
|
|
41
|
+
type TransformedData,
|
|
42
|
+
type TransformInputOptions,
|
|
43
|
+
transformInputData,
|
|
44
|
+
} from "./geometry/transform-input-data";
|
|
45
|
+
export { type ResolvedXValues, resolveXValues } from "./geometry/x-values";
|
|
46
|
+
export { closestIndex } from "./interaction/closest-index";
|
|
47
|
+
export { type CurvePath, type CurveRun, RUN_STRIDE, runSegmentCount, toCurvePath } from "./interaction/path-segments";
|
|
48
|
+
export { getYForX } from "./interaction/y-for-x";
|
|
49
|
+
// polar — pie and donut geometry
|
|
50
|
+
export { ARC_SEGMENTS, buildSliceEdgePath, buildSlicePath } from "./polar/build-slice-path";
|
|
51
|
+
export { sliceLabelPosition } from "./polar/label-position";
|
|
52
|
+
export { collectPieInput, type PieInput } from "./polar/pie-input";
|
|
53
|
+
export type { InnerRadius, PieSliceData, PolarLayout, PolarPoint } from "./polar/polar.types";
|
|
54
|
+
export { normalizeDegrees, polarToCartesian } from "./polar/polar-point";
|
|
55
|
+
export { type ResolvePolarLayoutOptions, resolveInnerRadius, resolvePolarLayout } from "./polar/resolve-layout";
|
|
56
|
+
export { type ResolveSlicesOptions, resolveSlices } from "./polar/resolve-slices";
|
|
57
|
+
export { sliceIndexAt } from "./polar/slice-index-at";
|
|
58
|
+
export { sliceOpacity } from "./polar/slice-opacity";
|
|
59
|
+
export { type MakeScaleOptions, makeScale } from "./scale/make-scale";
|
|
60
|
+
export { invertValue, scaleValue } from "./scale/scale";
|
|
61
|
+
export type { DomainTuple, RangeTuple, ScaleDescriptor, ScaleType } from "./scale/scale.types";
|
|
62
|
+
// shape — bars, scatter and candles, each on a fixed verb sequence
|
|
63
|
+
export {
|
|
64
|
+
type BarPoint,
|
|
65
|
+
type BarRect,
|
|
66
|
+
type BarRectOptions,
|
|
67
|
+
barRects,
|
|
68
|
+
barsPathFromRects,
|
|
69
|
+
buildBarsPath,
|
|
70
|
+
resolveBaseline,
|
|
71
|
+
} from "./shape/build-bars";
|
|
72
|
+
export {
|
|
73
|
+
buildCandlePaths,
|
|
74
|
+
type Candle,
|
|
75
|
+
type CandlePathOptions,
|
|
76
|
+
type CandlePaths,
|
|
77
|
+
candlePoints,
|
|
78
|
+
candleSentiment,
|
|
79
|
+
SENTIMENTS,
|
|
80
|
+
type Sentiment,
|
|
81
|
+
} from "./shape/build-candles";
|
|
82
|
+
export { buildScatterPath, type ScatterPathOptions, type ScatterShape } from "./shape/build-scatter";
|
|
83
|
+
export { type CornerRadii, KAPPA, rectPath } from "./shape/rect-path";
|
|
84
|
+
export { type AxisGutterInput, resolveAxisGutters } from "./text/axis-gutters";
|
|
85
|
+
export { type BarLabelAnchorOptions, type BarLabelPosition, barLabelAnchor } from "./text/bar-label";
|
|
86
|
+
export {
|
|
87
|
+
categoricalTickFormat,
|
|
88
|
+
defaultTickFormat,
|
|
89
|
+
formatDateTick,
|
|
90
|
+
formatNumberTick,
|
|
91
|
+
} from "./text/format-tick";
|
|
92
|
+
export {
|
|
93
|
+
anchorX,
|
|
94
|
+
anchorY,
|
|
95
|
+
type LabelAlignment,
|
|
96
|
+
type LabelMetrics,
|
|
97
|
+
type LabelPosition,
|
|
98
|
+
labelHeight,
|
|
99
|
+
} from "./text/label-anchor";
|
|
100
|
+
export { type BuildTicksOptions, buildTicks } from "./ticks/build-ticks";
|
|
101
|
+
export { linearTicks, logTicks } from "./ticks/linear-ticks";
|
|
102
|
+
export type { ChartTick } from "./ticks/tick.types";
|
|
103
|
+
export { DEFAULT_TICK_COUNT, downsampleTicks, normalizeTickCount } from "./ticks/tick-count";
|
|
104
|
+
export { timeTicks } from "./ticks/time-ticks";
|
|
105
|
+
export { asNumber, isPlottable } from "./util/as-number";
|
|
106
|
+
export { clamp } from "./util/clamp";
|
|
107
|
+
export { extent, unionExtents } from "./util/extent";
|
|
108
|
+
export { readAt } from "./util/read-at";
|
|
109
|
+
export { type Side, type SidedNumber, sidesOf, valueFromSidedNumber } from "./util/sided-number";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { closestIndex } from "./closest-index";
|
|
3
|
+
|
|
4
|
+
const xs = [0, 10, 20, 30, 40];
|
|
5
|
+
|
|
6
|
+
describe("closestIndex", () => {
|
|
7
|
+
test("finds an exact hit", () => {
|
|
8
|
+
expect(closestIndex(xs, 20)).toBe(2);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("picks the nearer neighbour", () => {
|
|
12
|
+
expect(closestIndex(xs, 21)).toBe(2);
|
|
13
|
+
expect(closestIndex(xs, 29)).toBe(3);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("clamps outside the range", () => {
|
|
17
|
+
expect(closestIndex(xs, -100)).toBe(0);
|
|
18
|
+
expect(closestIndex(xs, 1000)).toBe(4);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("degenerates safely", () => {
|
|
22
|
+
expect(closestIndex([], 5)).toBe(-1);
|
|
23
|
+
expect(closestIndex([7], 1000)).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("agrees with a linear scan over a large series", () => {
|
|
27
|
+
const many = Array.from({ length: 500 }, (_, index) => index * 3);
|
|
28
|
+
for (const probe of [0, 1, 2, 700, 748, 749.9, 1497]) {
|
|
29
|
+
let best = 0;
|
|
30
|
+
for (let index = 1; index < many.length; index += 1) {
|
|
31
|
+
if (Math.abs((many[index] as number) - probe) < Math.abs((many[best] as number) - probe)) best = index;
|
|
32
|
+
}
|
|
33
|
+
expect(closestIndex(many, probe)).toBe(best);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index of the value in `xs` nearest to `x`.
|
|
3
|
+
*
|
|
4
|
+
* `xs` must ascend — a chart's x values do, because the scales are built from
|
|
5
|
+
* their extent and the data is plotted in order.
|
|
6
|
+
*
|
|
7
|
+
* Flat worklet: calls nothing, closes over nothing. It runs on every frame of
|
|
8
|
+
* a scrub, which is the reason it is a binary search and not a linear scan.
|
|
9
|
+
*/
|
|
10
|
+
export function closestIndex(xs: readonly number[], x: number): number {
|
|
11
|
+
"worklet";
|
|
12
|
+
const count = xs.length;
|
|
13
|
+
if (count === 0) return -1;
|
|
14
|
+
if (count === 1) return 0;
|
|
15
|
+
|
|
16
|
+
let lo = 0;
|
|
17
|
+
let hi = count - 1;
|
|
18
|
+
while (hi - lo > 1) {
|
|
19
|
+
const mid = (lo + hi) >> 1;
|
|
20
|
+
if ((xs[mid] as number) <= x) lo = mid;
|
|
21
|
+
else hi = mid;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const loDistance = Math.abs((xs[lo] as number) - x);
|
|
25
|
+
const hiDistance = Math.abs((xs[hi] as number) - x);
|
|
26
|
+
return hiDistance < loDistance ? hi : lo;
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildLinePath } from "../curve/build-line";
|
|
4
|
+
import { RUN_STRIDE, runSegmentCount, toCurvePath } from "./path-segments";
|
|
5
|
+
|
|
6
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
7
|
+
|
|
8
|
+
describe("toCurvePath", () => {
|
|
9
|
+
test("returns nothing for an empty path", () => {
|
|
10
|
+
expect(toCurvePath("")).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("normalises a straight line into a cubic on the same line", () => {
|
|
14
|
+
const path = toCurvePath("M0,0L30,30");
|
|
15
|
+
expect(path).toHaveLength(1);
|
|
16
|
+
// Control points at the third and two-third marks: still a straight line.
|
|
17
|
+
expect(path[0]).toEqual([0, 0, 10, 10, 20, 20, 30, 30]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("keeps a cubic's own control points", () => {
|
|
21
|
+
expect(toCurvePath("M0,0C1,2,3,4,5,6")[0]).toEqual([0, 0, 1, 2, 3, 4, 5, 6]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("gives every segment the same stride whatever the curve was", () => {
|
|
25
|
+
// The reason the solver needs no branch on verb.
|
|
26
|
+
const points = [at(0, 5), at(10, 8), at(20, 3), at(30, 9)];
|
|
27
|
+
for (const curve of ["linear", "monotone", "step", "natural"] as const) {
|
|
28
|
+
for (const run of toCurvePath(buildLinePath(points, { curve }))) {
|
|
29
|
+
expect((run.length - 2) % RUN_STRIDE).toBe(0);
|
|
30
|
+
expect(runSegmentCount(run)).toBeGreaterThan(0);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("splits a gapped series into one run per unbroken stretch", () => {
|
|
36
|
+
const path = toCurvePath(buildLinePath([at(0, 1), at(10, 2), at(20, null), at(30, 4), at(40, 5)]));
|
|
37
|
+
expect(path).toHaveLength(2);
|
|
38
|
+
expect(path[0]?.[0]).toBe(0);
|
|
39
|
+
expect(path[1]?.[0]).toBe(30);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("drops a single-point subpath, which has no segment to solve", () => {
|
|
43
|
+
expect(toCurvePath("M5,5Z")).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("handles implicit repeated coordinates after one command", () => {
|
|
47
|
+
expect(toCurvePath("M0,0L10,10L20,20")).toHaveLength(1);
|
|
48
|
+
expect(runSegmentCount(toCurvePath("M0,0L10,10L20,20")[0] as number[])).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reads scientific notation and negative numbers", () => {
|
|
52
|
+
const path = toCurvePath("M-1e1,-5L10,5");
|
|
53
|
+
expect(path[0]?.[0]).toBe(-10);
|
|
54
|
+
expect(path[0]?.[1]).toBe(-5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("ignores a verb it does not know rather than throwing", () => {
|
|
58
|
+
expect(() => toCurvePath("M0,0Q5,5,10,10L20,20")).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One contiguous run of cubic segments.
|
|
3
|
+
*
|
|
4
|
+
* Flat numbers, `[x0,y0, c1x,c1y,c2x,c2y,x1,y1, c1x,c1y,c2x,c2y,x2,y2, …]` —
|
|
5
|
+
* two for the start point, then six per segment. Segment `i` starts where
|
|
6
|
+
* segment `i-1` ended, so the array is `2 + 6n` long.
|
|
7
|
+
*
|
|
8
|
+
* Flat because it crosses into a shared value and is read every frame of a
|
|
9
|
+
* scrub. Two hundred `{x, y}` objects is two hundred serialisations where a
|
|
10
|
+
* number array is one.
|
|
11
|
+
*/
|
|
12
|
+
export type CurveRun = readonly number[];
|
|
13
|
+
|
|
14
|
+
/** A whole path: one run per subpath. A series with gaps has more than one. */
|
|
15
|
+
export type CurvePath = readonly CurveRun[];
|
|
16
|
+
|
|
17
|
+
/** Numbers per segment after the leading start point. */
|
|
18
|
+
export const RUN_STRIDE = 6;
|
|
19
|
+
|
|
20
|
+
/** How many cubic segments a run holds. */
|
|
21
|
+
export function runSegmentCount(run: CurveRun): number {
|
|
22
|
+
return run.length < 2 + RUN_STRIDE ? 0 : (run.length - 2) / RUN_STRIDE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Path verbs, their operands, and the pen position between them. */
|
|
26
|
+
type Builder = {
|
|
27
|
+
readonly runs: number[][];
|
|
28
|
+
run: number[] | null;
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const TOKEN = /([MLCZmlczHhVv])|(-?\d*\.?\d+(?:[eE][-+]?\d+)?)/g;
|
|
34
|
+
const THIRD = 1 / 3;
|
|
35
|
+
|
|
36
|
+
/** Appends a cubic, opening a run if the pen has just moved. */
|
|
37
|
+
function cubicTo(builder: Builder, c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void {
|
|
38
|
+
if (builder.run === null) {
|
|
39
|
+
builder.run = [builder.x, builder.y];
|
|
40
|
+
builder.runs.push(builder.run);
|
|
41
|
+
}
|
|
42
|
+
builder.run.push(c1x, c1y, c2x, c2y, x, y);
|
|
43
|
+
builder.x = x;
|
|
44
|
+
builder.y = y;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Appends a straight segment **as a cubic**, control points at the third and
|
|
49
|
+
* two-third marks — the same line, in the one shape the solver understands.
|
|
50
|
+
*/
|
|
51
|
+
function lineTo(builder: Builder, x: number, y: number): void {
|
|
52
|
+
const dx = x - builder.x;
|
|
53
|
+
const dy = y - builder.y;
|
|
54
|
+
cubicTo(
|
|
55
|
+
builder,
|
|
56
|
+
builder.x + dx * THIRD,
|
|
57
|
+
builder.y + dy * THIRD,
|
|
58
|
+
builder.x + dx * 2 * THIRD,
|
|
59
|
+
builder.y + dy * 2 * THIRD,
|
|
60
|
+
x,
|
|
61
|
+
y
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function moveTo(builder: Builder, values: readonly number[]): void {
|
|
66
|
+
if (values.length < 2) return;
|
|
67
|
+
builder.x = values[0] as number;
|
|
68
|
+
builder.y = values[1] as number;
|
|
69
|
+
builder.run = null;
|
|
70
|
+
for (let index = 2; index + 1 < values.length; index += 2) {
|
|
71
|
+
lineTo(builder, values[index] as number, values[index + 1] as number);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function curveTo(builder: Builder, values: readonly number[]): void {
|
|
76
|
+
for (let index = 0; index + 5 < values.length; index += RUN_STRIDE) {
|
|
77
|
+
cubicTo(
|
|
78
|
+
builder,
|
|
79
|
+
values[index] as number,
|
|
80
|
+
values[index + 1] as number,
|
|
81
|
+
values[index + 2] as number,
|
|
82
|
+
values[index + 3] as number,
|
|
83
|
+
values[index + 4] as number,
|
|
84
|
+
values[index + 5] as number
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyVerb(builder: Builder, verb: string, values: readonly number[]): void {
|
|
90
|
+
switch (verb) {
|
|
91
|
+
case "M":
|
|
92
|
+
moveTo(builder, values);
|
|
93
|
+
return;
|
|
94
|
+
case "C":
|
|
95
|
+
curveTo(builder, values);
|
|
96
|
+
return;
|
|
97
|
+
case "L":
|
|
98
|
+
for (let index = 0; index + 1 < values.length; index += 2) {
|
|
99
|
+
lineTo(builder, values[index] as number, values[index + 1] as number);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
case "H":
|
|
103
|
+
for (const value of values) lineTo(builder, value, builder.y);
|
|
104
|
+
return;
|
|
105
|
+
case "V":
|
|
106
|
+
for (const value of values) lineTo(builder, builder.x, value);
|
|
107
|
+
return;
|
|
108
|
+
default:
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Re-encodes an SVG path string as runs of cubics.
|
|
115
|
+
*
|
|
116
|
+
* Two jobs, and the second is the interesting one.
|
|
117
|
+
*
|
|
118
|
+
* It **normalises every segment to a cubic**, so the scrub solver has exactly
|
|
119
|
+
* one segment kind to handle however the curve was drawn — no branch on verb,
|
|
120
|
+
* no second code path to get wrong. d3 emits `L` for a linear curve and `C`
|
|
121
|
+
* for a monotone one; downstream, they are indistinguishable.
|
|
122
|
+
*
|
|
123
|
+
* And it **splits on subpath**, so a series broken by a gap yields one run per
|
|
124
|
+
* unbroken stretch and the solver reports no value inside the gap rather than
|
|
125
|
+
* interpolating a line nobody measured.
|
|
126
|
+
*
|
|
127
|
+
* Only the verbs d3's `line` and `area` emit are handled — `M`, `L`, `C`, `Z`,
|
|
128
|
+
* plus `H`/`V` defensively. Anything else is ignored rather than throwing: a
|
|
129
|
+
* path this cannot fully read should cost a scrub, not the whole chart.
|
|
130
|
+
*/
|
|
131
|
+
export function toCurvePath(path: string): CurvePath {
|
|
132
|
+
if (path === "") return [];
|
|
133
|
+
|
|
134
|
+
const builder: Builder = { runs: [], run: null, x: 0, y: 0 };
|
|
135
|
+
let verb = "";
|
|
136
|
+
let operands: number[] = [];
|
|
137
|
+
|
|
138
|
+
TOKEN.lastIndex = 0;
|
|
139
|
+
for (let match = TOKEN.exec(path); match !== null; match = TOKEN.exec(path)) {
|
|
140
|
+
if (match[1] === undefined) {
|
|
141
|
+
operands.push(Number(match[2]));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (verb !== "") applyVerb(builder, verb, operands);
|
|
145
|
+
operands = [];
|
|
146
|
+
verb = match[1].toUpperCase();
|
|
147
|
+
if (verb === "Z") {
|
|
148
|
+
verb = "";
|
|
149
|
+
builder.run = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (verb !== "") applyVerb(builder, verb, operands);
|
|
153
|
+
|
|
154
|
+
return builder.runs.filter((run) => run.length >= 2 + RUN_STRIDE);
|
|
155
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildLinePath } from "../curve/build-line";
|
|
4
|
+
import { toCurvePath } from "./path-segments";
|
|
5
|
+
import { getYForX } from "./y-for-x";
|
|
6
|
+
|
|
7
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The analytic cubic root, as an oracle.
|
|
11
|
+
*
|
|
12
|
+
* This is the implementation `getYForX` deliberately does not ship — it is
|
|
13
|
+
* three functions calling each other, which the flat-worklet rule forbids. It
|
|
14
|
+
* lives here so bisection has something exact to be checked against.
|
|
15
|
+
*/
|
|
16
|
+
function cuberoot(x: number): number {
|
|
17
|
+
const y = Math.abs(x) ** (1 / 3);
|
|
18
|
+
return x < 0 ? -y : y;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function solveCubic(a: number, b: number, c: number, d: number): number[] {
|
|
22
|
+
if (Math.abs(a) < 1e-8) {
|
|
23
|
+
if (Math.abs(b) < 1e-8) return Math.abs(c) < 1e-8 ? [] : [-d / c];
|
|
24
|
+
const disc = c * c - 4 * b * d;
|
|
25
|
+
if (disc < 0) return [];
|
|
26
|
+
return [(-c + Math.sqrt(disc)) / (2 * b), (-c - Math.sqrt(disc)) / (2 * b)];
|
|
27
|
+
}
|
|
28
|
+
const p = (3 * a * c - b * b) / (3 * a * a);
|
|
29
|
+
const q = (2 * b * b * b - 9 * a * b * c + 27 * a * a * d) / (27 * a * a * a);
|
|
30
|
+
let roots: number[];
|
|
31
|
+
if (Math.abs(p) < 1e-12) roots = [cuberoot(-q)];
|
|
32
|
+
else if (Math.abs(q) < 1e-12) roots = p < 0 ? [0, Math.sqrt(-p), -Math.sqrt(-p)] : [0];
|
|
33
|
+
else {
|
|
34
|
+
const disc = (q * q) / 4 + (p * p * p) / 27;
|
|
35
|
+
if (Math.abs(disc) < 1e-12) roots = [(-1.5 * q) / p, (3 * q) / p];
|
|
36
|
+
else if (disc > 0) {
|
|
37
|
+
const u = cuberoot(-q / 2 - Math.sqrt(disc));
|
|
38
|
+
roots = [u - p / (3 * u)];
|
|
39
|
+
} else {
|
|
40
|
+
const u = 2 * Math.sqrt(-p / 3);
|
|
41
|
+
const t = Math.acos(((3 * q) / p / u) * 1) / 3;
|
|
42
|
+
const k = (2 * Math.PI) / 3;
|
|
43
|
+
roots = [u * Math.cos(t), u * Math.cos(t - k), u * Math.cos(t - 2 * k)];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return roots.map((root) => root - b / (3 * a));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bezier(p0: number, c1: number, c2: number, p1: number, t: number): number {
|
|
50
|
+
const mt = 1 - t;
|
|
51
|
+
return mt * mt * mt * p0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * p1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `y` at `x` on one cubic, solved exactly. */
|
|
55
|
+
function oracleY(run: readonly number[], x: number): number {
|
|
56
|
+
const [p0x, p0y, c1x, c1y, c2x, c2y, p1x, p1y] = run as number[];
|
|
57
|
+
const a = -(p0x as number) + 3 * (c1x as number) - 3 * (c2x as number) + (p1x as number);
|
|
58
|
+
const b = 3 * (p0x as number) - 6 * (c1x as number) + 3 * (c2x as number);
|
|
59
|
+
const c = -3 * (p0x as number) + 3 * (c1x as number);
|
|
60
|
+
const d = (p0x as number) - x;
|
|
61
|
+
for (const root of solveCubic(a, b, c, d)) {
|
|
62
|
+
if (root >= -1e-9 && root <= 1 + 1e-9) {
|
|
63
|
+
return bezier(p0y as number, c1y as number, c2y as number, p1y as number, Math.min(1, Math.max(0, root)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return Number.NaN;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("getYForX", () => {
|
|
70
|
+
test("rides a straight line exactly", () => {
|
|
71
|
+
const path = toCurvePath(buildLinePath([at(0, 0), at(100, 100)]));
|
|
72
|
+
expect(getYForX(path, 25)).toBeCloseTo(25, 6);
|
|
73
|
+
expect(getYForX(path, 73.5)).toBeCloseTo(73.5, 6);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("passes through every data point on an interpolating curve", () => {
|
|
77
|
+
const points = [at(0, 40), at(50, 10), at(100, 90), at(150, 30)];
|
|
78
|
+
const path = toCurvePath(buildLinePath(points, { curve: "monotone" }));
|
|
79
|
+
for (const point of points) {
|
|
80
|
+
expect(getYForX(path, point.x)).toBeCloseTo(point.y as number, 6);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("clamps to the ends outside the path", () => {
|
|
85
|
+
const path = toCurvePath(buildLinePath([at(10, 5), at(90, 45)]));
|
|
86
|
+
expect(getYForX(path, -50)).toBe(5);
|
|
87
|
+
expect(getYForX(path, 500)).toBe(45);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns NaN inside a gap so the caller can fall back to a datum", () => {
|
|
91
|
+
const path = toCurvePath(buildLinePath([at(0, 1), at(10, 2), at(20, null), at(30, 4), at(40, 5)]));
|
|
92
|
+
expect(getYForX(path, 20)).toBeNaN();
|
|
93
|
+
expect(Number.isFinite(getYForX(path, 5))).toBe(true);
|
|
94
|
+
expect(Number.isFinite(getYForX(path, 35))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("degenerates safely", () => {
|
|
98
|
+
expect(getYForX([], 5)).toBeNaN();
|
|
99
|
+
expect(getYForX(toCurvePath(buildLinePath([at(0, 1)])), 0)).toBeNaN();
|
|
100
|
+
expect(getYForX(toCurvePath(buildLinePath([at(0, 0), at(10, 10)])), Number.NaN)).toBeNaN();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resolves a vertical step segment to the value after the step", () => {
|
|
104
|
+
const path = toCurvePath(buildLinePath([at(0, 0), at(10, 100)], { curve: "stepBefore" }));
|
|
105
|
+
expect(Number.isFinite(getYForX(path, 0))).toBe(true);
|
|
106
|
+
expect(getYForX(path, 10)).toBeCloseTo(100, 6);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("agrees with the analytic solver across a thousand random curves", () => {
|
|
110
|
+
let checked = 0;
|
|
111
|
+
let seed = 20260902;
|
|
112
|
+
const random = (): number => {
|
|
113
|
+
seed = (seed * 1103515245 + 12345) % 2147483648;
|
|
114
|
+
return seed / 2147483648;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
for (let trial = 0; trial < 1000; trial += 1) {
|
|
118
|
+
const points = Array.from({ length: 5 }, (_, index) => at(index * 40, random() * 200));
|
|
119
|
+
const path = toCurvePath(buildLinePath(points, { curve: "monotone" }));
|
|
120
|
+
const x = random() * 160;
|
|
121
|
+
const actual = getYForX(path, x);
|
|
122
|
+
if (!Number.isFinite(actual)) continue;
|
|
123
|
+
|
|
124
|
+
const run = path[0] as readonly number[];
|
|
125
|
+
const segment = Math.min(Math.floor(x / 40), (run.length - 2) / 6 - 1);
|
|
126
|
+
const expected = oracleY(run.slice(segment * 6, segment * 6 + 8), x);
|
|
127
|
+
if (!Number.isFinite(expected)) continue;
|
|
128
|
+
|
|
129
|
+
expect(actual).toBeCloseTo(expected, 6);
|
|
130
|
+
checked += 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
expect(checked).toBeGreaterThan(800);
|
|
134
|
+
});
|
|
135
|
+
});
|