@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,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { InnerRadius } from "./polar.types";
|
|
3
|
+
import { resolveInnerRadius, resolvePolarLayout } from "./resolve-layout";
|
|
4
|
+
|
|
5
|
+
describe("resolvePolarLayout", () => {
|
|
6
|
+
test("fits the largest circle the canvas holds, centred", () => {
|
|
7
|
+
const layout = resolvePolarLayout({ canvas: { width: 300, height: 200 } });
|
|
8
|
+
expect(layout.center).toEqual({ x: 150, y: 100 });
|
|
9
|
+
expect(layout.radius).toBe(100);
|
|
10
|
+
expect(layout.innerRadius).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("padding shrinks the circle and moves the centre", () => {
|
|
14
|
+
const layout = resolvePolarLayout({ canvas: { width: 300, height: 200 }, padding: { left: 100 } });
|
|
15
|
+
expect(layout.center).toEqual({ x: 200, y: 100 });
|
|
16
|
+
expect(layout.radius).toBe(100);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("an explicit size caps the diameter but never exceeds the canvas", () => {
|
|
20
|
+
expect(resolvePolarLayout({ canvas: { width: 300, height: 200 }, size: 100 }).radius).toBe(50);
|
|
21
|
+
expect(resolvePolarLayout({ canvas: { width: 300, height: 200 }, size: 1000 }).radius).toBe(100);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("resolves the inner radius against the outer one", () => {
|
|
25
|
+
expect(resolvePolarLayout({ canvas: { width: 200, height: 200 }, innerRadius: "50%" }).innerRadius).toBe(50);
|
|
26
|
+
expect(resolvePolarLayout({ canvas: { width: 200, height: 200 }, innerRadius: 30 }).innerRadius).toBe(30);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a zero or over-padded canvas yields a zero radius rather than a negative one", () => {
|
|
30
|
+
expect(resolvePolarLayout({ canvas: { width: 0, height: 0 } }).radius).toBe(0);
|
|
31
|
+
expect(resolvePolarLayout({ canvas: { width: 50, height: 50 }, padding: 40 }).radius).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("resolveInnerRadius", () => {
|
|
36
|
+
test("a number is taken as points", () => {
|
|
37
|
+
expect(resolveInnerRadius(40, 100)).toBe(40);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a percentage is of the outer radius", () => {
|
|
41
|
+
expect(resolveInnerRadius("60%", 100)).toBe(60);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("clamps into [0, radius)", () => {
|
|
45
|
+
expect(resolveInnerRadius(-5, 100)).toBe(0);
|
|
46
|
+
expect(resolveInnerRadius(150, 100)).toBeLessThan(100);
|
|
47
|
+
expect(resolveInnerRadius(150, 100)).toBeGreaterThanOrEqual(0);
|
|
48
|
+
expect(resolveInnerRadius("120%", 100)).toBeLessThan(100);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an unreadable percentage is 0", () => {
|
|
52
|
+
expect(resolveInnerRadius("abc%" as unknown as InnerRadius, 100)).toBe(0);
|
|
53
|
+
expect(resolveInnerRadius(Number.NaN, 100)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("undefined is 0, which is a pie rather than a donut", () => {
|
|
57
|
+
expect(resolveInnerRadius(undefined, 100)).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a zero outer radius gives a zero inner one", () => {
|
|
61
|
+
expect(resolveInnerRadius("50%", 0)).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ChartSize } from "../chart.types";
|
|
2
|
+
import { type SidedNumber, sidesOf } from "../util/sided-number";
|
|
3
|
+
import type { InnerRadius, PolarLayout } from "./polar.types";
|
|
4
|
+
|
|
5
|
+
export type ResolvePolarLayoutOptions = {
|
|
6
|
+
readonly canvas: ChartSize;
|
|
7
|
+
/** Space between the canvas edge and the circle. */
|
|
8
|
+
readonly padding?: SidedNumber;
|
|
9
|
+
/** An explicit diameter, in points. Never larger than the padded canvas. */
|
|
10
|
+
readonly size?: number;
|
|
11
|
+
readonly innerRadius?: InnerRadius;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The largest circle the padded canvas holds, centred in it.
|
|
16
|
+
*
|
|
17
|
+
* The radius is never negative: a canvas smaller than its padding — the first
|
|
18
|
+
* layout pass, before anything has been measured — gives a zero radius, and a
|
|
19
|
+
* zero radius draws nothing rather than an inverted slice.
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePolarLayout(options: ResolvePolarLayoutOptions): PolarLayout {
|
|
22
|
+
const { canvas, padding, size, innerRadius } = options;
|
|
23
|
+
const sides = sidesOf(padding);
|
|
24
|
+
const width = Math.max(0, canvas.width - sides.left - sides.right);
|
|
25
|
+
const height = Math.max(0, canvas.height - sides.top - sides.bottom);
|
|
26
|
+
const fit = Math.min(width, height) / 2;
|
|
27
|
+
const radius = size !== undefined && Number.isFinite(size) ? Math.max(0, Math.min(fit, size / 2)) : fit;
|
|
28
|
+
return {
|
|
29
|
+
center: { x: sides.left + width / 2, y: sides.top + height / 2 },
|
|
30
|
+
radius,
|
|
31
|
+
innerRadius: resolveInnerRadius(innerRadius, radius),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The hole's radius in points, held inside `[0, radius)`.
|
|
37
|
+
*
|
|
38
|
+
* A hole as large as the circle would leave a ring of zero width, which draws
|
|
39
|
+
* nothing and cannot be tapped, so the clamp stops one point short of it. An
|
|
40
|
+
* unreadable value — `"abc%"`, `NaN` — is no hole, because a pie is the more
|
|
41
|
+
* reasonable thing to draw than nothing.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveInnerRadius(spec: InnerRadius | undefined, radius: number): number {
|
|
44
|
+
if (radius <= 0) return 0;
|
|
45
|
+
const requested = typeof spec === "number" ? spec : parsePercent(spec) * radius;
|
|
46
|
+
if (!Number.isFinite(requested) || requested <= 0) return 0;
|
|
47
|
+
return Math.min(requested, Math.max(0, radius - 1));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `"60%"` as `0.6`; `0` for anything that is not a percentage. */
|
|
51
|
+
function parsePercent(spec: `${number}%` | undefined): number {
|
|
52
|
+
if (spec === undefined) return 0;
|
|
53
|
+
const parsed = Number(spec.slice(0, -1));
|
|
54
|
+
return Number.isFinite(parsed) ? parsed / 100 : 0;
|
|
55
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PolarLayout } from "./polar.types";
|
|
3
|
+
import { resolveSlices } from "./resolve-slices";
|
|
4
|
+
|
|
5
|
+
const layout: PolarLayout = { center: { x: 100, y: 100 }, radius: 80, innerRadius: 20 };
|
|
6
|
+
|
|
7
|
+
function sweeps(values: readonly number[], options: { startAngle?: number; circleSweepDegrees?: number } = {}) {
|
|
8
|
+
return resolveSlices({ values, labels: values.map(String), layout, ...options }).map((slice) => slice.sweepAngle);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("resolveSlices", () => {
|
|
12
|
+
test("splits the circle in proportion, starting at 0°", () => {
|
|
13
|
+
const slices = resolveSlices({ values: [1, 1, 2], labels: ["a", "b", "c"], layout });
|
|
14
|
+
expect(slices.map((slice) => slice.fraction)).toEqual([0.25, 0.25, 0.5]);
|
|
15
|
+
expect(slices.map((slice) => slice.startAngle)).toEqual([0, 90, 180]);
|
|
16
|
+
expect(slices.map((slice) => slice.endAngle)).toEqual([90, 180, 360]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("carries the layout, label, value and index on every slice", () => {
|
|
20
|
+
const [slice] = resolveSlices({ values: [3], labels: ["only"], layout });
|
|
21
|
+
expect(slice).toMatchObject({
|
|
22
|
+
index: 0,
|
|
23
|
+
label: "only",
|
|
24
|
+
value: 3,
|
|
25
|
+
center: layout.center,
|
|
26
|
+
radius: 80,
|
|
27
|
+
innerRadius: 20,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a negative or non-finite value gets a zero sweep but keeps its index", () => {
|
|
32
|
+
const slices = resolveSlices({ values: [1, -1, Number.NaN, 1], labels: ["a", "b", "c", "d"], layout });
|
|
33
|
+
expect(slices.map((slice) => slice.index)).toEqual([0, 1, 2, 3]);
|
|
34
|
+
expect(slices.map((slice) => slice.sweepAngle)).toEqual([180, 0, 0, 180]);
|
|
35
|
+
expect(slices[1]?.startAngle).toBe(180);
|
|
36
|
+
expect(slices[2]?.startAngle).toBe(180);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a zero total gives every slice a zero sweep and no NaN", () => {
|
|
40
|
+
const slices = resolveSlices({ values: [0, 0], labels: ["a", "b"], layout });
|
|
41
|
+
for (const slice of slices) {
|
|
42
|
+
expect(slice.sweepAngle).toBe(0);
|
|
43
|
+
expect(slice.fraction).toBe(0);
|
|
44
|
+
expect(Number.isFinite(slice.startAngle)).toBe(true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("sweeps sum to the circle exactly, with the last slice absorbing drift", () => {
|
|
49
|
+
const values = [1, 1, 1, 1, 1, 1, 1];
|
|
50
|
+
const total = sweeps(values).reduce((sum, sweep) => sum + sweep, 0);
|
|
51
|
+
expect(total).toBe(360);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a trailing zero slice stays zero when drift is absorbed", () => {
|
|
55
|
+
const result = sweeps([1, 1, 1, 0]);
|
|
56
|
+
expect(result[3]).toBe(0);
|
|
57
|
+
expect(result.reduce((sum, sweep) => sum + sweep, 0)).toBe(360);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("honours a start angle and a partial circle", () => {
|
|
61
|
+
const slices = resolveSlices({
|
|
62
|
+
values: [1, 1],
|
|
63
|
+
labels: ["a", "b"],
|
|
64
|
+
layout,
|
|
65
|
+
startAngle: -90,
|
|
66
|
+
circleSweepDegrees: 180,
|
|
67
|
+
});
|
|
68
|
+
expect(slices.map((slice) => slice.startAngle)).toEqual([-90, 0]);
|
|
69
|
+
expect(slices.map((slice) => slice.sweepAngle)).toEqual([90, 90]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("flags the one slice that is the whole circle", () => {
|
|
73
|
+
const [whole] = resolveSlices({ values: [5, 0], labels: ["a", "b"], layout });
|
|
74
|
+
expect(whole?.sliceIsEntireCircle).toBe(true);
|
|
75
|
+
const [half] = resolveSlices({ values: [5], labels: ["a"], layout, circleSweepDegrees: 180 });
|
|
76
|
+
expect(half?.sliceIsEntireCircle).toBe(false);
|
|
77
|
+
const [part] = resolveSlices({ values: [5, 5], labels: ["a", "b"], layout });
|
|
78
|
+
expect(part?.sliceIsEntireCircle).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a missing label falls back to the index", () => {
|
|
82
|
+
const [slice] = resolveSlices({ values: [1], labels: [], layout });
|
|
83
|
+
expect(slice?.label).toBe("0");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("no values gives no slices", () => {
|
|
87
|
+
expect(resolveSlices({ values: [], labels: [], layout })).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PieSliceData, PolarLayout } from "./polar.types";
|
|
2
|
+
|
|
3
|
+
export type ResolveSlicesOptions = {
|
|
4
|
+
readonly values: readonly number[];
|
|
5
|
+
readonly labels: readonly string[];
|
|
6
|
+
readonly layout: PolarLayout;
|
|
7
|
+
/** Where the first slice begins. Degrees clockwise from 12 o'clock. */
|
|
8
|
+
readonly startAngle?: number;
|
|
9
|
+
/** How much of the circle the slices fill. 180 is a half-pie gauge. */
|
|
10
|
+
readonly circleSweepDegrees?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every value as a slice of the circle.
|
|
15
|
+
*
|
|
16
|
+
* A negative or unreadable value gets a **zero sweep and keeps its index**.
|
|
17
|
+
* Dropping it would move every later slice one place, and a legend built from
|
|
18
|
+
* the same rows would then colour the wrong slice.
|
|
19
|
+
*
|
|
20
|
+
* The sweeps sum to the circle exactly: the last slice with any sweep absorbs
|
|
21
|
+
* the floating-point drift of the others. Without that, seven equal slices end
|
|
22
|
+
* a hair short of 360° and a hairline of background shows through at the seam.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveSlices(options: ResolveSlicesOptions): PieSliceData[] {
|
|
25
|
+
const { values, labels, layout, startAngle = 0, circleSweepDegrees = 360 } = options;
|
|
26
|
+
const clean = values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0));
|
|
27
|
+
const total = clean.reduce((sum, value) => sum + value, 0);
|
|
28
|
+
const circle = Number.isFinite(circleSweepDegrees) ? circleSweepDegrees : 360;
|
|
29
|
+
const origin = Number.isFinite(startAngle) ? startAngle : 0;
|
|
30
|
+
|
|
31
|
+
const sweeps = clean.map((value) => (total > 0 ? (value / total) * circle : 0));
|
|
32
|
+
const last = clean.reduce((found, value, index) => (value > 0 ? index : found), -1);
|
|
33
|
+
if (last >= 0) {
|
|
34
|
+
const others = sweeps.reduce((sum, sweep, index) => (index === last ? sum : sum + sweep), 0);
|
|
35
|
+
sweeps[last] = circle - others;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cursor = origin;
|
|
39
|
+
return clean.map((value, index) => {
|
|
40
|
+
const sweepAngle = sweeps[index] as number;
|
|
41
|
+
const start = cursor;
|
|
42
|
+
cursor += sweepAngle;
|
|
43
|
+
const fraction = total > 0 ? value / total : 0;
|
|
44
|
+
return {
|
|
45
|
+
index,
|
|
46
|
+
label: labels[index] ?? String(index),
|
|
47
|
+
value,
|
|
48
|
+
fraction,
|
|
49
|
+
startAngle: start,
|
|
50
|
+
endAngle: start + sweepAngle,
|
|
51
|
+
sweepAngle,
|
|
52
|
+
center: layout.center,
|
|
53
|
+
radius: layout.radius,
|
|
54
|
+
innerRadius: layout.innerRadius,
|
|
55
|
+
sliceIsEntireCircle: circle === 360 && fraction === 1,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sliceIndexAt } from "./slice-index-at";
|
|
3
|
+
|
|
4
|
+
// Three quarters and a zero-sweep slice: 0–90, 90–180, 180–180, 180–360.
|
|
5
|
+
const starts = [0, 90, 180, 180];
|
|
6
|
+
const sweeps = [90, 90, 0, 180];
|
|
7
|
+
|
|
8
|
+
function hit(x: number, y: number, innerRadius = 0): number {
|
|
9
|
+
return sliceIndexAt(x, y, 100, 100, innerRadius, 80, starts, sweeps);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("sliceIndexAt", () => {
|
|
13
|
+
test("finds the slice under a point", () => {
|
|
14
|
+
expect(hit(130, 70)).toBe(0);
|
|
15
|
+
expect(hit(130, 130)).toBe(1);
|
|
16
|
+
expect(hit(70, 130)).toBe(3);
|
|
17
|
+
expect(hit(70, 70)).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("outside the radius is nothing", () => {
|
|
21
|
+
expect(hit(200, 100)).toBe(-1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("inside the hole is nothing", () => {
|
|
25
|
+
expect(hit(110, 100, 40)).toBe(-1);
|
|
26
|
+
expect(hit(150, 100, 40)).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a zero-sweep slice is never hit", () => {
|
|
30
|
+
expect(hit(100, 150)).toBe(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wraps a slice that crosses 360°", () => {
|
|
34
|
+
// One slice from 300° through 0° to 60°.
|
|
35
|
+
expect(sliceIndexAt(100, 50, 100, 100, 0, 80, [300], [120])).toBe(0);
|
|
36
|
+
expect(sliceIndexAt(150, 100, 100, 100, 0, 80, [300], [120])).toBe(-1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("accepts start angles outside [0, 360)", () => {
|
|
40
|
+
expect(sliceIndexAt(130, 70, 100, 100, 0, 80, [-360], [90])).toBe(0);
|
|
41
|
+
expect(sliceIndexAt(130, 70, 100, 100, 0, 80, [720], [90])).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a full-circle slice takes every point", () => {
|
|
45
|
+
expect(sliceIndexAt(100, 50, 100, 100, 0, 80, [0], [360])).toBe(0);
|
|
46
|
+
expect(sliceIndexAt(60, 140, 100, 100, 0, 80, [0], [360])).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no slices is nothing", () => {
|
|
50
|
+
expect(sliceIndexAt(100, 100, 100, 100, 0, 80, [], [])).toBe(-1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which slice is under a canvas point, or `-1`.
|
|
3
|
+
*
|
|
4
|
+
* Flat worklet: it calls nothing, so it may run on the UI thread from a gesture
|
|
5
|
+
* callback. The angle maths is inlined rather than shared with
|
|
6
|
+
* `polar-point.ts` for that reason — see the package AGENTS.md on why a
|
|
7
|
+
* module-scope worklet must not call another function.
|
|
8
|
+
*
|
|
9
|
+
* A slice owns its start angle and not its end, so a point exactly on a
|
|
10
|
+
* boundary belongs to the later slice. A slice with no sweep owns nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function sliceIndexAt(
|
|
13
|
+
x: number,
|
|
14
|
+
y: number,
|
|
15
|
+
centerX: number,
|
|
16
|
+
centerY: number,
|
|
17
|
+
innerRadius: number,
|
|
18
|
+
radius: number,
|
|
19
|
+
startAngles: readonly number[],
|
|
20
|
+
sweepAngles: readonly number[]
|
|
21
|
+
): number {
|
|
22
|
+
"worklet";
|
|
23
|
+
const dx = x - centerX;
|
|
24
|
+
const dy = y - centerY;
|
|
25
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
26
|
+
if (distance > radius || distance < innerRadius) return -1;
|
|
27
|
+
|
|
28
|
+
// Degrees clockwise from 12 o'clock — the same convention the slices use.
|
|
29
|
+
let angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
|
|
30
|
+
angle = ((angle % 360) + 360) % 360;
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < startAngles.length; index += 1) {
|
|
33
|
+
const sweep = sweepAngles[index] ?? 0;
|
|
34
|
+
if (!(sweep > 0)) continue;
|
|
35
|
+
const start = (((startAngles[index] ?? 0) % 360) + 360) % 360;
|
|
36
|
+
let relative = angle - start;
|
|
37
|
+
if (relative < 0) relative += 360;
|
|
38
|
+
if (relative < sweep) return index;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sliceOpacity } from "./slice-opacity";
|
|
3
|
+
|
|
4
|
+
describe("sliceOpacity", () => {
|
|
5
|
+
test("every slice is at full opacity with nothing selected", () => {
|
|
6
|
+
expect(sliceOpacity(0, null, 1, 0.4)).toBe(1);
|
|
7
|
+
expect(sliceOpacity(3, null, 0.8, 0.4)).toBe(0.8);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("the selected slice keeps its opacity", () => {
|
|
11
|
+
expect(sliceOpacity(0, 0, 1, 0.4)).toBe(1);
|
|
12
|
+
expect(sliceOpacity(2, 2, 0.5, 0.4)).toBe(0.5);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("every other slice dims by the factor", () => {
|
|
16
|
+
expect(sliceOpacity(1, 0, 1, 0.4)).toBeCloseTo(0.4);
|
|
17
|
+
expect(sliceOpacity(0, 1, 0.5, 0.4)).toBeCloseTo(0.2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("a dim of one leaves the others alone", () => {
|
|
21
|
+
expect(sliceOpacity(1, 0, 1, 1)).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Index zero is a real slice, not a falsy "nothing selected".
|
|
25
|
+
test("selecting the first slice dims the rest, not the first", () => {
|
|
26
|
+
expect(sliceOpacity(0, 0, 1, 0.4)).toBe(1);
|
|
27
|
+
expect(sliceOpacity(1, 0, 1, 0.4)).toBeCloseTo(0.4);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opacity one slice draws at, given which slice is selected.
|
|
3
|
+
*
|
|
4
|
+
* With no selection every slice draws at `opacity`. With one, the selected
|
|
5
|
+
* slice keeps `opacity` and every other slice draws at `opacity × dim` — a
|
|
6
|
+
* selection that changed nothing on screen would look like a tap that missed.
|
|
7
|
+
* `dim` of `1` turns the effect off without needing a second code path.
|
|
8
|
+
*/
|
|
9
|
+
export function sliceOpacity(index: number, selectedIndex: number | null, opacity: number, dim: number): number {
|
|
10
|
+
if (selectedIndex === null || selectedIndex === index) return opacity;
|
|
11
|
+
return opacity * dim;
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { makeScale } from "./make-scale";
|
|
3
|
+
import { scaleValue } from "./scale";
|
|
4
|
+
|
|
5
|
+
describe("makeScale", () => {
|
|
6
|
+
test("keeps the domain it was given when nicing is off", () => {
|
|
7
|
+
const scale = makeScale({ kind: "linear", domain: [3, 97], range: [0, 300] });
|
|
8
|
+
expect(scale.domain).toEqual([3, 97]);
|
|
9
|
+
expect(scale.range).toEqual([0, 300]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("rounds the domain out to tick values when nicing is on", () => {
|
|
13
|
+
const scale = makeScale({ kind: "linear", domain: [3, 97], range: [0, 300], nice: true });
|
|
14
|
+
expect(scale.domain[0]).toBeLessThanOrEqual(3);
|
|
15
|
+
expect(scale.domain[1]).toBeGreaterThanOrEqual(97);
|
|
16
|
+
expect(scale.domain).toEqual([0, 100]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("honours a requested tick count when nicing", () => {
|
|
20
|
+
const scale = makeScale({ kind: "linear", domain: [0.7, 9.3], range: [0, 300], nice: 2 });
|
|
21
|
+
expect(scale.domain[0]).toBeLessThanOrEqual(0.7);
|
|
22
|
+
expect(scale.domain[1]).toBeGreaterThanOrEqual(9.3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns a descriptor holding numbers only, so a worklet can read it", () => {
|
|
26
|
+
const scale = makeScale({ kind: "time", domain: [0, 86_400_000], range: [0, 240] });
|
|
27
|
+
expect(JSON.parse(JSON.stringify(scale))).toEqual(scale as unknown as Record<string, unknown>);
|
|
28
|
+
for (const bound of [...scale.domain, ...scale.range]) expect(typeof bound).toBe("number");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("nices a time domain onto a calendar boundary", () => {
|
|
32
|
+
const start = Date.UTC(2026, 0, 1, 4, 37);
|
|
33
|
+
const end = Date.UTC(2026, 0, 3, 19, 12);
|
|
34
|
+
const scale = makeScale({ kind: "time", domain: [start, end], range: [0, 300], nice: true });
|
|
35
|
+
expect(scale.domain[0]).toBeLessThanOrEqual(start);
|
|
36
|
+
expect(scale.domain[1]).toBeGreaterThanOrEqual(end);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("carries the log base through", () => {
|
|
40
|
+
const scale = makeScale({ kind: "log", domain: [1, 1024], range: [0, 300], base: 2 });
|
|
41
|
+
expect(scale.kind === "log" && scale.base).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("floors a log domain above zero rather than emitting -Infinity", () => {
|
|
45
|
+
const scale = makeScale({ kind: "log", domain: [0, 100], range: [0, 300] });
|
|
46
|
+
expect(scale.domain[0]).toBeGreaterThan(0);
|
|
47
|
+
expect(Number.isFinite(scaleValue(scale, 50))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("substitutes zero for a non-finite bound rather than poisoning every point", () => {
|
|
51
|
+
const scale = makeScale({ kind: "linear", domain: [Number.NaN, 100], range: [0, 300] });
|
|
52
|
+
expect(Number.isFinite(scale.domain[0])).toBe(true);
|
|
53
|
+
expect(Number.isFinite(scaleValue(scale, 50))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { scaleLinear, scaleLog, scaleTime } from "d3-scale";
|
|
2
|
+
import type { DomainTuple, RangeTuple, ScaleDescriptor, ScaleType } from "./scale.types";
|
|
3
|
+
|
|
4
|
+
/** The smallest domain a log scale can take; d3 produces garbage at or below zero. */
|
|
5
|
+
const LOG_FLOOR = 1e-9;
|
|
6
|
+
|
|
7
|
+
export type MakeScaleOptions = {
|
|
8
|
+
readonly kind: ScaleType;
|
|
9
|
+
readonly domain: DomainTuple;
|
|
10
|
+
readonly range: RangeTuple;
|
|
11
|
+
/**
|
|
12
|
+
* Round the domain out to values a tick would land on. `true` uses the
|
|
13
|
+
* default tick count; a number asks for that many.
|
|
14
|
+
*/
|
|
15
|
+
readonly nice?: boolean | number;
|
|
16
|
+
/** Log base. Ignored by every other kind. */
|
|
17
|
+
readonly base?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds a scale and returns only its descriptor.
|
|
22
|
+
*
|
|
23
|
+
* d3 does the work that is genuinely hard — `.nice()` rounding a domain out to
|
|
24
|
+
* human numbers, and `scaleTime` understanding that a DST day is 23 hours —
|
|
25
|
+
* and then we throw the closure away and keep the numbers. Nothing downstream
|
|
26
|
+
* ever holds a d3 scale, which is what keeps the UI thread able to read one.
|
|
27
|
+
*/
|
|
28
|
+
export function makeScale(options: MakeScaleOptions): ScaleDescriptor {
|
|
29
|
+
const { kind, range } = options;
|
|
30
|
+
const domain = normaliseDomain(options.domain, kind);
|
|
31
|
+
|
|
32
|
+
const nice = options.nice;
|
|
33
|
+
|
|
34
|
+
switch (kind) {
|
|
35
|
+
case "linear": {
|
|
36
|
+
const scale = scaleLinear().domain([domain[0], domain[1]]).range([range[0], range[1]]);
|
|
37
|
+
applyNice(scale, nice);
|
|
38
|
+
const nicened = scale.domain();
|
|
39
|
+
return { kind: "linear", domain: [nicened[0] as number, nicened[1] as number], range };
|
|
40
|
+
}
|
|
41
|
+
case "time": {
|
|
42
|
+
const scale = scaleTime()
|
|
43
|
+
.domain([new Date(domain[0]), new Date(domain[1])])
|
|
44
|
+
.range([range[0], range[1]]);
|
|
45
|
+
applyNice(scale, nice);
|
|
46
|
+
const nicened = scale.domain();
|
|
47
|
+
return { kind: "time", domain: [nicened[0].getTime(), nicened[1].getTime()], range };
|
|
48
|
+
}
|
|
49
|
+
case "log": {
|
|
50
|
+
const base = options.base ?? 10;
|
|
51
|
+
const scale = scaleLog().base(base).domain([domain[0], domain[1]]).range([range[0], range[1]]);
|
|
52
|
+
applyNice(scale, nice);
|
|
53
|
+
const nicened = scale.domain();
|
|
54
|
+
return { kind: "log", domain: [nicened[0] as number, nicened[1] as number], range, base };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Rounds a scale's domain out in place, when the caller asked for it. */
|
|
60
|
+
function applyNice(scale: { nice: (count?: number) => unknown }, nice: boolean | number | undefined): void {
|
|
61
|
+
if (nice === undefined || nice === false) return;
|
|
62
|
+
scale.nice(nice === true ? undefined : nice);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A domain d3 will not choke on.
|
|
67
|
+
*
|
|
68
|
+
* Two guards. A non-finite bound becomes zero, because a `NaN` domain produces
|
|
69
|
+
* a `NaN` position for every point and an empty canvas with nothing logged. A
|
|
70
|
+
* log domain is floored above zero, because `scaleLog` with a zero or negative
|
|
71
|
+
* bound returns `-Infinity` for the whole range.
|
|
72
|
+
*/
|
|
73
|
+
function normaliseDomain(domain: DomainTuple, kind: ScaleType): DomainTuple {
|
|
74
|
+
const lo = Number.isFinite(domain[0]) ? domain[0] : 0;
|
|
75
|
+
const hi = Number.isFinite(domain[1]) ? domain[1] : 0;
|
|
76
|
+
if (kind !== "log") return [lo, hi];
|
|
77
|
+
return [Math.max(lo, LOG_FLOOR), Math.max(hi, LOG_FLOOR * 10)];
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { invertValue, scaleValue } from "./scale";
|
|
3
|
+
import type { ScaleDescriptor } from "./scale.types";
|
|
4
|
+
|
|
5
|
+
const linear: ScaleDescriptor = { kind: "linear", domain: [0, 100], range: [0, 300] };
|
|
6
|
+
const flipped: ScaleDescriptor = { kind: "linear", domain: [0, 100], range: [300, 0] };
|
|
7
|
+
const log: ScaleDescriptor = { kind: "log", domain: [1, 1000], range: [0, 300], base: 10 };
|
|
8
|
+
|
|
9
|
+
describe("scaleValue", () => {
|
|
10
|
+
test("maps the domain onto the range", () => {
|
|
11
|
+
expect(scaleValue(linear, 0)).toBe(0);
|
|
12
|
+
expect(scaleValue(linear, 50)).toBe(150);
|
|
13
|
+
expect(scaleValue(linear, 100)).toBe(300);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("handles an inverted range, which is what a y axis is", () => {
|
|
17
|
+
expect(scaleValue(flipped, 0)).toBe(300);
|
|
18
|
+
expect(scaleValue(flipped, 100)).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("extrapolates outside the domain rather than clamping", () => {
|
|
22
|
+
// Clamping here would silently flatten an out-of-viewport point onto the
|
|
23
|
+
// axis. Clipping is the renderer's job, not the scale's.
|
|
24
|
+
expect(scaleValue(linear, 150)).toBe(450);
|
|
25
|
+
expect(scaleValue(linear, -50)).toBe(-150);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns the range midpoint for a zero-width domain", () => {
|
|
29
|
+
const point: ScaleDescriptor = { kind: "linear", domain: [7, 7], range: [0, 300] };
|
|
30
|
+
expect(scaleValue(point, 7)).toBe(150);
|
|
31
|
+
expect(scaleValue(point, 999)).toBe(150);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("places log decades evenly", () => {
|
|
35
|
+
expect(scaleValue(log, 1)).toBeCloseTo(0, 9);
|
|
36
|
+
expect(scaleValue(log, 10)).toBeCloseTo(100, 9);
|
|
37
|
+
expect(scaleValue(log, 100)).toBeCloseTo(200, 9);
|
|
38
|
+
expect(scaleValue(log, 1000)).toBeCloseTo(300, 9);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("never returns NaN for a non-positive value on a log scale", () => {
|
|
42
|
+
expect(Number.isFinite(scaleValue(log, 0))).toBe(true);
|
|
43
|
+
expect(Number.isFinite(scaleValue(log, -5))).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns the midpoint for a log scale whose domain reaches zero", () => {
|
|
47
|
+
const broken: ScaleDescriptor = { kind: "log", domain: [0, 100], range: [0, 300], base: 10 };
|
|
48
|
+
expect(scaleValue(broken, 10)).toBe(150);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reads a time domain as epoch milliseconds", () => {
|
|
52
|
+
const day = 86_400_000;
|
|
53
|
+
const time: ScaleDescriptor = { kind: "time", domain: [0, day], range: [0, 240] };
|
|
54
|
+
expect(scaleValue(time, day / 2)).toBe(120);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("invertValue", () => {
|
|
59
|
+
test("round-trips every kind", () => {
|
|
60
|
+
for (const scale of [linear, flipped, log]) {
|
|
61
|
+
for (const value of [1, 2, 17, 99]) {
|
|
62
|
+
expect(invertValue(scale, scaleValue(scale, value))).toBeCloseTo(value, 6);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns the domain start for a zero-width range", () => {
|
|
68
|
+
const collapsed: ScaleDescriptor = { kind: "linear", domain: [4, 9], range: [12, 12] };
|
|
69
|
+
expect(invertValue(collapsed, 12)).toBe(4);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("stays finite on a log scale whose domain reaches zero", () => {
|
|
73
|
+
const broken: ScaleDescriptor = { kind: "log", domain: [0, 100], range: [0, 300], base: 10 };
|
|
74
|
+
expect(Number.isFinite(invertValue(broken, 150))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ScaleDescriptor } from "./scale.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A domain value's position along the range.
|
|
5
|
+
*
|
|
6
|
+
* Flat worklet: it calls nothing and closes over nothing, so a gesture
|
|
7
|
+
* callback can reference it directly. See the package AGENTS.md.
|
|
8
|
+
*
|
|
9
|
+
* A zero-width domain returns the range midpoint rather than dividing by zero.
|
|
10
|
+
* That case is real — a single datum, or a series that is one repeated value —
|
|
11
|
+
* and one `NaN` written into a shared value freezes a chart permanently.
|
|
12
|
+
*/
|
|
13
|
+
export function scaleValue(scale: ScaleDescriptor, value: number): number {
|
|
14
|
+
"worklet";
|
|
15
|
+
const d0 = scale.domain[0];
|
|
16
|
+
const d1 = scale.domain[1];
|
|
17
|
+
const r0 = scale.range[0];
|
|
18
|
+
const r1 = scale.range[1];
|
|
19
|
+
|
|
20
|
+
switch (scale.kind) {
|
|
21
|
+
case "linear":
|
|
22
|
+
case "time": {
|
|
23
|
+
if (d0 === d1) return (r0 + r1) / 2;
|
|
24
|
+
return r0 + ((value - d0) / (d1 - d0)) * (r1 - r0);
|
|
25
|
+
}
|
|
26
|
+
case "log": {
|
|
27
|
+
if (d0 <= 0 || d1 <= 0 || d0 === d1) return (r0 + r1) / 2;
|
|
28
|
+
const l0 = Math.log(d0);
|
|
29
|
+
const l1 = Math.log(d1);
|
|
30
|
+
const lv = Math.log(value > 0 ? value : Number.MIN_VALUE);
|
|
31
|
+
return r0 + ((lv - l0) / (l1 - l0)) * (r1 - r0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The domain value at a position along the range — `scaleValue` inverted.
|
|
38
|
+
*
|
|
39
|
+
* This is the one piece of scale maths that runs on the UI thread every frame
|
|
40
|
+
* of a scrub, which is the whole reason the descriptor is plain data.
|
|
41
|
+
*
|
|
42
|
+
* Flat worklet: calls nothing, closes over nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function invertValue(scale: ScaleDescriptor, position: number): number {
|
|
45
|
+
"worklet";
|
|
46
|
+
const d0 = scale.domain[0];
|
|
47
|
+
const d1 = scale.domain[1];
|
|
48
|
+
const r0 = scale.range[0];
|
|
49
|
+
const r1 = scale.range[1];
|
|
50
|
+
|
|
51
|
+
if (r0 === r1) return d0;
|
|
52
|
+
const t = (position - r0) / (r1 - r0);
|
|
53
|
+
|
|
54
|
+
switch (scale.kind) {
|
|
55
|
+
case "linear":
|
|
56
|
+
case "time":
|
|
57
|
+
return d0 + t * (d1 - d0);
|
|
58
|
+
case "log": {
|
|
59
|
+
if (d0 <= 0 || d1 <= 0) return d0;
|
|
60
|
+
const l0 = Math.log(d0);
|
|
61
|
+
const l1 = Math.log(d1);
|
|
62
|
+
return Math.exp(l0 + t * (l1 - l0));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|