@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,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { matchPointCounts, resamplePoints } from "./resample-points";
|
|
4
|
+
|
|
5
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
6
|
+
|
|
7
|
+
describe("resamplePoints", () => {
|
|
8
|
+
test("keeps both ends and interpolates between", () => {
|
|
9
|
+
const resampled = resamplePoints([at(0, 0), at(10, 10)], 3);
|
|
10
|
+
expect(resampled).toHaveLength(3);
|
|
11
|
+
expect(resampled[0]).toEqual(at(0, 0));
|
|
12
|
+
expect(resampled[1]).toEqual(at(5, 5));
|
|
13
|
+
expect(resampled[2]).toEqual(at(10, 10));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("keeps a gap a gap rather than averaging across it", () => {
|
|
17
|
+
expect(resamplePoints([at(0, 0), at(10, null)], 3)[1]?.y).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("degenerates safely", () => {
|
|
21
|
+
expect(resamplePoints([], 4)).toEqual([]);
|
|
22
|
+
expect(resamplePoints([at(1, 1)], 3)).toHaveLength(3);
|
|
23
|
+
expect(resamplePoints([at(1, 1), at(2, 2)], 0)).toEqual([]);
|
|
24
|
+
expect(resamplePoints([at(1, 1), at(2, 2)], 1)).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("matchPointCounts", () => {
|
|
29
|
+
const previous = [at(0, 1), at(10, 2), at(20, 3)];
|
|
30
|
+
|
|
31
|
+
test("always returns two arrays of equal length", () => {
|
|
32
|
+
const next = [at(0, 5), at(10, 6), at(20, 7), at(30, 8), at(40, 9)];
|
|
33
|
+
for (const strategy of ["none", "pad-end", "pad-start", "resample"] as const) {
|
|
34
|
+
const [a, b] = matchPointCounts(previous, next, strategy);
|
|
35
|
+
expect(a).toHaveLength(b.length);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("passes equal-length series through untouched", () => {
|
|
40
|
+
const next = [at(0, 5), at(10, 6), at(20, 7)];
|
|
41
|
+
const [a, b] = matchPointCounts(previous, next, "none");
|
|
42
|
+
expect(a).toBe(previous);
|
|
43
|
+
expect(b).toBe(next);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("grows the tail out of where the line stopped", () => {
|
|
47
|
+
const next = [at(0, 1), at(10, 2), at(20, 3), at(30, 4), at(40, 5)];
|
|
48
|
+
const [a] = matchPointCounts(previous, next, "pad-end");
|
|
49
|
+
expect(a).toHaveLength(5);
|
|
50
|
+
expect(a[3]).toEqual(at(20, 3));
|
|
51
|
+
expect(a[4]).toEqual(at(20, 3));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("collapses the departing head into the first survivor", () => {
|
|
55
|
+
const next = [at(20, 3)];
|
|
56
|
+
const [, b] = matchPointCounts(previous, next, "pad-start");
|
|
57
|
+
expect(b).toHaveLength(3);
|
|
58
|
+
expect(b[0]).toEqual(at(20, 3));
|
|
59
|
+
expect(b[2]).toEqual(at(20, 3));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("resamples an unrelated series to the longer length", () => {
|
|
63
|
+
const next = [at(100, 9), at(200, 9), at(300, 9), at(400, 9), at(500, 9)];
|
|
64
|
+
const [a, b] = matchPointCounts(previous, next, "resample");
|
|
65
|
+
expect(a).toHaveLength(5);
|
|
66
|
+
expect(b).toHaveLength(5);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ChartPoint } from "../chart.types";
|
|
2
|
+
import type { MorphStrategy } from "./morph-strategy";
|
|
3
|
+
|
|
4
|
+
/** One point between two, at fraction `t`. A gap at either end stays a gap. */
|
|
5
|
+
function lerpPoint(from: ChartPoint, to: ChartPoint, t: number): ChartPoint {
|
|
6
|
+
const bothPlotted = from.y !== null && to.y !== null;
|
|
7
|
+
return {
|
|
8
|
+
x: from.x + (to.x - from.x) * t,
|
|
9
|
+
y: bothPlotted ? (from.y as number) + ((to.y as number) - (from.y as number)) * t : null,
|
|
10
|
+
xValue: from.xValue + (to.xValue - from.xValue) * t,
|
|
11
|
+
yValue: from.yValue !== null && to.yValue !== null ? from.yValue + (to.yValue - from.yValue) * t : null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `points` re-spread over exactly `length` places, evenly by index. */
|
|
16
|
+
export function resamplePoints(points: readonly ChartPoint[], length: number): readonly ChartPoint[] {
|
|
17
|
+
if (length <= 0 || points.length === 0) return [];
|
|
18
|
+
if (points.length === 1 || length === 1) return new Array(length).fill(points[0] as ChartPoint);
|
|
19
|
+
|
|
20
|
+
const last = points.length - 1;
|
|
21
|
+
return Array.from({ length }, (_, index) => {
|
|
22
|
+
const source = (index / (length - 1)) * last;
|
|
23
|
+
const lower = Math.floor(source);
|
|
24
|
+
const upper = Math.min(last, lower + 1);
|
|
25
|
+
return lerpPoint(points[lower] as ChartPoint, points[upper] as ChartPoint, source - lower);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `points` grown to `length` by repeating an end. */
|
|
30
|
+
function padPoints(points: readonly ChartPoint[], length: number, end: "start" | "end"): readonly ChartPoint[] {
|
|
31
|
+
if (points.length >= length || points.length === 0) return points;
|
|
32
|
+
const filler = new Array(length - points.length).fill(
|
|
33
|
+
end === "end" ? (points[points.length - 1] as ChartPoint) : (points[0] as ChartPoint)
|
|
34
|
+
);
|
|
35
|
+
return end === "end" ? [...points, ...filler] : [...filler, ...points];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Two series at one length, ready to build two interpolatable paths.
|
|
40
|
+
*
|
|
41
|
+
* Matching happens here, in data space, and not on the finished paths. That is
|
|
42
|
+
* the only place it is geometrically meaningful, and it is why
|
|
43
|
+
* `useAnimatedPath` never has to fall back to snapping: two point arrays of
|
|
44
|
+
* equal length through the same curve produce the same sequence of path verbs,
|
|
45
|
+
* which is exactly what Skia's interpolation requires.
|
|
46
|
+
*/
|
|
47
|
+
export function matchPointCounts(
|
|
48
|
+
previous: readonly ChartPoint[],
|
|
49
|
+
next: readonly ChartPoint[],
|
|
50
|
+
strategy: MorphStrategy
|
|
51
|
+
): readonly [readonly ChartPoint[], readonly ChartPoint[]] {
|
|
52
|
+
if (previous.length === next.length) return [previous, next];
|
|
53
|
+
|
|
54
|
+
const length = Math.max(previous.length, next.length);
|
|
55
|
+
switch (strategy) {
|
|
56
|
+
case "pad-end":
|
|
57
|
+
return [padPoints(previous, length, "end"), padPoints(next, length, "end")];
|
|
58
|
+
case "pad-start":
|
|
59
|
+
return [padPoints(previous, length, "start"), padPoints(next, length, "start")];
|
|
60
|
+
case "none":
|
|
61
|
+
case "resample":
|
|
62
|
+
return [resamplePoints(previous, length), resamplePoints(next, length)];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which axis the categories run along.
|
|
3
|
+
*
|
|
4
|
+
* `vertical` is the ordinary chart: categories along x, values up y.
|
|
5
|
+
* `horizontal` swaps the roles so bars grow rightward from a category axis on
|
|
6
|
+
* the left. It is decided once, at the root, because every mark, axis and the
|
|
7
|
+
* scrub have to agree on it.
|
|
8
|
+
*/
|
|
9
|
+
export type ChartOrientation = "vertical" | "horizontal";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One plotted datum, in both coordinate systems at once.
|
|
13
|
+
*
|
|
14
|
+
* Marks need the canvas coordinates; a tooltip, a label and the scrub state
|
|
15
|
+
* need the domain values. Carrying both on one object is what stops a caller
|
|
16
|
+
* having to re-invert a position to find out what it meant.
|
|
17
|
+
*/
|
|
18
|
+
export type ChartPoint = {
|
|
19
|
+
/** Canvas x, in points. On a horizontal chart this is the value's position, `NaN` for a gap. */
|
|
20
|
+
readonly x: number;
|
|
21
|
+
/** Canvas y, in points. `null` marks a gap — the series has no value here. On a horizontal chart, the category's position. */
|
|
22
|
+
readonly y: number | null;
|
|
23
|
+
/** The datum's category in domain units — x on a vertical chart. Epoch milliseconds on a time scale. */
|
|
24
|
+
readonly xValue: number;
|
|
25
|
+
/** The datum's value in domain units — y on a vertical chart. `null` for a gap. */
|
|
26
|
+
readonly yValue: number | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One datum of a stacked series: a point with the edge it stands on.
|
|
31
|
+
*
|
|
32
|
+
* `y` and `yValue` are the segment's top — the running total including this
|
|
33
|
+
* series — so a `ChartSegment` reads as a `ChartPoint` wherever a mark wants
|
|
34
|
+
* only the upper edge. `y0` is where the series below it ended. Both are
|
|
35
|
+
* `null` for a gap.
|
|
36
|
+
*/
|
|
37
|
+
export type ChartSegment = ChartPoint & {
|
|
38
|
+
/**
|
|
39
|
+
* Canvas position of the segment's base **along the value axis** — a y on
|
|
40
|
+
* a vertical chart, an x on a horizontal one. Named for the vertical case,
|
|
41
|
+
* like `yValue`; the pair `y`/`y0` is what a bar draws between either way.
|
|
42
|
+
*/
|
|
43
|
+
readonly y0: number | null;
|
|
44
|
+
/** The base in domain units. */
|
|
45
|
+
readonly y0Value: number | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The plot rect: the canvas minus the axis gutters and the padding. */
|
|
49
|
+
export type ChartBounds = {
|
|
50
|
+
readonly left: number;
|
|
51
|
+
readonly right: number;
|
|
52
|
+
readonly top: number;
|
|
53
|
+
readonly bottom: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ChartSize = {
|
|
57
|
+
readonly width: number;
|
|
58
|
+
readonly height: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A row of the caller's data. Fields are read by key, never by position. */
|
|
62
|
+
export type ChartRow = Record<string, unknown>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildAreaPath } from "./build-area";
|
|
4
|
+
|
|
5
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
6
|
+
|
|
7
|
+
describe("buildAreaPath", () => {
|
|
8
|
+
test("closes the fill against the baseline", () => {
|
|
9
|
+
expect(buildAreaPath([at(0, 0), at(10, 10)], { baseline: 100 })).toBe("M0,0L10,10L10,100L0,100Z");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("returns an empty string for no points", () => {
|
|
13
|
+
expect(buildAreaPath([], { baseline: 100 })).toBe("");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("follows a per-point lower edge when given one", () => {
|
|
17
|
+
expect(buildAreaPath([at(0, 0), at(10, 10)], { baseline: 100, lower: [80, 90] })).toBe("M0,0L10,10L10,90L0,80Z");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("falls back to the baseline where the lower edge has a gap", () => {
|
|
21
|
+
expect(buildAreaPath([at(0, 0), at(10, 10)], { baseline: 100, lower: [80, null] })).toBe("M0,0L10,10L10,100L0,80Z");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("gives the lower edge the same interpolator as the upper", () => {
|
|
25
|
+
// Closing a monotone area by hand leaves a curved top on a straight
|
|
26
|
+
// bottom, and the two disagree visibly wherever the baseline is not flat.
|
|
27
|
+
const path = buildAreaPath([at(0, 0), at(10, 10), at(20, 4)], {
|
|
28
|
+
baseline: 100,
|
|
29
|
+
lower: [90, 70, 95],
|
|
30
|
+
curve: "monotone",
|
|
31
|
+
});
|
|
32
|
+
expect((path.match(/C/g) ?? []).length).toBeGreaterThanOrEqual(4);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("breaks at a gap by default", () => {
|
|
36
|
+
const path = buildAreaPath([at(0, 0), at(10, null), at(20, 5)], { baseline: 100 });
|
|
37
|
+
expect((path.match(/M/g) ?? []).length).toBeGreaterThan(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("closes against a baseline x when horizontal", () => {
|
|
41
|
+
// Category on y, value on x: the lower edge is a vertical line at x = baseline.
|
|
42
|
+
const points = [
|
|
43
|
+
{ x: 30, y: 0, xValue: 0, yValue: 30 },
|
|
44
|
+
{ x: 50, y: 10, xValue: 1, yValue: 50 },
|
|
45
|
+
];
|
|
46
|
+
expect(buildAreaPath(points, { baseline: 0, orientation: "horizontal" })).toBe("M30,0L50,10L0,10L0,0Z");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { area } from "d3-shape";
|
|
2
|
+
import type { ChartOrientation, ChartPoint } from "../chart.types";
|
|
3
|
+
import { isDrawable } from "./build-line";
|
|
4
|
+
import { CURVES, type CurveType } from "./curves";
|
|
5
|
+
|
|
6
|
+
export type AreaPathOptions = {
|
|
7
|
+
readonly curve?: CurveType;
|
|
8
|
+
readonly connectMissingData?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Canvas y the fill closes against — usually the plot rect's bottom, or the
|
|
11
|
+
* position of zero when the series crosses it.
|
|
12
|
+
*/
|
|
13
|
+
readonly baseline: number;
|
|
14
|
+
/** Per-point lower edge, for a band rather than a fill to a baseline. */
|
|
15
|
+
readonly lower?: readonly (number | null)[];
|
|
16
|
+
/** Whether the fill closes down to a y (the default) or across to an x. */
|
|
17
|
+
readonly orientation?: ChartOrientation;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An SVG path string for the region between `points` and a baseline.
|
|
22
|
+
*
|
|
23
|
+
* Built with d3's `area` rather than by appending a closing edge to the line
|
|
24
|
+
* path, so the lower edge follows the same interpolator as the upper one. A
|
|
25
|
+
* hand-closed monotone area has a curved top and a straight bottom, and the
|
|
26
|
+
* two disagree visibly wherever the baseline is not flat.
|
|
27
|
+
*/
|
|
28
|
+
export function buildAreaPath(points: readonly ChartPoint[], options: AreaPathOptions): string {
|
|
29
|
+
if (points.length === 0) return "";
|
|
30
|
+
const curve = CURVES[options.curve ?? "linear"];
|
|
31
|
+
const lower = options.lower;
|
|
32
|
+
|
|
33
|
+
const edge = (index: number): number => {
|
|
34
|
+
if (!lower) return options.baseline;
|
|
35
|
+
const bound = lower[index];
|
|
36
|
+
return bound === null || bound === undefined || !Number.isFinite(bound) ? options.baseline : bound;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const generator =
|
|
40
|
+
options.orientation === "horizontal"
|
|
41
|
+
? area<ChartPoint>()
|
|
42
|
+
.y((point) => point.y ?? 0)
|
|
43
|
+
.x1((point) => point.x)
|
|
44
|
+
.x0((_point, index) => edge(index))
|
|
45
|
+
.curve(curve)
|
|
46
|
+
: area<ChartPoint>()
|
|
47
|
+
.x((point) => point.x)
|
|
48
|
+
.y1((point) => point.y ?? 0)
|
|
49
|
+
.y0((_point, index) => edge(index))
|
|
50
|
+
.curve(curve);
|
|
51
|
+
|
|
52
|
+
if (options.connectMissingData) {
|
|
53
|
+
const drawable = points.filter(isDrawable);
|
|
54
|
+
return drawable.length === 0 ? "" : (generator(drawable) ?? "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return generator.defined(isDrawable)(points) ?? "";
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildLinePath, isDrawable } from "./build-line";
|
|
4
|
+
import { CURVE_TYPES } from "./curves";
|
|
5
|
+
|
|
6
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
7
|
+
|
|
8
|
+
/** The command letters of a path, which is what `isInterpolatable` compares. */
|
|
9
|
+
function commands(path: string): string {
|
|
10
|
+
return (path.match(/[A-Za-z]/g) ?? []).join("");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("buildLinePath", () => {
|
|
14
|
+
test("returns an empty string for no points", () => {
|
|
15
|
+
expect(buildLinePath([])).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("emits a move then lines for a linear curve", () => {
|
|
19
|
+
expect(buildLinePath([at(0, 0), at(10, 10)])).toBe("M0,0L10,10");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("emits cubics for monotone", () => {
|
|
23
|
+
expect(commands(buildLinePath([at(0, 0), at(10, 10), at(20, 5)], { curve: "monotone" }))).toBe("MCC");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("breaks the line at a gap rather than drawing through it", () => {
|
|
27
|
+
// Two subpaths, two moves. A line drawn across missing data asserts a
|
|
28
|
+
// trend nobody measured.
|
|
29
|
+
expect(commands(buildLinePath([at(0, 0), at(10, null), at(20, 5)]))).toContain("MZM");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("draws through a gap when asked", () => {
|
|
33
|
+
expect(buildLinePath([at(0, 0), at(10, null), at(20, 5)], { connectMissingData: true })).toBe("M0,0L20,5");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("treats a non-finite coordinate as a gap", () => {
|
|
37
|
+
expect(buildLinePath([at(0, 0), at(10, Number.NaN), at(20, 5)], { connectMissingData: true })).toBe("M0,0L20,5");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns an empty string when every point is a gap", () => {
|
|
41
|
+
expect(buildLinePath([at(0, null), at(10, null)], { connectMissingData: true })).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("produces the same command sequence for equal point counts on every curve", () => {
|
|
45
|
+
// This is the animation invariant. Skia can only interpolate two paths
|
|
46
|
+
// whose verbs match, so a data change that keeps the point count must
|
|
47
|
+
// keep the command sequence — otherwise `useAnimatedPath` snaps instead
|
|
48
|
+
// of morphing and the chart jumps.
|
|
49
|
+
const before = [at(0, 5), at(10, 8), at(20, 3), at(30, 9)];
|
|
50
|
+
const after = [at(0, 2), at(10, 1), at(20, 7), at(30, 4)];
|
|
51
|
+
for (const curve of CURVE_TYPES) {
|
|
52
|
+
expect(commands(buildLinePath(after, { curve }))).toBe(commands(buildLinePath(before, { curve })));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isDrawable", () => {
|
|
58
|
+
test("rejects gaps and non-finite coordinates", () => {
|
|
59
|
+
expect(isDrawable(at(0, 1))).toBe(true);
|
|
60
|
+
expect(isDrawable(at(0, null))).toBe(false);
|
|
61
|
+
expect(isDrawable(at(0, Number.NaN))).toBe(false);
|
|
62
|
+
expect(isDrawable(at(Number.POSITIVE_INFINITY, 1))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { line } from "d3-shape";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { CURVES, type CurveType } from "./curves";
|
|
4
|
+
|
|
5
|
+
export type LinePathOptions = {
|
|
6
|
+
readonly curve?: CurveType;
|
|
7
|
+
/**
|
|
8
|
+
* Draw straight through a gap instead of breaking the line.
|
|
9
|
+
*
|
|
10
|
+
* The default breaks it, because a line drawn across missing data asserts a
|
|
11
|
+
* trend that was never measured.
|
|
12
|
+
*/
|
|
13
|
+
readonly connectMissingData?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Whether a point can be drawn — both coordinates finite, and not a gap. */
|
|
17
|
+
export function isDrawable(point: ChartPoint): boolean {
|
|
18
|
+
return point.y !== null && Number.isFinite(point.y) && Number.isFinite(point.x);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An SVG path string through `points`.
|
|
23
|
+
*
|
|
24
|
+
* A string rather than a Skia path because this module is pure — Skia's
|
|
25
|
+
* builder cannot be imported here without taking the whole test suite down
|
|
26
|
+
* (see the package AGENTS.md). `src/skia/build-path.ts` parses it.
|
|
27
|
+
*
|
|
28
|
+
* Gaps come out as separate subpaths via d3's `.defined()`, so a broken series
|
|
29
|
+
* is one path object with several `M` commands rather than several paths.
|
|
30
|
+
*/
|
|
31
|
+
export function buildLinePath(points: readonly ChartPoint[], options: LinePathOptions = {}): string {
|
|
32
|
+
if (points.length === 0) return "";
|
|
33
|
+
const curve = CURVES[options.curve ?? "linear"];
|
|
34
|
+
|
|
35
|
+
if (options.connectMissingData) {
|
|
36
|
+
const drawable = points.filter(isDrawable);
|
|
37
|
+
if (drawable.length === 0) return "";
|
|
38
|
+
return (
|
|
39
|
+
line<ChartPoint>()
|
|
40
|
+
.x((point) => point.x)
|
|
41
|
+
.y((point) => point.y as number)
|
|
42
|
+
.curve(curve)(drawable) ?? ""
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
line<ChartPoint>()
|
|
48
|
+
.x((point) => point.x)
|
|
49
|
+
.y((point) => point.y ?? 0)
|
|
50
|
+
.defined(isDrawable)
|
|
51
|
+
.curve(curve)(points) ?? ""
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { APPROXIMATING_CURVES, CURVE_TYPES, CURVES } from "./curves";
|
|
3
|
+
|
|
4
|
+
describe("CURVES", () => {
|
|
5
|
+
test("every listed name resolves to an interpolator", () => {
|
|
6
|
+
expect(CURVE_TYPES.length).toBeGreaterThan(0);
|
|
7
|
+
for (const name of CURVE_TYPES) expect(CURVES[name]).toBeDefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("names the curves that do not touch their own data", () => {
|
|
11
|
+
// A scrub dot on an approximating curve sits off every datum. Callers
|
|
12
|
+
// need to be able to ask, rather than find out from a screenshot.
|
|
13
|
+
expect(APPROXIMATING_CURVES).toContain("basis");
|
|
14
|
+
for (const name of APPROXIMATING_CURVES) expect(CURVE_TYPES).toContain(name);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("offers monotone, the one interpolating curve that cannot overshoot", () => {
|
|
18
|
+
expect(CURVE_TYPES).toContain("monotone");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
curveBasis,
|
|
3
|
+
curveBumpX,
|
|
4
|
+
curveBumpY,
|
|
5
|
+
curveCardinal,
|
|
6
|
+
curveCatmullRom,
|
|
7
|
+
curveLinear,
|
|
8
|
+
curveMonotoneX,
|
|
9
|
+
curveNatural,
|
|
10
|
+
curveStep,
|
|
11
|
+
curveStepAfter,
|
|
12
|
+
curveStepBefore,
|
|
13
|
+
} from "d3-shape";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The curve interpolators a mark can be drawn with.
|
|
17
|
+
*
|
|
18
|
+
* `monotone` is the default worth reaching for on a value-over-time series: it
|
|
19
|
+
* is the only interpolating curve here that cannot overshoot, so a line
|
|
20
|
+
* through non-negative data never dips below zero between two points and
|
|
21
|
+
* invents a loss that never happened.
|
|
22
|
+
*
|
|
23
|
+
* `basis` is the odd one out and is kept deliberately. It approximates rather
|
|
24
|
+
* than interpolates — the drawn line does not pass through its own data — so a
|
|
25
|
+
* scrub dot placed on the curve will not coincide with any datum. Fine for a
|
|
26
|
+
* decorative sparkline, wrong for anything a reader will measure.
|
|
27
|
+
*/
|
|
28
|
+
export const CURVES = {
|
|
29
|
+
linear: curveLinear,
|
|
30
|
+
monotone: curveMonotoneX,
|
|
31
|
+
natural: curveNatural,
|
|
32
|
+
basis: curveBasis,
|
|
33
|
+
cardinal: curveCardinal,
|
|
34
|
+
catmullRom: curveCatmullRom,
|
|
35
|
+
bumpX: curveBumpX,
|
|
36
|
+
bumpY: curveBumpY,
|
|
37
|
+
step: curveStep,
|
|
38
|
+
stepBefore: curveStepBefore,
|
|
39
|
+
stepAfter: curveStepAfter,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export type CurveType = keyof typeof CURVES;
|
|
43
|
+
|
|
44
|
+
/** Every curve name, for a caller mapping over the set. */
|
|
45
|
+
export const CURVE_TYPES = Object.keys(CURVES) as readonly CurveType[];
|
|
46
|
+
|
|
47
|
+
/** Curves that do not pass through their own data points. */
|
|
48
|
+
export const APPROXIMATING_CURVES: readonly CurveType[] = ["basis"];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { groupLayout, resolveBand } from "./band";
|
|
3
|
+
|
|
4
|
+
describe("resolveBand", () => {
|
|
5
|
+
test("the step is the smallest gap between neighbouring positions", () => {
|
|
6
|
+
expect(resolveBand({ xPositions: [0, 50, 100, 150], plotWidth: 200 }).step).toBe(50);
|
|
7
|
+
expect(resolveBand({ xPositions: [0, 50, 75, 150], plotWidth: 200 }).step).toBe(25);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("the bandwidth is the step less the inner padding", () => {
|
|
11
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100 }).bandwidth).toBe(40);
|
|
12
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, innerPadding: 0.5 }).bandwidth).toBe(25);
|
|
13
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, innerPadding: 0 }).bandwidth).toBe(50);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("a bar count divides the plot instead of measuring the positions", () => {
|
|
17
|
+
// The way to keep bars the same width across charts with different row counts.
|
|
18
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, barCount: 10 }).step).toBe(10);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("an explicit bar width wins over the padding", () => {
|
|
22
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, barWidth: 12 }).bandwidth).toBe(12);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("clamps the inner padding to [0, 1) so a bar never inverts or vanishes", () => {
|
|
26
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, innerPadding: 1 }).bandwidth).toBeGreaterThan(0);
|
|
27
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, innerPadding: -1 }).bandwidth).toBe(50);
|
|
28
|
+
expect(resolveBand({ xPositions: [0, 50], plotWidth: 100, innerPadding: Number.NaN }).bandwidth).toBe(40);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a lone bar takes the whole plot as its step", () => {
|
|
32
|
+
expect(resolveBand({ xPositions: [50], plotWidth: 100 }).step).toBe(100);
|
|
33
|
+
expect(resolveBand({ xPositions: [], plotWidth: 100 }).step).toBe(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("ignores non-finite positions and duplicates", () => {
|
|
37
|
+
expect(resolveBand({ xPositions: [0, 0, Number.NaN, 50], plotWidth: 100 }).step).toBe(50);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a collapsed plot yields zero, never NaN", () => {
|
|
41
|
+
const band = resolveBand({ xPositions: [], plotWidth: 0 });
|
|
42
|
+
expect(band.step).toBe(0);
|
|
43
|
+
expect(band.bandwidth).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("groupLayout", () => {
|
|
48
|
+
test("splits the group's share of the step evenly, centred on the datum", () => {
|
|
49
|
+
const layout = groupLayout({ step: 100, seriesCount: 2, betweenGroupPadding: 0.2, withinGroupPadding: 0 });
|
|
50
|
+
// 80 for the group, 40 per slot, centres at ±20.
|
|
51
|
+
expect(layout.bandwidth).toBe(40);
|
|
52
|
+
expect(layout.offsets).toEqual([-20, 20]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("offsets are symmetric about zero for an odd count too", () => {
|
|
56
|
+
const layout = groupLayout({ step: 100, seriesCount: 3, betweenGroupPadding: 0.1, withinGroupPadding: 0 });
|
|
57
|
+
expect(layout.offsets).toEqual([-30, 0, 30]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("within-group padding narrows each bar but not its slot", () => {
|
|
61
|
+
const layout = groupLayout({ step: 100, seriesCount: 2, betweenGroupPadding: 0.2, withinGroupPadding: 0.5 });
|
|
62
|
+
expect(layout.bandwidth).toBe(20);
|
|
63
|
+
expect(layout.offsets).toEqual([-20, 20]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an explicit bar width wins over the padding", () => {
|
|
67
|
+
expect(groupLayout({ step: 100, seriesCount: 2, barWidth: 7 }).bandwidth).toBe(7);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("defaults both paddings to a fifth", () => {
|
|
71
|
+
const layout = groupLayout({ step: 100, seriesCount: 1 });
|
|
72
|
+
expect(layout.bandwidth).toBe(64);
|
|
73
|
+
expect(layout.offsets).toEqual([0]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("no series is no offsets and no width", () => {
|
|
77
|
+
expect(groupLayout({ step: 100, seriesCount: 0 })).toEqual({ bandwidth: 0, offsets: [] });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { clamp } from "../util/clamp";
|
|
2
|
+
|
|
3
|
+
/** How much of the category axis one datum owns, and how much of that a bar fills. */
|
|
4
|
+
export type Band = {
|
|
5
|
+
/** Canvas distance from one datum to the next. */
|
|
6
|
+
readonly step: number;
|
|
7
|
+
/** Canvas width of a bar. */
|
|
8
|
+
readonly bandwidth: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ResolveBandOptions = {
|
|
12
|
+
/** Canvas x per row — the chart's `xPositions`. */
|
|
13
|
+
readonly xPositions: readonly number[];
|
|
14
|
+
readonly plotWidth: number;
|
|
15
|
+
/** The fraction of the step left empty, split either side. Defaults to 0.2. */
|
|
16
|
+
readonly innerPadding?: number;
|
|
17
|
+
/** An exact bar width, overriding `innerPadding`. */
|
|
18
|
+
readonly barWidth?: number;
|
|
19
|
+
/** Divide the plot into this many steps instead of measuring the positions. */
|
|
20
|
+
readonly barCount?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The band a bar draws in, on a scale that has no band of its own.
|
|
25
|
+
*
|
|
26
|
+
* There is no band scale in this package; a category sits at its index on a
|
|
27
|
+
* linear scale. The step is therefore *measured* — the smallest canvas gap
|
|
28
|
+
* between neighbouring positions — which on evenly spaced data is what a
|
|
29
|
+
* band scale would have computed, and on a time axis with a missing month is
|
|
30
|
+
* the honest answer: the narrower gap.
|
|
31
|
+
*
|
|
32
|
+
* `barCount` exists so several charts with different row counts can share a
|
|
33
|
+
* bar width. A lone bar, or no bar, takes the whole plot as its step; it would
|
|
34
|
+
* otherwise have no width at all.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveBand(options: ResolveBandOptions): Band {
|
|
37
|
+
const { xPositions, plotWidth, barWidth, barCount } = options;
|
|
38
|
+
const width = Number.isFinite(plotWidth) && plotWidth > 0 ? plotWidth : 0;
|
|
39
|
+
|
|
40
|
+
let step: number;
|
|
41
|
+
if (barCount !== undefined && Number.isFinite(barCount) && barCount > 0) {
|
|
42
|
+
step = width / barCount;
|
|
43
|
+
} else {
|
|
44
|
+
step = smallestGap(xPositions);
|
|
45
|
+
if (step === 0) step = width;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const padding = paddingOrDefault(options.innerPadding, 0.2);
|
|
49
|
+
const bandwidth = barWidth !== undefined && Number.isFinite(barWidth) ? Math.max(barWidth, 0) : step * (1 - padding);
|
|
50
|
+
return { step, bandwidth };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type GroupLayoutOptions = {
|
|
54
|
+
readonly step: number;
|
|
55
|
+
readonly seriesCount: number;
|
|
56
|
+
/** The fraction of the step left empty between groups. Defaults to 0.2. */
|
|
57
|
+
readonly betweenGroupPadding?: number;
|
|
58
|
+
/** The fraction of each bar's slot left empty. Defaults to 0.2. */
|
|
59
|
+
readonly withinGroupPadding?: number;
|
|
60
|
+
readonly barWidth?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type GroupLayout = {
|
|
64
|
+
readonly bandwidth: number;
|
|
65
|
+
/** Canvas x offset of each series' bar from the datum's x, symmetric about zero. */
|
|
66
|
+
readonly offsets: readonly number[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where each series' bar sits inside a grouped step.
|
|
71
|
+
*
|
|
72
|
+
* The offsets are symmetric about zero so the group is centred on the datum,
|
|
73
|
+
* which keeps the scrub's snapped x — the datum's x — in the middle of the
|
|
74
|
+
* group rather than on its first bar.
|
|
75
|
+
*/
|
|
76
|
+
export function groupLayout(options: GroupLayoutOptions): GroupLayout {
|
|
77
|
+
const { step, seriesCount, barWidth } = options;
|
|
78
|
+
if (!Number.isFinite(seriesCount) || seriesCount <= 0) return { bandwidth: 0, offsets: [] };
|
|
79
|
+
|
|
80
|
+
const between = paddingOrDefault(options.betweenGroupPadding, 0.2);
|
|
81
|
+
const within = paddingOrDefault(options.withinGroupPadding, 0.2);
|
|
82
|
+
const groupWidth = step * (1 - between);
|
|
83
|
+
const slot = groupWidth / seriesCount;
|
|
84
|
+
const bandwidth = barWidth !== undefined && Number.isFinite(barWidth) ? Math.max(barWidth, 0) : slot * (1 - within);
|
|
85
|
+
|
|
86
|
+
const offsets: number[] = [];
|
|
87
|
+
for (let index = 0; index < seriesCount; index += 1) {
|
|
88
|
+
offsets.push(-groupWidth / 2 + slot * (index + 0.5));
|
|
89
|
+
}
|
|
90
|
+
return { bandwidth, offsets };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The smallest positive gap between neighbouring positions, or zero when there is none. */
|
|
94
|
+
function smallestGap(positions: readonly number[]): number {
|
|
95
|
+
const sorted = positions.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
96
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
97
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
98
|
+
const current = (sorted[index] as number) - (sorted[index - 1] as number);
|
|
99
|
+
if (current > 0 && current < gap) gap = current;
|
|
100
|
+
}
|
|
101
|
+
return Number.isFinite(gap) ? gap : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A padding fraction held inside `[0, 1)`. At one a bar would have no width
|
|
106
|
+
* and above it a negative one, which draws inside out.
|
|
107
|
+
*/
|
|
108
|
+
function paddingOrDefault(value: number | undefined, fallback: number): number {
|
|
109
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
110
|
+
return clamp(value, 0, 1 - Number.EPSILON);
|
|
111
|
+
}
|