@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,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { downsampleTicks, normalizeTickCount } from "./tick-count";
|
|
3
|
+
|
|
4
|
+
describe("downsampleTicks", () => {
|
|
5
|
+
test("returns the input untouched when it already fits", () => {
|
|
6
|
+
expect(downsampleTicks([1, 2, 3], 5)).toEqual([1, 2, 3]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("keeps both ends when thinning", () => {
|
|
10
|
+
const thinned = downsampleTicks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 4);
|
|
11
|
+
expect(thinned).toHaveLength(4);
|
|
12
|
+
expect(thinned[0]).toBe(0);
|
|
13
|
+
expect(thinned.at(-1)).toBe(9);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("degenerates safely", () => {
|
|
17
|
+
expect(downsampleTicks([1, 2, 3], 0)).toEqual([]);
|
|
18
|
+
expect(downsampleTicks([1, 2, 3], 1)).toEqual([1]);
|
|
19
|
+
expect(downsampleTicks([], 5)).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("does not alias the input array", () => {
|
|
23
|
+
const source = [1, 2, 3];
|
|
24
|
+
expect(downsampleTicks(source, 5)).not.toBe(source);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("normalizeTickCount", () => {
|
|
29
|
+
test("spreads exactly count values across the domain", () => {
|
|
30
|
+
expect(normalizeTickCount([0, 100], 5)).toEqual([0, 25, 50, 75, 100]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("puts a secondary axis' gridlines on the primary's", () => {
|
|
34
|
+
// The point of the function: same count, same fractions, same pixels.
|
|
35
|
+
const primary = normalizeTickCount([0, 100], 6);
|
|
36
|
+
const secondary = normalizeTickCount([-3, 12], 6);
|
|
37
|
+
expect(secondary).toHaveLength(primary.length);
|
|
38
|
+
const fractionOf = (values: number[], lo: number, hi: number) => values.map((v) => (v - lo) / (hi - lo));
|
|
39
|
+
expect(fractionOf(secondary, -3, 12)).toEqual(fractionOf(primary, 0, 100));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("degenerates safely", () => {
|
|
43
|
+
expect(normalizeTickCount([0, 100], 0)).toEqual([]);
|
|
44
|
+
expect(normalizeTickCount([4, 9], 1)).toEqual([4]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DomainTuple } from "../scale/scale.types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TICK_COUNT = 5;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* At most `count` of `values`, evenly sampled, keeping both ends.
|
|
7
|
+
*
|
|
8
|
+
* For when the tick values are given rather than generated — a caller's
|
|
9
|
+
* explicit `tickValues`, or a categorical axis with one tick per datum that
|
|
10
|
+
* would otherwise overprint itself.
|
|
11
|
+
*/
|
|
12
|
+
export function downsampleTicks(values: readonly number[], count: number): number[] {
|
|
13
|
+
if (count <= 0) return [];
|
|
14
|
+
if (values.length <= count) return [...values];
|
|
15
|
+
if (count === 1) return [values[0] as number];
|
|
16
|
+
|
|
17
|
+
const last = values.length - 1;
|
|
18
|
+
return Array.from({ length: count }, (_, index) => values[Math.round((index * last) / (count - 1))] as number);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Exactly `count` values spread evenly across `domain`.
|
|
23
|
+
*
|
|
24
|
+
* Two y axes drawing their own round numbers produce two sets of gridlines at
|
|
25
|
+
* different heights, and the chart reads as two charts overlaid. Giving the
|
|
26
|
+
* secondary axis the primary's count puts every line in the same place, at the
|
|
27
|
+
* cost of the secondary's labels no longer being round — which is the right
|
|
28
|
+
* trade, because a gridline is structural and a label is not.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeTickCount(domain: DomainTuple, count: number): number[] {
|
|
31
|
+
if (count <= 0) return [];
|
|
32
|
+
if (count === 1) return [domain[0]];
|
|
33
|
+
const span = domain[1] - domain[0];
|
|
34
|
+
return Array.from({ length: count }, (_, index) => domain[0] + (span * index) / (count - 1));
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** One axis tick: what it means, and where it sits. */
|
|
2
|
+
export type ChartTick = {
|
|
3
|
+
/** The domain value. Epoch milliseconds on a time scale. */
|
|
4
|
+
readonly value: number;
|
|
5
|
+
/** Canvas position in points, already through the scale. */
|
|
6
|
+
readonly position: number;
|
|
7
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { timeTicks } from "./time-ticks";
|
|
3
|
+
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
|
|
6
|
+
describe("timeTicks", () => {
|
|
7
|
+
test("lands on local midnight across a week", () => {
|
|
8
|
+
const start = new Date(2026, 0, 1).getTime();
|
|
9
|
+
const ticks = timeTicks([start, start + 7 * DAY], 7);
|
|
10
|
+
expect(ticks.length).toBeGreaterThan(0);
|
|
11
|
+
for (const tick of ticks) {
|
|
12
|
+
const date = new Date(tick);
|
|
13
|
+
expect(date.getHours()).toBe(0);
|
|
14
|
+
expect(date.getMinutes()).toBe(0);
|
|
15
|
+
expect(date.getSeconds()).toBe(0);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("lands on the first of the month across a year", () => {
|
|
20
|
+
const start = new Date(2026, 0, 1).getTime();
|
|
21
|
+
const end = new Date(2027, 0, 1).getTime();
|
|
22
|
+
for (const tick of timeTicks([start, end], 12)) {
|
|
23
|
+
expect(new Date(tick).getDate()).toBe(1);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("keeps midnight across a DST transition", () => {
|
|
28
|
+
// New Zealand leaves DST on 2026-04-05 and enters it on 2026-09-27. A
|
|
29
|
+
// ladder snapping on epoch multiples would put every tick an hour off
|
|
30
|
+
// midnight on one side of each boundary; d3-time uses calendar
|
|
31
|
+
// arithmetic and does not.
|
|
32
|
+
const previous = process.env.TZ;
|
|
33
|
+
process.env.TZ = "Pacific/Auckland";
|
|
34
|
+
try {
|
|
35
|
+
for (const [year, month, day] of [
|
|
36
|
+
[2026, 2, 30],
|
|
37
|
+
[2026, 8, 21],
|
|
38
|
+
] as const) {
|
|
39
|
+
const start = new Date(year, month, day).getTime();
|
|
40
|
+
const ticks = timeTicks([start, start + 14 * DAY], 14);
|
|
41
|
+
expect(ticks.length).toBeGreaterThan(0);
|
|
42
|
+
for (const tick of ticks) expect(new Date(tick).getHours()).toBe(0);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
process.env.TZ = previous;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("stays inside the hour on a sub-day span", () => {
|
|
50
|
+
const start = new Date(2026, 0, 1, 9, 0, 0).getTime();
|
|
51
|
+
const ticks = timeTicks([start, start + 6 * 3_600_000], 6);
|
|
52
|
+
expect(ticks.length).toBeGreaterThan(0);
|
|
53
|
+
for (const tick of ticks) expect(new Date(tick).getMinutes() % 15).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("degenerates safely", () => {
|
|
57
|
+
expect(timeTicks([0, DAY], 0)).toEqual([]);
|
|
58
|
+
expect(timeTicks([5, 5], 4)).toEqual([5]);
|
|
59
|
+
expect(timeTicks([Number.NaN, DAY], 4)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { scaleTime } from "d3-scale";
|
|
2
|
+
import type { DomainTuple } from "../scale/scale.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tick values across a time domain, as epoch milliseconds.
|
|
6
|
+
*
|
|
7
|
+
* This delegates to `scaleTime` rather than walking a fixed interval ladder,
|
|
8
|
+
* and that is the single biggest thing `d3-scale` buys this package. Local
|
|
9
|
+
* calendar boundaries are not evenly spaced: a DST spring-forward day is 23
|
|
10
|
+
* hours, months are four different lengths, and a hand-rolled ladder that
|
|
11
|
+
* snaps on epoch multiples puts every tick an hour off midnight for half the
|
|
12
|
+
* year. d3-time already knows all of it.
|
|
13
|
+
*/
|
|
14
|
+
export function timeTicks(domain: DomainTuple, count: number): number[] {
|
|
15
|
+
if (count <= 0) return [];
|
|
16
|
+
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1])) return [];
|
|
17
|
+
if (domain[0] === domain[1]) return [domain[0]];
|
|
18
|
+
return scaleTime()
|
|
19
|
+
.domain([new Date(domain[0]), new Date(domain[1])])
|
|
20
|
+
.ticks(Math.max(1, Math.round(count)))
|
|
21
|
+
.map((date) => date.getTime());
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { asNumber, isPlottable } from "./as-number";
|
|
3
|
+
|
|
4
|
+
describe("asNumber", () => {
|
|
5
|
+
test("passes a number through, including zero and negatives", () => {
|
|
6
|
+
expect(asNumber(0)).toBe(0);
|
|
7
|
+
expect(asNumber(-12.5)).toBe(-12.5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("reads a Date as epoch milliseconds", () => {
|
|
11
|
+
expect(asNumber(new Date(1_700_000_000_000))).toBe(1_700_000_000_000);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("parses a numeric string", () => {
|
|
15
|
+
expect(asNumber("42")).toBe(42);
|
|
16
|
+
expect(asNumber(" -3.5 ")).toBe(-3.5);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns NaN for a label rather than throwing", () => {
|
|
20
|
+
expect(asNumber("January")).toBeNaN();
|
|
21
|
+
expect(asNumber(null)).toBeNaN();
|
|
22
|
+
expect(asNumber(undefined)).toBeNaN();
|
|
23
|
+
expect(asNumber({})).toBeNaN();
|
|
24
|
+
expect(asNumber("")).toBeNaN();
|
|
25
|
+
expect(asNumber(" ")).toBeNaN();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("keeps an already-NaN number NaN", () => {
|
|
29
|
+
expect(asNumber(Number.NaN)).toBeNaN();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isPlottable", () => {
|
|
34
|
+
test("accepts finite numbers only", () => {
|
|
35
|
+
expect(isPlottable(0)).toBe(true);
|
|
36
|
+
expect(isPlottable(-1)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("rejects the gap marker and every non-finite number", () => {
|
|
40
|
+
expect(isPlottable(null)).toBe(false);
|
|
41
|
+
expect(isPlottable(undefined)).toBe(false);
|
|
42
|
+
expect(isPlottable(Number.NaN)).toBe(false);
|
|
43
|
+
expect(isPlottable(Number.POSITIVE_INFINITY)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value as a number, or `NaN` when it is not one.
|
|
3
|
+
*
|
|
4
|
+
* A chart's x field is routinely a `Date` or a string label rather than a
|
|
5
|
+
* number. A `Date` is its epoch milliseconds; a numeric string is that number;
|
|
6
|
+
* anything else is `NaN`, and the caller substitutes the datum's index.
|
|
7
|
+
*
|
|
8
|
+
* It returns `NaN` rather than throwing on purpose. One unparseable row in a
|
|
9
|
+
* thousand should leave a gap in the line, not blank the whole chart.
|
|
10
|
+
*/
|
|
11
|
+
export function asNumber(value: unknown): number {
|
|
12
|
+
if (typeof value === "number") return value;
|
|
13
|
+
if (value instanceof Date) return value.getTime();
|
|
14
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
15
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
16
|
+
return Number.NaN;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether a value can be drawn — finite, and not the `null` that marks a gap. */
|
|
20
|
+
export function isPlottable(value: number | null | undefined): value is number {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clamp } from "./clamp";
|
|
3
|
+
|
|
4
|
+
describe("clamp", () => {
|
|
5
|
+
test("holds a value inside the bounds", () => {
|
|
6
|
+
expect(clamp(5, 0, 10)).toBe(5);
|
|
7
|
+
expect(clamp(-1, 0, 10)).toBe(0);
|
|
8
|
+
expect(clamp(11, 0, 10)).toBe(10);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("returns the bound when the range is a point", () => {
|
|
12
|
+
expect(clamp(5, 3, 3)).toBe(3);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("passes the value through when the bounds are inverted", () => {
|
|
16
|
+
// A plot rect measured before layout can produce max < min. Returning the
|
|
17
|
+
// input keeps a finite number flowing; pinning to either bound would put
|
|
18
|
+
// a mark at an edge it was never near.
|
|
19
|
+
expect(clamp(5, 10, 0)).toBe(5);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `value` held inside `[min, max]`.
|
|
3
|
+
*
|
|
4
|
+
* Flat worklet: it calls nothing and closes over nothing, so it is safe to
|
|
5
|
+
* reference from a gesture callback on the UI thread. See the package
|
|
6
|
+
* AGENTS.md — a module-scope worklet that calls another module-scope worklet
|
|
7
|
+
* binds at import time in source order, and the UI thread gets
|
|
8
|
+
* `undefined is not a function`.
|
|
9
|
+
*/
|
|
10
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
11
|
+
"worklet";
|
|
12
|
+
if (min > max) return value;
|
|
13
|
+
if (value < min) return min;
|
|
14
|
+
if (value > max) return max;
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extent, unionExtents } from "./extent";
|
|
3
|
+
|
|
4
|
+
describe("extent", () => {
|
|
5
|
+
test("finds min and max", () => {
|
|
6
|
+
expect(extent([3, 1, 4, 1, 5])).toEqual([1, 5]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("skips gaps and non-finite values", () => {
|
|
10
|
+
expect(extent([3, null, 1, undefined, Number.NaN, Number.POSITIVE_INFINITY, 5])).toEqual([1, 5]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns null for nothing plottable, distinguishing it from all-zero", () => {
|
|
14
|
+
expect(extent([])).toBeNull();
|
|
15
|
+
expect(extent([null, undefined, Number.NaN])).toBeNull();
|
|
16
|
+
expect(extent([0, 0, 0])).toEqual([0, 0]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("collapses a single point to a zero-width extent", () => {
|
|
20
|
+
expect(extent([7])).toEqual([7, 7]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("unionExtents", () => {
|
|
25
|
+
test("spans every series", () => {
|
|
26
|
+
expect(unionExtents([[1, 4], [-2, 3], null])).toEqual([-2, 4]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns null when every series is empty", () => {
|
|
30
|
+
expect(unionExtents([null, null])).toBeNull();
|
|
31
|
+
expect(unionExtents([])).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `[min, max]` of everything finite in `values`, or `null` when nothing is.
|
|
3
|
+
*
|
|
4
|
+
* `null` rather than `[0, 0]` because an empty series and a series that is
|
|
5
|
+
* genuinely all zeroes need different domains, and collapsing them here would
|
|
6
|
+
* make that impossible to tell apart later.
|
|
7
|
+
*/
|
|
8
|
+
export function extent(values: Iterable<number | null | undefined>): readonly [number, number] | null {
|
|
9
|
+
let min = Number.POSITIVE_INFINITY;
|
|
10
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
11
|
+
let seen = false;
|
|
12
|
+
|
|
13
|
+
for (const value of values) {
|
|
14
|
+
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
|
15
|
+
seen = true;
|
|
16
|
+
if (value < min) min = value;
|
|
17
|
+
if (value > max) max = value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return seen ? [min, max] : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The union of several extents, skipping the empty ones. */
|
|
24
|
+
export function unionExtents(extents: Iterable<readonly [number, number] | null>): readonly [number, number] | null {
|
|
25
|
+
let min = Number.POSITIVE_INFINITY;
|
|
26
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
27
|
+
let seen = false;
|
|
28
|
+
|
|
29
|
+
for (const current of extents) {
|
|
30
|
+
if (current === null) continue;
|
|
31
|
+
seen = true;
|
|
32
|
+
if (current[0] < min) min = current[0];
|
|
33
|
+
if (current[1] > max) max = current[1];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return seen ? [min, max] : null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readAt } from "./read-at";
|
|
3
|
+
|
|
4
|
+
describe("readAt", () => {
|
|
5
|
+
test("reads a number", () => {
|
|
6
|
+
expect(readAt([1, 2, 3], 1)).toBe(2);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("a null, a missing index and the -1 of no datum all read as NaN", () => {
|
|
10
|
+
expect(Number.isNaN(readAt([1, null, 3], 1))).toBe(true);
|
|
11
|
+
expect(Number.isNaN(readAt([1, 2, 3], 7))).toBe(true);
|
|
12
|
+
expect(Number.isNaN(readAt([1, 2, 3], -1))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `list[index]` as a number, with a null, a missing index or the `-1` that
|
|
3
|
+
* means "no datum" all reading as `NaN`.
|
|
4
|
+
*
|
|
5
|
+
* Flat worklet: it calls nothing and closes over nothing, so the scrub can
|
|
6
|
+
* read a series on the UI thread without a branch per field. `NaN` rather
|
|
7
|
+
* than a throw because a shared value that is never written is a dot parked
|
|
8
|
+
* off-screen, and a worklet that throws is a chart that stops responding.
|
|
9
|
+
*/
|
|
10
|
+
export function readAt(list: readonly (number | null)[], index: number): number {
|
|
11
|
+
"worklet";
|
|
12
|
+
const value = list[index];
|
|
13
|
+
return value === null || value === undefined ? Number.NaN : value;
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sidesOf, valueFromSidedNumber } from "./sided-number";
|
|
3
|
+
|
|
4
|
+
describe("valueFromSidedNumber", () => {
|
|
5
|
+
test("spreads a bare number to every side", () => {
|
|
6
|
+
expect(valueFromSidedNumber(8, "top")).toBe(8);
|
|
7
|
+
expect(valueFromSidedNumber(8, "left")).toBe(8);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("reads one side of an object and defaults the rest to zero", () => {
|
|
11
|
+
expect(valueFromSidedNumber({ left: 40 }, "left")).toBe(40);
|
|
12
|
+
expect(valueFromSidedNumber({ left: 40 }, "right")).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("treats undefined and non-finite as zero", () => {
|
|
16
|
+
expect(valueFromSidedNumber(undefined, "top")).toBe(0);
|
|
17
|
+
expect(valueFromSidedNumber(Number.NaN, "top")).toBe(0);
|
|
18
|
+
expect(valueFromSidedNumber({ top: Number.POSITIVE_INFINITY }, "top")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("sidesOf", () => {
|
|
23
|
+
test("returns all four", () => {
|
|
24
|
+
expect(sidesOf({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({ top: 1, right: 2, bottom: 3, left: 4 });
|
|
25
|
+
expect(sidesOf(5)).toEqual({ top: 5, right: 5, bottom: 5, left: 5 });
|
|
26
|
+
expect(sidesOf(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A number for every side, or one number for all four. */
|
|
2
|
+
export type SidedNumber =
|
|
3
|
+
| number
|
|
4
|
+
| {
|
|
5
|
+
readonly top?: number;
|
|
6
|
+
readonly right?: number;
|
|
7
|
+
readonly bottom?: number;
|
|
8
|
+
readonly left?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Side = "top" | "right" | "bottom" | "left";
|
|
12
|
+
|
|
13
|
+
/** One side's value, defaulting to zero. */
|
|
14
|
+
export function valueFromSidedNumber(value: SidedNumber | undefined, side: Side): number {
|
|
15
|
+
if (value === undefined) return 0;
|
|
16
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
17
|
+
const side_ = value[side];
|
|
18
|
+
return typeof side_ === "number" && Number.isFinite(side_) ? side_ : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** All four sides at once, in `top right bottom left` order. */
|
|
22
|
+
export function sidesOf(value: SidedNumber | undefined): {
|
|
23
|
+
readonly top: number;
|
|
24
|
+
readonly right: number;
|
|
25
|
+
readonly bottom: number;
|
|
26
|
+
readonly left: number;
|
|
27
|
+
} {
|
|
28
|
+
return {
|
|
29
|
+
top: valueFromSidedNumber(value, "top"),
|
|
30
|
+
right: valueFromSidedNumber(value, "right"),
|
|
31
|
+
bottom: valueFromSidedNumber(value, "bottom"),
|
|
32
|
+
left: valueFromSidedNumber(value, "left"),
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/docs.test.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { PACKAGE_ROOT, SRC, sourceFiles } from "./source-tree.test";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Documentation is part of the change.
|
|
8
|
+
*
|
|
9
|
+
* `@delacour/react-native-ui` grew this test because `Radio` shipped undocumented
|
|
10
|
+
* and nothing caught it for fifteen commits. The same applies here, scoped to
|
|
11
|
+
* the top-level folders rather than every nested one — a doc per directory of
|
|
12
|
+
* four utility modules is noise, a doc per subsystem is not.
|
|
13
|
+
*/
|
|
14
|
+
describe("docs", () => {
|
|
15
|
+
const folders = readdirSync(SRC)
|
|
16
|
+
.map((entry) => join(SRC, entry))
|
|
17
|
+
.filter((path) => statSync(path).isDirectory())
|
|
18
|
+
.filter((path) => sourceFiles(path, { skipTests: true }).length > 0);
|
|
19
|
+
|
|
20
|
+
test("the package documents itself", () => {
|
|
21
|
+
const doc = join(PACKAGE_ROOT, "AGENTS.md");
|
|
22
|
+
expect(existsSync(doc)).toBe(true);
|
|
23
|
+
expect(readFileSync(doc, "utf8").split("\n").length).toBeGreaterThan(20);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("finds the subsystems, so a broken walker cannot pass silently", () => {
|
|
27
|
+
expect(folders.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every subsystem has an AGENTS.md that is not a stub", () => {
|
|
31
|
+
const missing: string[] = [];
|
|
32
|
+
for (const folder of folders) {
|
|
33
|
+
const doc = join(folder, "AGENTS.md");
|
|
34
|
+
if (!existsSync(doc)) {
|
|
35
|
+
missing.push(`${folder.slice(SRC.length + 1)} has no AGENTS.md`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const content = readFileSync(doc, "utf8");
|
|
39
|
+
if (!content.startsWith("# ") || content.split("\n").length < 10) {
|
|
40
|
+
missing.push(`${folder.slice(SRC.length + 1)}/AGENTS.md is a stub`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
expect(missing).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { read, relative, SRC, sourceFiles } from "./source-tree.test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The flat-worklet rule.
|
|
6
|
+
*
|
|
7
|
+
* **A worklet declared at module scope may close over module-scope constants
|
|
8
|
+
* but never over another function, imported or local.** Any helper it needs is
|
|
9
|
+
* declared inside its own body.
|
|
10
|
+
*
|
|
11
|
+
* Module scope is the whole rule. A module-scope worklet that calls another
|
|
12
|
+
* module-scope worklet binds at import time in source order, and the UI thread
|
|
13
|
+
* gets `undefined is not a function` — at the moment a finger touches the
|
|
14
|
+
* chart, on a device, with a stack that names neither function. Four
|
|
15
|
+
* components in `@delacour/react-native-ui` document having learned this.
|
|
16
|
+
*
|
|
17
|
+
* A worklet created *inside* a hook is captured by ordinary closure and may
|
|
18
|
+
* call whatever it likes, including the module-scope worklets here. That is
|
|
19
|
+
* how the gesture layer composes `closestIndex` and `getYForX` without
|
|
20
|
+
* duplicating sixty lines of solver.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Members of these are always safe — they exist in every worklet runtime. */
|
|
24
|
+
const GLOBAL_NAMESPACES = ["Math", "Number", "Array", "Object", "String", "JSON", "Boolean", "Date"];
|
|
25
|
+
|
|
26
|
+
/** Control-flow keywords that look like calls to a regex. */
|
|
27
|
+
const KEYWORDS = new Set(["if", "for", "while", "switch", "catch", "return", "typeof", "function", "new"]);
|
|
28
|
+
|
|
29
|
+
/** The body of the block opening at `open`, by brace matching. */
|
|
30
|
+
function blockAt(source: string, open: number): string {
|
|
31
|
+
let depth = 0;
|
|
32
|
+
for (let index = open; index < source.length; index += 1) {
|
|
33
|
+
const char = source[index];
|
|
34
|
+
if (char === "{") depth += 1;
|
|
35
|
+
else if (char === "}") {
|
|
36
|
+
depth -= 1;
|
|
37
|
+
if (depth === 0) return source.slice(open + 1, index);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return source.slice(open + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bodies of every column-zero `function` whose first statement is the directive. */
|
|
44
|
+
function moduleScopeWorklets(source: string): { readonly name: string; readonly body: string }[] {
|
|
45
|
+
const found: { name: string; body: string }[] = [];
|
|
46
|
+
const declaration = /^(?:export\s+)?function\s+(\w+)/gm;
|
|
47
|
+
|
|
48
|
+
for (let match = declaration.exec(source); match !== null; match = declaration.exec(source)) {
|
|
49
|
+
const open = source.indexOf("{", match.index);
|
|
50
|
+
if (open === -1) continue;
|
|
51
|
+
const body = blockAt(source, open);
|
|
52
|
+
if (!body.trimStart().startsWith('"worklet";')) continue;
|
|
53
|
+
found.push({ name: match[1] as string, body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Value names this module imports — the ones a module-scope worklet must not call. */
|
|
60
|
+
function importedValues(source: string): Set<string> {
|
|
61
|
+
const names = new Set<string>();
|
|
62
|
+
const pattern = /^\s*import\s+([\s\S]*?)\s*from\s*["'][^"']+["']/gm;
|
|
63
|
+
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
|
|
64
|
+
const clause = (match[1] as string).trim();
|
|
65
|
+
if (clause.startsWith("type ")) continue;
|
|
66
|
+
const named = clause.match(/\{([\s\S]*)\}/);
|
|
67
|
+
if (named) {
|
|
68
|
+
for (const raw of (named[1] as string).split(",")) {
|
|
69
|
+
const specifier = raw.trim();
|
|
70
|
+
if (specifier === "" || specifier.startsWith("type ")) continue;
|
|
71
|
+
names.add((specifier.split(/\s+as\s+/).pop() as string).trim());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const bare = clause
|
|
75
|
+
.replace(/\{[\s\S]*\}/, "")
|
|
76
|
+
.replace(/,/g, "")
|
|
77
|
+
.trim();
|
|
78
|
+
if (bare !== "" && !bare.startsWith("*")) names.add(bare);
|
|
79
|
+
}
|
|
80
|
+
return names;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Functions declared at module scope — also off limits inside a module-scope worklet. */
|
|
84
|
+
function functionNames(source: string): Set<string> {
|
|
85
|
+
const names = new Set<string>();
|
|
86
|
+
const patterns = [
|
|
87
|
+
/(?:^|\n)\s*(?:export\s+)?function\s+(\w+)/g,
|
|
88
|
+
/(?:^|\n)\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*(?::[^=]+)?=>/g,
|
|
89
|
+
];
|
|
90
|
+
for (const pattern of patterns) {
|
|
91
|
+
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
|
|
92
|
+
names.add(match[1] as string);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return names;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("flat worklets", () => {
|
|
99
|
+
const files = sourceFiles(SRC, { skipTests: true });
|
|
100
|
+
|
|
101
|
+
test("finds the module-scope worklets, so a broken walker cannot pass silently", () => {
|
|
102
|
+
const total = files.reduce((count, file) => count + moduleScopeWorklets(read(file)).length, 0);
|
|
103
|
+
expect(total).toBeGreaterThan(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("no module-scope worklet calls an imported or module-scope function", () => {
|
|
107
|
+
const offenders: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
const source = read(file);
|
|
111
|
+
if (!source.includes('"worklet";')) continue;
|
|
112
|
+
const forbidden = new Set([...importedValues(source), ...functionNames(source)]);
|
|
113
|
+
|
|
114
|
+
for (const worklet of moduleScopeWorklets(source)) {
|
|
115
|
+
const declaredInside = functionNames(worklet.body);
|
|
116
|
+
const calls = /(?:^|[^.\w$])(\w+)\s*\(/g;
|
|
117
|
+
|
|
118
|
+
for (let call = calls.exec(worklet.body); call !== null; call = calls.exec(worklet.body)) {
|
|
119
|
+
const name = call[1] as string;
|
|
120
|
+
if (KEYWORDS.has(name) || GLOBAL_NAMESPACES.includes(name)) continue;
|
|
121
|
+
if (declaredInside.has(name) || name === worklet.name) continue;
|
|
122
|
+
if (!forbidden.has(name)) continue;
|
|
123
|
+
offenders.push(`${relative(file)}: ${worklet.name}() calls ${name}()`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
expect(offenders).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|