@delacour/react-native-charts 0.1.0-alpha.20260925053522
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/package.json +61 -0
- package/src/animation/AGENTS.md +40 -0
- package/src/animation/animation.types.ts +16 -0
- package/src/animation/index.ts +2 -0
- package/src/animation/use-animated-path.ts +74 -0
- package/src/cartesian/AGENTS.md +110 -0
- package/src/cartesian/axes/chart-x-axis.tsx +52 -0
- package/src/cartesian/axes/chart-y-axis.tsx +56 -0
- package/src/cartesian/cartesian-chart.context.tsx +43 -0
- package/src/cartesian/cartesian-chart.tsx +132 -0
- package/src/cartesian/cartesian-chart.types.ts +181 -0
- package/src/cartesian/hooks/use-canvas-size.ts +37 -0
- package/src/cartesian/hooks/use-chart-model.ts +278 -0
- package/src/cartesian/index.ts +41 -0
- package/src/cartesian/marks/chart-area.tsx +83 -0
- package/src/cartesian/marks/chart-bar-group.tsx +132 -0
- package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
- package/src/cartesian/marks/chart-bar.tsx +195 -0
- package/src/cartesian/marks/chart-candlestick.tsx +148 -0
- package/src/cartesian/marks/chart-cursor.tsx +168 -0
- package/src/cartesian/marks/chart-grid.tsx +46 -0
- package/src/cartesian/marks/chart-line.tsx +73 -0
- package/src/cartesian/marks/chart-scatter.tsx +71 -0
- package/src/core/AGENTS.md +77 -0
- package/src/core/animation/morph-strategy.test.ts +38 -0
- package/src/core/animation/morph-strategy.ts +53 -0
- package/src/core/animation/resample-points.test.ts +68 -0
- package/src/core/animation/resample-points.ts +64 -0
- package/src/core/chart.types.ts +62 -0
- package/src/core/curve/build-area.test.ts +48 -0
- package/src/core/curve/build-area.ts +58 -0
- package/src/core/curve/build-line.test.ts +64 -0
- package/src/core/curve/build-line.ts +53 -0
- package/src/core/curve/curves.test.ts +20 -0
- package/src/core/curve/curves.ts +48 -0
- package/src/core/geometry/band.test.ts +79 -0
- package/src/core/geometry/band.ts +111 -0
- package/src/core/geometry/category-domain.test.ts +33 -0
- package/src/core/geometry/category-domain.ts +18 -0
- package/src/core/geometry/chart-bounds.test.ts +36 -0
- package/src/core/geometry/chart-bounds.ts +47 -0
- package/src/core/geometry/chart-layout.test.ts +230 -0
- package/src/core/geometry/chart-layout.ts +162 -0
- package/src/core/geometry/domain.test.ts +87 -0
- package/src/core/geometry/domain.ts +94 -0
- package/src/core/geometry/grid-lines.test.ts +48 -0
- package/src/core/geometry/grid-lines.ts +43 -0
- package/src/core/geometry/stack.test.ts +151 -0
- package/src/core/geometry/stack.ts +109 -0
- package/src/core/geometry/step.test.ts +34 -0
- package/src/core/geometry/step.ts +21 -0
- package/src/core/geometry/transform-input-data.test.ts +105 -0
- package/src/core/geometry/transform-input-data.ts +88 -0
- package/src/core/geometry/x-values.test.ts +37 -0
- package/src/core/geometry/x-values.ts +36 -0
- package/src/core/index.ts +109 -0
- package/src/core/interaction/closest-index.test.ts +36 -0
- package/src/core/interaction/closest-index.ts +27 -0
- package/src/core/interaction/path-segments.test.ts +60 -0
- package/src/core/interaction/path-segments.ts +155 -0
- package/src/core/interaction/y-for-x.test.ts +135 -0
- package/src/core/interaction/y-for-x.ts +106 -0
- package/src/core/polar/build-slice-path.test.ts +173 -0
- package/src/core/polar/build-slice-path.ts +98 -0
- package/src/core/polar/label-position.test.ts +41 -0
- package/src/core/polar/label-position.ts +18 -0
- package/src/core/polar/pie-input.test.ts +36 -0
- package/src/core/polar/pie-input.ts +32 -0
- package/src/core/polar/polar-point.test.ts +55 -0
- package/src/core/polar/polar-point.ts +29 -0
- package/src/core/polar/polar.types.ts +47 -0
- package/src/core/polar/resolve-layout.test.ts +63 -0
- package/src/core/polar/resolve-layout.ts +55 -0
- package/src/core/polar/resolve-slices.test.ts +89 -0
- package/src/core/polar/resolve-slices.ts +58 -0
- package/src/core/polar/slice-index-at.test.ts +52 -0
- package/src/core/polar/slice-index-at.ts +42 -0
- package/src/core/polar/slice-opacity.test.ts +29 -0
- package/src/core/polar/slice-opacity.ts +12 -0
- package/src/core/scale/make-scale.test.ts +55 -0
- package/src/core/scale/make-scale.ts +78 -0
- package/src/core/scale/scale.test.ts +76 -0
- package/src/core/scale/scale.ts +65 -0
- package/src/core/scale/scale.types.ts +30 -0
- package/src/core/shape/build-bars.test.ts +203 -0
- package/src/core/shape/build-bars.ts +167 -0
- package/src/core/shape/build-candles.test.ts +156 -0
- package/src/core/shape/build-candles.ts +153 -0
- package/src/core/shape/build-scatter.test.ts +103 -0
- package/src/core/shape/build-scatter.ts +81 -0
- package/src/core/shape/rect-path.test.ts +61 -0
- package/src/core/shape/rect-path.ts +69 -0
- package/src/core/text/axis-gutters.test.ts +70 -0
- package/src/core/text/axis-gutters.ts +70 -0
- package/src/core/text/bar-label.test.ts +92 -0
- package/src/core/text/bar-label.ts +61 -0
- package/src/core/text/format-tick.test.ts +87 -0
- package/src/core/text/format-tick.ts +76 -0
- package/src/core/text/label-anchor.test.ts +59 -0
- package/src/core/text/label-anchor.ts +62 -0
- package/src/core/ticks/build-ticks.test.ts +38 -0
- package/src/core/ticks/build-ticks.ts +36 -0
- package/src/core/ticks/linear-ticks.test.ts +56 -0
- package/src/core/ticks/linear-ticks.ts +67 -0
- package/src/core/ticks/tick-count.test.ts +46 -0
- package/src/core/ticks/tick-count.ts +35 -0
- package/src/core/ticks/tick.types.ts +7 -0
- package/src/core/ticks/time-ticks.test.ts +61 -0
- package/src/core/ticks/time-ticks.ts +22 -0
- package/src/core/util/as-number.test.ts +45 -0
- package/src/core/util/as-number.ts +22 -0
- package/src/core/util/clamp.test.ts +21 -0
- package/src/core/util/clamp.ts +16 -0
- package/src/core/util/extent.test.ts +33 -0
- package/src/core/util/extent.ts +37 -0
- package/src/core/util/read-at.test.ts +14 -0
- package/src/core/util/read-at.ts +14 -0
- package/src/core/util/sided-number.test.ts +28 -0
- package/src/core/util/sided-number.ts +34 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +84 -0
- package/src/gesture/gesture-overlay.tsx +21 -0
- package/src/gesture/gesture.types.ts +143 -0
- package/src/gesture/index.ts +12 -0
- package/src/gesture/use-chart-scrub.ts +44 -0
- package/src/gesture/use-scrub-gesture.ts +162 -0
- package/src/index.ts +6 -0
- package/src/no-classname.test.ts +27 -0
- package/src/polar/AGENTS.md +55 -0
- package/src/polar/hooks/use-polar-model.ts +88 -0
- package/src/polar/index.ts +14 -0
- package/src/polar/marks/pie-inset.tsx +70 -0
- package/src/polar/marks/pie-label.tsx +71 -0
- package/src/polar/marks/pie-slice.tsx +82 -0
- package/src/polar/marks/pie-slices.tsx +60 -0
- package/src/polar/polar-chart.context.tsx +37 -0
- package/src/polar/polar-chart.tsx +91 -0
- package/src/polar/polar-chart.types.ts +82 -0
- package/src/purity.test.ts +75 -0
- package/src/skia/AGENTS.md +39 -0
- package/src/skia/build-path.ts +22 -0
- package/src/skia/font.ts +78 -0
- package/src/skia/index.ts +9 -0
- package/src/source-tree.test.ts +33 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
4
|
+
import { categoryDomainCovers } from "./category-domain";
|
|
5
|
+
|
|
6
|
+
const at = (xValue: number): ChartPoint => ({ x: 0, y: 0, xValue, yValue: 1 });
|
|
7
|
+
const linear = (domain: readonly [number, number]): ScaleDescriptor => ({ kind: "linear", domain, range: [0, 100] });
|
|
8
|
+
|
|
9
|
+
describe("categoryDomainCovers", () => {
|
|
10
|
+
test("holds when the domain reaches half a step past the outermost categories", () => {
|
|
11
|
+
expect(categoryDomainCovers([at(0), at(1), at(2)], linear([-0.5, 2.5]), 1)).toBe(true);
|
|
12
|
+
expect(categoryDomainCovers([at(0), at(1), at(2)], linear([-1, 3]), 1)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("fails when a bar would straddle the plot's edge", () => {
|
|
16
|
+
expect(categoryDomainCovers([at(0), at(1), at(2)], linear([0, 2]), 1)).toBe(false);
|
|
17
|
+
expect(categoryDomainCovers([at(0), at(1), at(2)], linear([-0.5, 2]), 1)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("reads the categories from xValue, which is the category whichever axis it lies on", () => {
|
|
21
|
+
// A horizontal point has its value on x and its category on y; xValue is still the category.
|
|
22
|
+
const horizontal: ChartPoint[] = [
|
|
23
|
+
{ x: 40, y: 10, xValue: 0, yValue: 4 },
|
|
24
|
+
{ x: 80, y: 90, xValue: 1, yValue: 8 },
|
|
25
|
+
];
|
|
26
|
+
expect(categoryDomainCovers(horizontal, linear([-0.5, 1.5]), 1)).toBe(true);
|
|
27
|
+
expect(categoryDomainCovers(horizontal, linear([0, 1]), 1)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an empty series is covered — there is nothing to warn about", () => {
|
|
31
|
+
expect(categoryDomainCovers([], linear([0, 1]), 1)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChartPoint } from "../chart.types";
|
|
2
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the category scale's domain reaches half a step past the first and
|
|
6
|
+
* last category, which is what a bar centred on each needs to sit inside the
|
|
7
|
+
* plot. `xValue` is the category in either orientation, and the caller hands
|
|
8
|
+
* in whichever canvas scale holds the categories — `yScale` on a horizontal
|
|
9
|
+
* chart — so the check is the same either way round.
|
|
10
|
+
*/
|
|
11
|
+
export function categoryDomainCovers(series: readonly ChartPoint[], scale: ScaleDescriptor, step: number): boolean {
|
|
12
|
+
const first = series[0]?.xValue;
|
|
13
|
+
const last = series[series.length - 1]?.xValue;
|
|
14
|
+
if (first === undefined || last === undefined) return true;
|
|
15
|
+
const half = step / 2;
|
|
16
|
+
const [lo, hi] = scale.domain;
|
|
17
|
+
return Math.min(first, last) - half >= lo && Math.max(first, last) + half <= hi;
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getChartBounds, hasArea, NO_GUTTERS } from "./chart-bounds";
|
|
3
|
+
|
|
4
|
+
const size = { width: 300, height: 200 };
|
|
5
|
+
|
|
6
|
+
describe("getChartBounds", () => {
|
|
7
|
+
test("is the whole canvas with no padding or gutters", () => {
|
|
8
|
+
expect(getChartBounds(size, undefined, NO_GUTTERS)).toEqual({ left: 0, top: 0, right: 300, bottom: 200 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("subtracts padding and gutters from the right sides", () => {
|
|
12
|
+
const bounds = getChartBounds(size, { left: 10, bottom: 5 }, { top: 0, right: 4, bottom: 20, left: 30 });
|
|
13
|
+
expect(bounds).toEqual({ left: 40, top: 0, right: 296, bottom: 175 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("collapses rather than inverting when the gutters exceed the canvas", () => {
|
|
17
|
+
// Real on the first frame, before onLayout reports a size. An inverted
|
|
18
|
+
// rect gives a negative scale range and draws every mark mirrored.
|
|
19
|
+
const bounds = getChartBounds({ width: 20, height: 20 }, undefined, { top: 0, right: 0, bottom: 40, left: 40 });
|
|
20
|
+
expect(bounds.right).toBeGreaterThanOrEqual(bounds.left);
|
|
21
|
+
expect(bounds.bottom).toBeGreaterThanOrEqual(bounds.top);
|
|
22
|
+
expect(hasArea(bounds)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("treats a non-finite canvas as zero", () => {
|
|
26
|
+
const bounds = getChartBounds({ width: Number.NaN, height: 200 }, undefined, NO_GUTTERS);
|
|
27
|
+
expect(Number.isFinite(bounds.right)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("hasArea", () => {
|
|
32
|
+
test("is false for a rect with no room to draw in", () => {
|
|
33
|
+
expect(hasArea({ left: 0, top: 0, right: 0, bottom: 10 })).toBe(false);
|
|
34
|
+
expect(hasArea({ left: 0, top: 0, right: 10, bottom: 10 })).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ChartBounds, ChartSize } from "../chart.types";
|
|
2
|
+
import { type SidedNumber, sidesOf } from "../util/sided-number";
|
|
3
|
+
|
|
4
|
+
/** Space each axis reserves outside the plot rect, in points. */
|
|
5
|
+
export type ChartGutters = {
|
|
6
|
+
readonly top: number;
|
|
7
|
+
readonly right: number;
|
|
8
|
+
readonly bottom: number;
|
|
9
|
+
readonly left: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const NO_GUTTERS: ChartGutters = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The plot rect — the canvas minus the caller's padding and the axis gutters.
|
|
16
|
+
*
|
|
17
|
+
* Collapses to a zero-size rect at the top-left rather than inverting when the
|
|
18
|
+
* gutters exceed the canvas. That happens for real on the first frame, before
|
|
19
|
+
* `onLayout` has reported a size, and an inverted rect produces a negative
|
|
20
|
+
* scale range that draws every mark mirrored off-screen.
|
|
21
|
+
*/
|
|
22
|
+
export function getChartBounds(
|
|
23
|
+
size: ChartSize,
|
|
24
|
+
padding: SidedNumber | undefined,
|
|
25
|
+
gutters: ChartGutters = NO_GUTTERS
|
|
26
|
+
): ChartBounds {
|
|
27
|
+
const pad = sidesOf(padding);
|
|
28
|
+
const width = Number.isFinite(size.width) ? Math.max(0, size.width) : 0;
|
|
29
|
+
const height = Number.isFinite(size.height) ? Math.max(0, size.height) : 0;
|
|
30
|
+
|
|
31
|
+
const left = pad.left + gutters.left;
|
|
32
|
+
const top = pad.top + gutters.top;
|
|
33
|
+
const right = width - pad.right - gutters.right;
|
|
34
|
+
const bottom = height - pad.bottom - gutters.bottom;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
left,
|
|
38
|
+
top,
|
|
39
|
+
right: Math.max(left, right),
|
|
40
|
+
bottom: Math.max(top, bottom),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether a rect has room to draw in. */
|
|
45
|
+
export function hasArea(bounds: ChartBounds): boolean {
|
|
46
|
+
return bounds.right > bounds.left && bounds.bottom > bounds.top;
|
|
47
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatNumberTick } from "../text/format-tick";
|
|
3
|
+
import { axisRanges, type ChartPlan, pickAxisRoles, placeAxisRoles, planAxis, resolveChartFrame } from "./chart-layout";
|
|
4
|
+
|
|
5
|
+
const canvas = { width: 300, height: 200 };
|
|
6
|
+
|
|
7
|
+
function numericAxis(overrides: Partial<Parameters<typeof planAxis>[0]> = {}) {
|
|
8
|
+
return planAxis({
|
|
9
|
+
domain: [0, 100],
|
|
10
|
+
kind: "linear",
|
|
11
|
+
range: [0, 300],
|
|
12
|
+
tickCount: 5,
|
|
13
|
+
format: formatNumberTick,
|
|
14
|
+
show: true,
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("planAxis", () => {
|
|
20
|
+
test("decides tick values and their labels together", () => {
|
|
21
|
+
const plan = numericAxis();
|
|
22
|
+
expect(plan.tickValues.length).toBeGreaterThan(0);
|
|
23
|
+
expect(plan.labels).toHaveLength(plan.tickValues.length);
|
|
24
|
+
expect(plan.labels[0]).toBe("0");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("produces no labels for a hidden axis, but keeps the values", () => {
|
|
28
|
+
// The values still position a gridline even when nothing is written.
|
|
29
|
+
const plan = numericAxis({ show: false });
|
|
30
|
+
expect(plan.labels).toEqual([]);
|
|
31
|
+
expect(plan.tickValues.length).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("honours explicit tick values", () => {
|
|
35
|
+
expect(numericAxis({ tickValues: [0, 50, 100] }).tickValues).toEqual([0, 50, 100]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("reports the niced domain, so pass two cannot re-nice it", () => {
|
|
39
|
+
const plan = numericAxis({ domain: [3, 97], nice: true });
|
|
40
|
+
expect(plan.domain).toEqual([0, 100]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("nices to its own tick count, so the ends of the axis get labels", () => {
|
|
44
|
+
// Niced at d3's default ten, [28, 91] rounds to [25, 95] and four ticks
|
|
45
|
+
// across it lands on 40/60/80 — three labels, neither at an end.
|
|
46
|
+
const plan = numericAxis({ domain: [28, 91], nice: true, tickCount: 4 });
|
|
47
|
+
expect(plan.domain).toEqual([20, 100]);
|
|
48
|
+
expect(plan.tickValues).toEqual([20, 40, 60, 80, 100]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("uses the caller's formatter", () => {
|
|
52
|
+
expect(numericAxis({ format: (value) => `${value}%` }).labels[0]).toBe("0%");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveChartFrame", () => {
|
|
57
|
+
const plan: ChartPlan = {
|
|
58
|
+
x: numericAxis({ domain: [0, 10], tickValues: [0, 5, 10] }),
|
|
59
|
+
y: numericAxis({ domain: [0, 100], tickValues: [0, 50, 100] }),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const frame = (overrides: Partial<Parameters<typeof resolveChartFrame>[0]> = {}) =>
|
|
63
|
+
resolveChartFrame({
|
|
64
|
+
canvas,
|
|
65
|
+
plan,
|
|
66
|
+
xLabelWidths: [10, 10, 20],
|
|
67
|
+
yLabelWidths: [8, 16, 24],
|
|
68
|
+
lineHeight: 12,
|
|
69
|
+
showXAxis: true,
|
|
70
|
+
showYAxis: true,
|
|
71
|
+
...overrides,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("insets the plot rect by the measured gutters", () => {
|
|
75
|
+
const resolved = frame();
|
|
76
|
+
expect(resolved.bounds.left).toBeGreaterThan(0);
|
|
77
|
+
expect(resolved.bounds.bottom).toBeLessThan(canvas.height);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("keeps the plan's tick values and only moves their positions", () => {
|
|
81
|
+
// The whole point of two passes: values fixed in pass one, positions in two.
|
|
82
|
+
const resolved = frame();
|
|
83
|
+
expect(resolved.xTicks.map((tick) => tick.value)).toEqual([0, 5, 10]);
|
|
84
|
+
expect(resolved.yTicks.map((tick) => tick.value)).toEqual([0, 50, 100]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("puts every tick inside the plot rect", () => {
|
|
88
|
+
const resolved = frame();
|
|
89
|
+
for (const tick of resolved.xTicks) {
|
|
90
|
+
expect(tick.position).toBeGreaterThanOrEqual(resolved.bounds.left - 1e-6);
|
|
91
|
+
expect(tick.position).toBeLessThanOrEqual(resolved.bounds.right + 1e-6);
|
|
92
|
+
}
|
|
93
|
+
for (const tick of resolved.yTicks) {
|
|
94
|
+
expect(tick.position).toBeGreaterThanOrEqual(resolved.bounds.top - 1e-6);
|
|
95
|
+
expect(tick.position).toBeLessThanOrEqual(resolved.bounds.bottom + 1e-6);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("inverts the y range, so a larger value sits higher on screen", () => {
|
|
100
|
+
const resolved = frame();
|
|
101
|
+
const [low, high] = [resolved.yTicks[0], resolved.yTicks.at(-1)];
|
|
102
|
+
expect(high?.position ?? 0).toBeLessThan(low?.position ?? 0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("reclaims the gutter when an axis is hidden", () => {
|
|
106
|
+
const shown = frame();
|
|
107
|
+
const hidden = frame({ showXAxis: false, showYAxis: false, xLabelWidths: [], yLabelWidths: [] });
|
|
108
|
+
expect(hidden.bounds.left).toBeLessThan(shown.bounds.left);
|
|
109
|
+
expect(hidden.bounds.bottom).toBeGreaterThan(shown.bounds.bottom);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("is stable: measuring the same labels twice gives the same rect", () => {
|
|
113
|
+
// If this ever failed the layout would oscillate on every render.
|
|
114
|
+
expect(frame().bounds).toEqual(frame().bounds);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("survives a canvas with no size", () => {
|
|
118
|
+
const resolved = frame({ canvas: { width: 0, height: 0 } });
|
|
119
|
+
expect(Number.isFinite(resolved.bounds.right)).toBe(true);
|
|
120
|
+
expect(resolved.bounds.right).toBeGreaterThanOrEqual(resolved.bounds.left);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reserves the left gutter for category labels planned onto y", () => {
|
|
124
|
+
// A horizontal chart plans its categories into the y range. The frame
|
|
125
|
+
// does not know or care: it measures whatever labels sit on each side.
|
|
126
|
+
const categories = planAxis({
|
|
127
|
+
domain: [0, 2],
|
|
128
|
+
kind: "linear",
|
|
129
|
+
range: [0, 100],
|
|
130
|
+
tickCount: 3,
|
|
131
|
+
tickValues: [0, 1, 2],
|
|
132
|
+
format: (value) => ["January", "February", "March"][value] ?? "",
|
|
133
|
+
show: true,
|
|
134
|
+
});
|
|
135
|
+
const values = planAxis({
|
|
136
|
+
domain: [0, 100],
|
|
137
|
+
kind: "linear",
|
|
138
|
+
range: [0, 300],
|
|
139
|
+
tickCount: 4,
|
|
140
|
+
format: String,
|
|
141
|
+
show: true,
|
|
142
|
+
});
|
|
143
|
+
const frame = resolveChartFrame({
|
|
144
|
+
canvas: { width: 300, height: 100 },
|
|
145
|
+
plan: { x: values, y: categories },
|
|
146
|
+
xLabelWidths: [10, 10, 10, 10],
|
|
147
|
+
yLabelWidths: [60, 62, 40],
|
|
148
|
+
lineHeight: 12,
|
|
149
|
+
showXAxis: true,
|
|
150
|
+
showYAxis: true,
|
|
151
|
+
});
|
|
152
|
+
expect(frame.bounds.left).toBeGreaterThanOrEqual(62);
|
|
153
|
+
expect(frame.yTicks.map((tick) => tick.value)).toEqual([0, 1, 2]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("runs a horizontal chart's category axis top to bottom, so the first row is the top one", () => {
|
|
157
|
+
const categories = planAxis({
|
|
158
|
+
domain: [0, 2],
|
|
159
|
+
kind: "linear",
|
|
160
|
+
range: [0, 100],
|
|
161
|
+
tickCount: 3,
|
|
162
|
+
format: String,
|
|
163
|
+
show: false,
|
|
164
|
+
});
|
|
165
|
+
const values = planAxis({
|
|
166
|
+
domain: [0, 100],
|
|
167
|
+
kind: "linear",
|
|
168
|
+
range: [0, 300],
|
|
169
|
+
tickCount: 4,
|
|
170
|
+
format: String,
|
|
171
|
+
show: false,
|
|
172
|
+
});
|
|
173
|
+
const frame = resolveChartFrame({
|
|
174
|
+
canvas: { width: 300, height: 100 },
|
|
175
|
+
plan: { x: values, y: categories },
|
|
176
|
+
xLabelWidths: [],
|
|
177
|
+
yLabelWidths: [],
|
|
178
|
+
lineHeight: 12,
|
|
179
|
+
showXAxis: false,
|
|
180
|
+
showYAxis: false,
|
|
181
|
+
orientation: "horizontal",
|
|
182
|
+
});
|
|
183
|
+
expect(frame.yScale.range[0]).toBe(frame.bounds.top);
|
|
184
|
+
expect(frame.yScale.range[1]).toBe(frame.bounds.bottom);
|
|
185
|
+
// The first tick — the first category — sits highest on screen.
|
|
186
|
+
expect(frame.yTicks[0]?.position).toBeLessThan(frame.yTicks[2]?.position as number);
|
|
187
|
+
// Values still grow rightward.
|
|
188
|
+
expect(frame.xScale.range).toEqual([frame.bounds.left, frame.bounds.right]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a vertical chart keeps its value axis bottom to top", () => {
|
|
192
|
+
const frame = resolveChartFrame({
|
|
193
|
+
canvas: { width: 300, height: 100 },
|
|
194
|
+
plan: {
|
|
195
|
+
x: planAxis({ domain: [0, 2], kind: "linear", range: [0, 300], tickCount: 3, format: String, show: false }),
|
|
196
|
+
y: planAxis({ domain: [0, 100], kind: "linear", range: [100, 0], tickCount: 4, format: String, show: false }),
|
|
197
|
+
},
|
|
198
|
+
xLabelWidths: [],
|
|
199
|
+
yLabelWidths: [],
|
|
200
|
+
lineHeight: 12,
|
|
201
|
+
showXAxis: false,
|
|
202
|
+
showYAxis: false,
|
|
203
|
+
});
|
|
204
|
+
expect(frame.yScale.range).toEqual([frame.bounds.bottom, frame.bounds.top]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("axis roles", () => {
|
|
209
|
+
const outer = { left: 10, right: 110, top: 5, bottom: 65 };
|
|
210
|
+
|
|
211
|
+
test("vertical puts categories on x and values up y", () => {
|
|
212
|
+
expect(pickAxisRoles("x", "y", "vertical")).toEqual({ category: "x", value: "y" });
|
|
213
|
+
expect(placeAxisRoles("c", "v", "vertical")).toEqual({ x: "c", y: "v" });
|
|
214
|
+
expect(axisRanges(outer, "vertical")).toEqual({ category: [10, 110], value: [65, 5] });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("horizontal puts categories down y, top first, and values along x", () => {
|
|
218
|
+
expect(pickAxisRoles("x", "y", "horizontal")).toEqual({ category: "y", value: "x" });
|
|
219
|
+
expect(placeAxisRoles("c", "v", "horizontal")).toEqual({ x: "v", y: "c" });
|
|
220
|
+
// Top to bottom, so the first row is the top one and positions ascend.
|
|
221
|
+
expect(axisRanges(outer, "horizontal")).toEqual({ category: [5, 65], value: [10, 110] });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("pick and place are inverses", () => {
|
|
225
|
+
for (const orientation of ["vertical", "horizontal"] as const) {
|
|
226
|
+
const picked = pickAxisRoles(1, 2, orientation);
|
|
227
|
+
expect(placeAxisRoles(picked.category, picked.value, orientation)).toEqual({ x: 1, y: 2 });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ChartBounds, ChartOrientation, ChartSize } from "../chart.types";
|
|
2
|
+
import { makeScale } from "../scale/make-scale";
|
|
3
|
+
import type { DomainTuple, RangeTuple, ScaleDescriptor, ScaleType } from "../scale/scale.types";
|
|
4
|
+
import { resolveAxisGutters } from "../text/axis-gutters";
|
|
5
|
+
import { buildTicks } from "../ticks/build-ticks";
|
|
6
|
+
import type { ChartTick } from "../ticks/tick.types";
|
|
7
|
+
import type { SidedNumber } from "../util/sided-number";
|
|
8
|
+
import { getChartBounds } from "./chart-bounds";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Something per axis role, picked out of something per canvas axis.
|
|
12
|
+
*
|
|
13
|
+
* A chart has a category axis and a value axis; the canvas has x and y.
|
|
14
|
+
* Vertical maps category to x; horizontal maps it to y. Every place that
|
|
15
|
+
* would otherwise write `horizontal ? y : x` goes through here, so the swap
|
|
16
|
+
* is made in one spot and cannot be made in one direction only.
|
|
17
|
+
*/
|
|
18
|
+
export function pickAxisRoles<T>(x: T, y: T, orientation: ChartOrientation): { category: T; value: T } {
|
|
19
|
+
return orientation === "horizontal" ? { category: y, value: x } : { category: x, value: y };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The inverse of `pickAxisRoles`: per role back onto the canvas axes. */
|
|
23
|
+
export function placeAxisRoles<T>(category: T, value: T, orientation: ChartOrientation): { x: T; y: T } {
|
|
24
|
+
return orientation === "horizontal" ? { x: value, y: category } : { x: category, y: value };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The canvas range each role maps into.
|
|
29
|
+
*
|
|
30
|
+
* Values always grow away from the origin corner: up the y axis, or right
|
|
31
|
+
* along x. Categories run left to right, or **top to bottom** when horizontal
|
|
32
|
+
* — so the first row is the top one, as in a table, and the category
|
|
33
|
+
* positions ascend, which the scrub's binary search requires.
|
|
34
|
+
*/
|
|
35
|
+
export function axisRanges(
|
|
36
|
+
outer: ChartBounds,
|
|
37
|
+
orientation: ChartOrientation
|
|
38
|
+
): { category: RangeTuple; value: RangeTuple } {
|
|
39
|
+
return orientation === "horizontal"
|
|
40
|
+
? { category: [outer.top, outer.bottom], value: [outer.left, outer.right] }
|
|
41
|
+
: { category: [outer.left, outer.right], value: [outer.bottom, outer.top] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What one axis decided before anything was measured. */
|
|
45
|
+
export type AxisPlan = {
|
|
46
|
+
readonly domain: DomainTuple;
|
|
47
|
+
readonly kind: ScaleType;
|
|
48
|
+
readonly tickValues: readonly number[];
|
|
49
|
+
readonly labels: readonly string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ChartPlan = {
|
|
53
|
+
readonly x: AxisPlan;
|
|
54
|
+
readonly y: AxisPlan;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PlanAxisOptions = {
|
|
58
|
+
readonly domain: DomainTuple;
|
|
59
|
+
readonly kind: ScaleType;
|
|
60
|
+
readonly range: readonly [number, number];
|
|
61
|
+
readonly tickCount: number;
|
|
62
|
+
/** Explicit values, instead of generating any. */
|
|
63
|
+
readonly tickValues?: readonly number[];
|
|
64
|
+
readonly format: (value: number, index: number) => string;
|
|
65
|
+
readonly show: boolean;
|
|
66
|
+
readonly nice?: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pass one: what an axis will say, decided against the whole canvas.
|
|
71
|
+
*
|
|
72
|
+
* The tick *values* are chosen here and never chosen again. That is what
|
|
73
|
+
* breaks the measure/gutter cycle — label width sets the gutter, the gutter
|
|
74
|
+
* sets the plot rect, and the plot rect would set the tick count, which is a
|
|
75
|
+
* loop. Fixing the values after one look at the full canvas means the second
|
|
76
|
+
* pass moves positions only, and there is nothing left to iterate.
|
|
77
|
+
*/
|
|
78
|
+
export function planAxis(options: PlanAxisOptions): AxisPlan {
|
|
79
|
+
const scale = makeScale({
|
|
80
|
+
kind: options.kind,
|
|
81
|
+
domain: options.domain,
|
|
82
|
+
range: options.range,
|
|
83
|
+
// Nice to the count this axis will actually tick at, not to d3's default
|
|
84
|
+
// of ten. `[28, 91]` niced at ten rounds to `[25, 95]`, and four ticks
|
|
85
|
+
// across that lands on 40/60/80 — three labels, neither of them at an
|
|
86
|
+
// end of the axis. Niced at four it rounds to `[20, 100]` and ticks
|
|
87
|
+
// 20/40/60/80/100, which both ends and every gridline agree with.
|
|
88
|
+
nice: options.nice === true ? options.tickCount : options.nice,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const ticks = buildTicks(scale, { values: options.tickValues, count: options.tickCount });
|
|
92
|
+
const tickValues = ticks.map((tick) => tick.value);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
domain: scale.domain,
|
|
96
|
+
kind: options.kind,
|
|
97
|
+
tickValues,
|
|
98
|
+
labels: options.show ? tickValues.map((value, index) => options.format(value, index)) : [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The resolved frame: where the plot sits and what maps into it. */
|
|
103
|
+
export type ChartFrame = {
|
|
104
|
+
readonly bounds: ChartBounds;
|
|
105
|
+
readonly xScale: ScaleDescriptor;
|
|
106
|
+
readonly yScale: ScaleDescriptor;
|
|
107
|
+
readonly xTicks: readonly ChartTick[];
|
|
108
|
+
readonly yTicks: readonly ChartTick[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type ResolveChartFrameOptions = {
|
|
112
|
+
readonly canvas: ChartSize;
|
|
113
|
+
readonly padding?: SidedNumber;
|
|
114
|
+
readonly plan: ChartPlan;
|
|
115
|
+
/** Measured advance widths of the x labels, in the plan's order. */
|
|
116
|
+
readonly xLabelWidths: readonly number[];
|
|
117
|
+
readonly yLabelWidths: readonly number[];
|
|
118
|
+
readonly lineHeight: number;
|
|
119
|
+
readonly showXAxis: boolean;
|
|
120
|
+
readonly showYAxis: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Which canvas axis holds the categories. The frame builds the scales, so
|
|
123
|
+
* it has to know: a horizontal chart's y range runs top to bottom for the
|
|
124
|
+
* categories, where a vertical chart's runs bottom to top for the values.
|
|
125
|
+
*/
|
|
126
|
+
readonly orientation?: ChartOrientation;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Pass two: gutters from the measurements, then the plot rect and the scales.
|
|
131
|
+
*
|
|
132
|
+
* The domains come from the plan rather than being re-derived, so a `nice()`
|
|
133
|
+
* applied in pass one is not applied twice, and the two passes cannot disagree
|
|
134
|
+
* about what the axis covers.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveChartFrame(options: ResolveChartFrameOptions): ChartFrame {
|
|
137
|
+
const { canvas, padding, plan } = options;
|
|
138
|
+
|
|
139
|
+
const gutters = resolveAxisGutters({
|
|
140
|
+
xLabelWidths: options.xLabelWidths,
|
|
141
|
+
yLabelWidths: options.yLabelWidths,
|
|
142
|
+
fontHeight: options.lineHeight,
|
|
143
|
+
showXAxis: options.showXAxis,
|
|
144
|
+
showYAxis: options.showYAxis,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const bounds = getChartBounds(canvas, padding, gutters);
|
|
148
|
+
|
|
149
|
+
const orientation = options.orientation ?? "vertical";
|
|
150
|
+
const byRole = axisRanges(bounds, orientation);
|
|
151
|
+
const ranges = placeAxisRoles(byRole.category, byRole.value, orientation);
|
|
152
|
+
const xScale = makeScale({ kind: plan.x.kind, domain: plan.x.domain, range: ranges.x });
|
|
153
|
+
const yScale = makeScale({ kind: plan.y.kind, domain: plan.y.domain, range: ranges.y });
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
bounds,
|
|
157
|
+
xScale,
|
|
158
|
+
yScale,
|
|
159
|
+
xTicks: buildTicks(xScale, { values: plan.x.tickValues, count: plan.x.tickValues.length }),
|
|
160
|
+
yTicks: buildTicks(yScale, { values: plan.y.tickValues, count: plan.y.tickValues.length }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { EMPTY_DOMAIN, resolveDomain, resolveDomainPadding } from "./domain";
|
|
3
|
+
|
|
4
|
+
describe("resolveDomain", () => {
|
|
5
|
+
test("measures the extent of the data", () => {
|
|
6
|
+
expect(resolveDomain({ values: [3, 1, 4, 1, 5] })).toEqual([1, 5]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("falls back to a usable domain when nothing is measurable", () => {
|
|
10
|
+
expect(resolveDomain({ values: [] })).toEqual(EMPTY_DOMAIN);
|
|
11
|
+
expect(resolveDomain({ values: [null, Number.NaN] })).toEqual(EMPTY_DOMAIN);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("leaves a constant series zero-width so it draws flat through the middle", () => {
|
|
15
|
+
// Expanding here would invent a spread the data does not have.
|
|
16
|
+
expect(resolveDomain({ values: [50, 50, 50] })).toEqual([50, 50]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("pulls zero in only when asked", () => {
|
|
20
|
+
expect(resolveDomain({ values: [40, 90] })).toEqual([40, 90]);
|
|
21
|
+
expect(resolveDomain({ values: [40, 90], includeZero: true })).toEqual([0, 90]);
|
|
22
|
+
expect(resolveDomain({ values: [-40, -10], includeZero: true })).toEqual([-40, 0]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("pads by a fraction of the extent", () => {
|
|
26
|
+
expect(resolveDomain({ values: [0, 100], padding: 0.1 })).toEqual([-10, 110]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("pads a constant series against its own magnitude", () => {
|
|
30
|
+
expect(resolveDomain({ values: [50, 50], padding: 0.1 })).toEqual([45, 55]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("lets an explicit bound override either end independently", () => {
|
|
34
|
+
expect(resolveDomain({ values: [3, 77], domain: [0, undefined] })).toEqual([0, 77]);
|
|
35
|
+
expect(resolveDomain({ values: [3, 77], domain: [undefined, 100] })).toEqual([3, 100]);
|
|
36
|
+
expect(resolveDomain({ values: [3, 77], domain: [0, 100] })).toEqual([0, 100]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an explicit bound wins over padding", () => {
|
|
40
|
+
expect(resolveDomain({ values: [0, 100], padding: 0.5, domain: [0, undefined] })[0]).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("ignores a non-finite explicit bound rather than poisoning the scale", () => {
|
|
44
|
+
expect(resolveDomain({ values: [3, 77], domain: [Number.NaN, undefined] })).toEqual([3, 77]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("pads by an absolute amount in domain units", () => {
|
|
48
|
+
// Half a step each side is what puts a bar's whole width inside the plot.
|
|
49
|
+
expect(resolveDomain({ values: [0, 4], absolutePadding: 0.5 })).toEqual([-0.5, 4.5]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("applies absolute padding after fractional padding", () => {
|
|
53
|
+
expect(resolveDomain({ values: [0, 100], padding: 0.1, absolutePadding: 5 })).toEqual([-15, 115]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("applies absolute padding to a zero-width domain", () => {
|
|
57
|
+
// One bar still needs a width to stand in.
|
|
58
|
+
expect(resolveDomain({ values: [2, 2], absolutePadding: 0.5 })).toEqual([1.5, 2.5]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an explicit bound wins over absolute padding", () => {
|
|
62
|
+
expect(resolveDomain({ values: [0, 4], absolutePadding: 0.5, domain: [0, undefined] })).toEqual([0, 4.5]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("ignores a non-finite or negative absolute padding", () => {
|
|
66
|
+
expect(resolveDomain({ values: [0, 4], absolutePadding: Number.NaN })).toEqual([0, 4]);
|
|
67
|
+
expect(resolveDomain({ values: [0, 4], absolutePadding: -1 })).toEqual([0, 4]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("resolveDomainPadding", () => {
|
|
72
|
+
test("a bare number pads y only, which is the existing contract", () => {
|
|
73
|
+
expect(resolveDomainPadding(0.1)).toEqual({ x: 0, y: 0.1 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an object names each axis, defaulting the other to nothing", () => {
|
|
77
|
+
expect(resolveDomainPadding({ x: 0.5 })).toEqual({ x: 0.5, y: 0 });
|
|
78
|
+
expect(resolveDomainPadding({ y: 0.2 })).toEqual({ x: 0, y: 0.2 });
|
|
79
|
+
expect(resolveDomainPadding({ x: 0.5, y: 0.2 })).toEqual({ x: 0.5, y: 0.2 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("undefined and NaN both mean no padding", () => {
|
|
83
|
+
expect(resolveDomainPadding(undefined)).toEqual({ x: 0, y: 0 });
|
|
84
|
+
expect(resolveDomainPadding(Number.NaN)).toEqual({ x: 0, y: 0 });
|
|
85
|
+
expect(resolveDomainPadding({ x: Number.NaN, y: Number.POSITIVE_INFINITY })).toEqual({ x: 0, y: 0 });
|
|
86
|
+
});
|
|
87
|
+
});
|