@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,94 @@
|
|
|
1
|
+
import type { DomainTuple } from "../scale/scale.types";
|
|
2
|
+
import { extent } from "../util/extent";
|
|
3
|
+
|
|
4
|
+
/** What a scale falls back to when the data offers nothing to measure. */
|
|
5
|
+
export const EMPTY_DOMAIN: DomainTuple = [0, 1];
|
|
6
|
+
|
|
7
|
+
export type ResolveDomainOptions = {
|
|
8
|
+
readonly values: Iterable<number | null | undefined>;
|
|
9
|
+
/**
|
|
10
|
+
* Explicit bounds. Either end may be `undefined` to keep the data's own —
|
|
11
|
+
* `[0, undefined]` is the common case: pin the floor, let the ceiling grow.
|
|
12
|
+
*/
|
|
13
|
+
readonly domain?: readonly [number | undefined, number | undefined];
|
|
14
|
+
/** Extra span added to each end, as a fraction of the measured extent. */
|
|
15
|
+
readonly padding?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Extra span added to each end in domain units, after `padding`.
|
|
18
|
+
*
|
|
19
|
+
* This is how a bar gets its whole width inside the plot: half a step each
|
|
20
|
+
* side, where a fraction of the extent would be a different amount for
|
|
21
|
+
* every dataset. Applied to a zero-width domain too — one bar still needs
|
|
22
|
+
* somewhere to stand.
|
|
23
|
+
*/
|
|
24
|
+
readonly absolutePadding?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Pull zero into the domain.
|
|
27
|
+
*
|
|
28
|
+
* A bar's length is read against the axis, so a bar chart that starts at 40
|
|
29
|
+
* exaggerates every difference. A line chart usually should not — clipping
|
|
30
|
+
* to the data is what makes a trend legible.
|
|
31
|
+
*/
|
|
32
|
+
readonly includeZero?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The domain a scale should take.
|
|
37
|
+
*
|
|
38
|
+
* A constant series comes back zero-width on purpose. `scaleValue` maps that
|
|
39
|
+
* to the range midpoint, so a flat series draws as a flat line through the
|
|
40
|
+
* middle — which is what it is. Expanding it here would invent a spread the
|
|
41
|
+
* data does not have.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveDomain(options: ResolveDomainOptions): DomainTuple {
|
|
44
|
+
const measured = extent(options.values);
|
|
45
|
+
let lo = measured?.[0] ?? EMPTY_DOMAIN[0];
|
|
46
|
+
let hi = measured?.[1] ?? EMPTY_DOMAIN[1];
|
|
47
|
+
|
|
48
|
+
if (options.includeZero) {
|
|
49
|
+
lo = Math.min(lo, 0);
|
|
50
|
+
hi = Math.max(hi, 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const padding = options.padding;
|
|
54
|
+
if (padding !== undefined && padding > 0 && Number.isFinite(padding)) {
|
|
55
|
+
const span = hi - lo;
|
|
56
|
+
const amount = span === 0 ? Math.abs(lo) * padding : span * padding;
|
|
57
|
+
lo -= amount;
|
|
58
|
+
hi += amount;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const absolute = options.absolutePadding;
|
|
62
|
+
if (absolute !== undefined && absolute > 0 && Number.isFinite(absolute)) {
|
|
63
|
+
lo -= absolute;
|
|
64
|
+
hi += absolute;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const explicitLo = options.domain?.[0];
|
|
68
|
+
const explicitHi = options.domain?.[1];
|
|
69
|
+
if (explicitLo !== undefined && Number.isFinite(explicitLo)) lo = explicitLo;
|
|
70
|
+
if (explicitHi !== undefined && Number.isFinite(explicitHi)) hi = explicitHi;
|
|
71
|
+
|
|
72
|
+
return [lo, hi];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Padding per axis. `x` is measured in steps, `y` as a fraction of the extent. */
|
|
76
|
+
export type DomainPadding = number | { readonly x?: number; readonly y?: number };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `domainPadding` decoded to one number per axis.
|
|
80
|
+
*
|
|
81
|
+
* A bare number pads y only — that was the prop's contract before x padding
|
|
82
|
+
* existed, and a line chart that asked for `0.1` should not grow a margin
|
|
83
|
+
* beside its first point. Anything non-finite becomes zero rather than
|
|
84
|
+
* reaching a scale as `NaN`.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveDomainPadding(value: DomainPadding | undefined): { readonly x: number; readonly y: number } {
|
|
87
|
+
if (value === undefined) return { x: 0, y: 0 };
|
|
88
|
+
if (typeof value === "number") return { x: 0, y: finiteOrZero(value) };
|
|
89
|
+
return { x: finiteOrZero(value.x), y: finiteOrZero(value.y) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function finiteOrZero(value: number | undefined): number {
|
|
93
|
+
return value !== undefined && Number.isFinite(value) ? value : 0;
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartTick } from "../ticks/tick.types";
|
|
3
|
+
import { gridSegments } from "./grid-lines";
|
|
4
|
+
|
|
5
|
+
const bounds = { left: 20, top: 0, right: 300, bottom: 200 };
|
|
6
|
+
const yTicks: ChartTick[] = [
|
|
7
|
+
{ value: 0, position: 200 },
|
|
8
|
+
{ value: 50, position: 100 },
|
|
9
|
+
];
|
|
10
|
+
const xTicks: ChartTick[] = [
|
|
11
|
+
{ value: 0, position: 20 },
|
|
12
|
+
{ value: 10, position: 300 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe("gridSegments", () => {
|
|
16
|
+
test("spans the plot rect horizontally at each y tick", () => {
|
|
17
|
+
expect(gridSegments({ bounds, xTicks, yTicks })).toEqual([
|
|
18
|
+
[20, 200, 300, 200],
|
|
19
|
+
[20, 100, 300, 100],
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("defaults to the y ticks alone", () => {
|
|
24
|
+
// Horizontal rules carry a value across to the axis, which is what a
|
|
25
|
+
// gridline is for. Vertical ones mostly repeat what the marks show.
|
|
26
|
+
expect(gridSegments({ bounds, xTicks, yTicks })).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("draws vertical rules when asked", () => {
|
|
30
|
+
expect(gridSegments({ bounds, xTicks, yTicks, axis: "x" })).toEqual([
|
|
31
|
+
[20, 0, 20, 200],
|
|
32
|
+
[300, 0, 300, 200],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("draws both", () => {
|
|
37
|
+
expect(gridSegments({ bounds, xTicks, yTicks, axis: "both" })).toHaveLength(4);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("skips a tick with no usable position rather than pinning it to zero", () => {
|
|
41
|
+
const broken: ChartTick[] = [{ value: 0, position: Number.NaN }, ...yTicks];
|
|
42
|
+
expect(gridSegments({ bounds, xTicks, yTicks: broken })).toHaveLength(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("draws nothing when there are no ticks", () => {
|
|
46
|
+
expect(gridSegments({ bounds, xTicks: [], yTicks: [], axis: "both" })).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ChartBounds } from "../chart.types";
|
|
2
|
+
import type { ChartTick } from "../ticks/tick.types";
|
|
3
|
+
|
|
4
|
+
/** One rule, as `[x1, y1, x2, y2]`. */
|
|
5
|
+
export type GridSegment = readonly [number, number, number, number];
|
|
6
|
+
|
|
7
|
+
export type GridAxis = "x" | "y" | "both";
|
|
8
|
+
|
|
9
|
+
export type GridSegmentOptions = {
|
|
10
|
+
readonly bounds: ChartBounds;
|
|
11
|
+
readonly xTicks: readonly ChartTick[];
|
|
12
|
+
readonly yTicks: readonly ChartTick[];
|
|
13
|
+
readonly axis?: GridAxis;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The rules a grid draws, spanning the plot rect at each tick.
|
|
18
|
+
*
|
|
19
|
+
* A tick with a non-finite position is skipped rather than drawn at zero. That
|
|
20
|
+
* happens whenever an axis has no usable domain — a log scale reaching zero,
|
|
21
|
+
* say — and a rule pinned to the canvas edge reads as a real gridline the data
|
|
22
|
+
* does not have.
|
|
23
|
+
*/
|
|
24
|
+
export function gridSegments(options: GridSegmentOptions): GridSegment[] {
|
|
25
|
+
const { bounds, xTicks, yTicks, axis = "y" } = options;
|
|
26
|
+
const segments: GridSegment[] = [];
|
|
27
|
+
|
|
28
|
+
if (axis === "y" || axis === "both") {
|
|
29
|
+
for (const tick of yTicks) {
|
|
30
|
+
if (!Number.isFinite(tick.position)) continue;
|
|
31
|
+
segments.push([bounds.left, tick.position, bounds.right, tick.position]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (axis === "x" || axis === "both") {
|
|
36
|
+
for (const tick of xTicks) {
|
|
37
|
+
if (!Number.isFinite(tick.position)) continue;
|
|
38
|
+
segments.push([tick.position, bounds.top, tick.position, bounds.bottom]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return segments;
|
|
43
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
3
|
+
import { collectStackedYValues, stackSeries } from "./stack";
|
|
4
|
+
|
|
5
|
+
/** Domain 0..10 onto a 100pt tall plot, canvas y growing downward. */
|
|
6
|
+
const yScale: ScaleDescriptor = { kind: "linear", domain: [0, 10], range: [100, 0] };
|
|
7
|
+
const at = (value: number): number => 100 - value * 10;
|
|
8
|
+
|
|
9
|
+
const stack = (
|
|
10
|
+
data: readonly Record<string, unknown>[],
|
|
11
|
+
keys: readonly string[],
|
|
12
|
+
scale: ScaleDescriptor = yScale
|
|
13
|
+
): ReturnType<typeof stackSeries> =>
|
|
14
|
+
stackSeries({
|
|
15
|
+
data,
|
|
16
|
+
keys,
|
|
17
|
+
xValues: data.map((_, index) => index),
|
|
18
|
+
xPositions: data.map((_, index) => index * 10),
|
|
19
|
+
yScale: scale,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("stackSeries", () => {
|
|
23
|
+
test("each series bases where the one before it ended", () => {
|
|
24
|
+
const result = stack(
|
|
25
|
+
[
|
|
26
|
+
{ a: 3, b: 1 },
|
|
27
|
+
{ a: 2, b: 4 },
|
|
28
|
+
],
|
|
29
|
+
["a", "b"]
|
|
30
|
+
);
|
|
31
|
+
expect(result.a?.map((segment) => segment.yValue)).toEqual([3, 2]);
|
|
32
|
+
expect(result.a?.map((segment) => segment.y0Value)).toEqual([0, 0]);
|
|
33
|
+
expect(result.b?.map((segment) => segment.yValue)).toEqual([4, 6]);
|
|
34
|
+
expect(result.b?.map((segment) => segment.y0Value)).toEqual([3, 2]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("scales both edges through the y scale", () => {
|
|
38
|
+
const result = stack([{ a: 3, b: 1 }], ["a", "b"]);
|
|
39
|
+
expect(result.b?.[0]?.y).toBe(at(4));
|
|
40
|
+
expect(result.b?.[0]?.y0).toBe(at(3));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("carries x through unchanged", () => {
|
|
44
|
+
const result = stack([{ a: 1 }, { a: 2 }], ["a"]);
|
|
45
|
+
expect(result.a?.map((segment) => segment.x)).toEqual([0, 10]);
|
|
46
|
+
expect(result.a?.map((segment) => segment.xValue)).toEqual([0, 1]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("negatives stack downward from zero, independently of the positives", () => {
|
|
50
|
+
const result = stack([{ a: 3, b: -2, c: -1, d: 1 }], ["a", "b", "c", "d"]);
|
|
51
|
+
expect(result.b?.[0]).toMatchObject({ yValue: -2, y0Value: 0 });
|
|
52
|
+
expect(result.c?.[0]).toMatchObject({ yValue: -3, y0Value: -2 });
|
|
53
|
+
expect(result.d?.[0]).toMatchObject({ yValue: 4, y0Value: 3 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a null contributes nothing and yields a null segment", () => {
|
|
57
|
+
const result = stack([{ a: 3, b: null, c: 2 }], ["a", "b", "c"]);
|
|
58
|
+
expect(result.b?.[0]).toMatchObject({ y: null, yValue: null, y0: null, y0Value: null });
|
|
59
|
+
// c bases where a ended, as if b were not there.
|
|
60
|
+
expect(result.c?.[0]).toMatchObject({ yValue: 5, y0Value: 3 });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an unreadable value is a null segment too", () => {
|
|
64
|
+
const result = stack([{ a: "nope", b: 2 }], ["a", "b"]);
|
|
65
|
+
expect(result.a?.[0]?.yValue).toBeNull();
|
|
66
|
+
expect(result.b?.[0]).toMatchObject({ yValue: 2, y0Value: 0 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a zero contributes nothing but is not a gap", () => {
|
|
70
|
+
const result = stack([{ a: 0, b: 2 }], ["a", "b"]);
|
|
71
|
+
expect(result.a?.[0]).toMatchObject({ yValue: 0, y0Value: 0 });
|
|
72
|
+
expect(result.a?.[0]?.y).toBe(at(0));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a log scale puts a base of zero at the floor, never at -Infinity", () => {
|
|
76
|
+
const log: ScaleDescriptor = { kind: "log", domain: [1, 1000], range: [100, 0], base: 10 };
|
|
77
|
+
const result = stack([{ a: 10, b: 100 }], ["a", "b"], log);
|
|
78
|
+
expect(result.a?.[0]?.y0).toBe(100);
|
|
79
|
+
expect(Number.isFinite(result.a?.[0]?.y as number)).toBe(true);
|
|
80
|
+
expect(Number.isFinite(result.b?.[0]?.y0 as number)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns an entry for every key, empty data included", () => {
|
|
84
|
+
expect(stack([], ["a", "b"])).toEqual({ a: [], b: [] });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("collectStackedYValues", () => {
|
|
89
|
+
// A zero pair per row would put zero into the y domain of a chart with no
|
|
90
|
+
// stack at all, which is how a line of prices came to start at nothing.
|
|
91
|
+
test("yields nothing when there are no stack keys", () => {
|
|
92
|
+
expect(collectStackedYValues([{ a: 140 }, { a: 160 }], [])).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("yields each row's positive and negative running totals", () => {
|
|
96
|
+
const values = collectStackedYValues(
|
|
97
|
+
[
|
|
98
|
+
{ a: 3, b: 1 },
|
|
99
|
+
{ a: 2, b: 4 },
|
|
100
|
+
{ a: -2, b: 1 },
|
|
101
|
+
],
|
|
102
|
+
["a", "b"]
|
|
103
|
+
);
|
|
104
|
+
expect(values).toContain(4);
|
|
105
|
+
expect(values).toContain(6);
|
|
106
|
+
expect(values).toContain(-2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("includes zero, so a stack always has a base to stand on", () => {
|
|
110
|
+
expect(collectStackedYValues([{ a: 3 }], ["a"])).toContain(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("skips nulls and unreadable values", () => {
|
|
114
|
+
expect(collectStackedYValues([{ a: null, b: "x", c: 2 }], ["a", "b", "c"])).toEqual([2, 0]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("is empty for empty data", () => {
|
|
118
|
+
expect(collectStackedYValues([], ["a"])).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("stackSeries horizontal", () => {
|
|
123
|
+
test("puts the value edges on x and the category on y, with y0 along the value axis", () => {
|
|
124
|
+
const valueScale: ScaleDescriptor = { kind: "linear", domain: [0, 10], range: [0, 100] };
|
|
125
|
+
const result = stackSeries({
|
|
126
|
+
data: [{ a: 3, b: 1 }],
|
|
127
|
+
keys: ["a", "b"],
|
|
128
|
+
xValues: [0],
|
|
129
|
+
xPositions: [40],
|
|
130
|
+
yScale: valueScale,
|
|
131
|
+
orientation: "horizontal",
|
|
132
|
+
});
|
|
133
|
+
expect(result.a?.[0]).toMatchObject({ x: 30, y: 40, y0: 0, yValue: 3, y0Value: 0 });
|
|
134
|
+
expect(result.b?.[0]).toMatchObject({ x: 40, y: 40, y0: 30, yValue: 4, y0Value: 3 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a gap keeps its category position and has no x", () => {
|
|
138
|
+
const valueScale: ScaleDescriptor = { kind: "linear", domain: [0, 10], range: [0, 100] };
|
|
139
|
+
const result = stackSeries({
|
|
140
|
+
data: [{ a: null }],
|
|
141
|
+
keys: ["a"],
|
|
142
|
+
xValues: [0],
|
|
143
|
+
xPositions: [40],
|
|
144
|
+
yScale: valueScale,
|
|
145
|
+
orientation: "horizontal",
|
|
146
|
+
});
|
|
147
|
+
expect(result.a?.[0]?.y).toBe(40);
|
|
148
|
+
expect(Number.isNaN(result.a?.[0]?.x)).toBe(true);
|
|
149
|
+
expect(result.a?.[0]?.y0).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ChartOrientation, ChartRow, ChartSegment } from "../chart.types";
|
|
2
|
+
import { scaleValue } from "../scale/scale";
|
|
3
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
4
|
+
import { asNumber } from "../util/as-number";
|
|
5
|
+
|
|
6
|
+
export type StackSeriesOptions = {
|
|
7
|
+
readonly data: readonly ChartRow[];
|
|
8
|
+
/** The keys to stack, bottom first. */
|
|
9
|
+
readonly keys: readonly string[];
|
|
10
|
+
readonly xValues: readonly number[];
|
|
11
|
+
/** Canvas position of each row's category — on y when horizontal. */
|
|
12
|
+
readonly xPositions: readonly number[];
|
|
13
|
+
/** The value scale — on x when horizontal. */
|
|
14
|
+
readonly yScale: ScaleDescriptor;
|
|
15
|
+
readonly orientation?: ChartOrientation;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Series stacked on one another, in data space, then scaled.
|
|
20
|
+
*
|
|
21
|
+
* Positives climb from zero and negatives descend from zero, each on its own
|
|
22
|
+
* running total, so a row of `[3, -2, 1]` draws a bar of 4 above the axis and
|
|
23
|
+
* a bar of 2 below it rather than a bar of 2 that has lost a series. Stacking
|
|
24
|
+
* after scaling would get that wrong on a log axis and would add canvas
|
|
25
|
+
* offsets that mean nothing.
|
|
26
|
+
*
|
|
27
|
+
* A null contributes nothing and comes out as a null segment, so the series
|
|
28
|
+
* above it bases where the one below it ended — the gap is in one series, not
|
|
29
|
+
* in the whole column.
|
|
30
|
+
*/
|
|
31
|
+
export function stackSeries(options: StackSeriesOptions): Record<string, readonly ChartSegment[]> {
|
|
32
|
+
const { data, keys, xValues, xPositions, yScale, orientation = "vertical" } = options;
|
|
33
|
+
const horizontal = orientation === "horizontal";
|
|
34
|
+
const series: Record<string, ChartSegment[]> = {};
|
|
35
|
+
for (const key of keys) series[key] = new Array(data.length);
|
|
36
|
+
|
|
37
|
+
for (let index = 0; index < data.length; index += 1) {
|
|
38
|
+
const row = data[index] as ChartRow;
|
|
39
|
+
const x = xPositions[index] as number;
|
|
40
|
+
const xValue = xValues[index] as number;
|
|
41
|
+
let positive = 0;
|
|
42
|
+
let negative = 0;
|
|
43
|
+
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
const value = asNumber(row[key]);
|
|
46
|
+
const target = series[key] as ChartSegment[];
|
|
47
|
+
if (!Number.isFinite(value)) {
|
|
48
|
+
target[index] = segment(horizontal, x, xValue, null, null, Number.NaN, null);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const base = value < 0 ? negative : positive;
|
|
52
|
+
const top = base + value;
|
|
53
|
+
if (value < 0) negative = top;
|
|
54
|
+
else positive = top;
|
|
55
|
+
target[index] = segment(horizontal, x, xValue, top, base, scaleStacked(yScale, top), scaleStacked(yScale, base));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return series;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One segment, with its category and value positions on whichever canvas axes the orientation says. */
|
|
63
|
+
function segment(
|
|
64
|
+
horizontal: boolean,
|
|
65
|
+
category: number,
|
|
66
|
+
xValue: number,
|
|
67
|
+
yValue: number | null,
|
|
68
|
+
y0Value: number | null,
|
|
69
|
+
valuePosition: number,
|
|
70
|
+
y0: number | null
|
|
71
|
+
): ChartSegment {
|
|
72
|
+
if (horizontal) return { x: valuePosition, y: category, xValue, yValue, y0, y0Value };
|
|
73
|
+
return { x: category, y: yValue === null ? null : valuePosition, xValue, yValue, y0, y0Value };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A stacked edge through the scale, with zero pinned to the range floor on a
|
|
78
|
+
* log axis. `scaleValue` maps a non-positive value on a log scale to a finite
|
|
79
|
+
* but enormous negative position, which would put every bar's base far above
|
|
80
|
+
* the canvas.
|
|
81
|
+
*/
|
|
82
|
+
function scaleStacked(scale: ScaleDescriptor, value: number): number {
|
|
83
|
+
if (scale.kind === "log" && value <= 0) return scale.range[0];
|
|
84
|
+
return scaleValue(scale, value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every row's positive and negative running total, for measuring the y domain
|
|
89
|
+
* a stack needs. The raw values are no use here — a domain that covers each
|
|
90
|
+
* series covers none of their sums.
|
|
91
|
+
*/
|
|
92
|
+
export function collectStackedYValues(data: readonly ChartRow[], keys: readonly string[]): number[] {
|
|
93
|
+
// No stack, no totals. A zero pair per row would pull zero into the y domain
|
|
94
|
+
// of every chart, stacked or not, and a line of prices then starts at nothing.
|
|
95
|
+
if (keys.length === 0) return [];
|
|
96
|
+
const values: number[] = [];
|
|
97
|
+
for (const row of data) {
|
|
98
|
+
let positive = 0;
|
|
99
|
+
let negative = 0;
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
const value = asNumber(row[key]);
|
|
102
|
+
if (!Number.isFinite(value)) continue;
|
|
103
|
+
if (value < 0) negative += value;
|
|
104
|
+
else positive += value;
|
|
105
|
+
}
|
|
106
|
+
values.push(positive, negative);
|
|
107
|
+
}
|
|
108
|
+
return values;
|
|
109
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveStep } from "./step";
|
|
3
|
+
|
|
4
|
+
describe("resolveStep", () => {
|
|
5
|
+
test("is the smallest gap between neighbouring values", () => {
|
|
6
|
+
expect(resolveStep([0, 1, 2, 3])).toBe(1);
|
|
7
|
+
expect(resolveStep([0, 10, 15, 30])).toBe(5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("does not care about input order", () => {
|
|
11
|
+
expect(resolveStep([30, 0, 15, 10])).toBe(5);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("ignores duplicates rather than reporting a zero gap", () => {
|
|
15
|
+
// A zero step would give every bar a zero width, and a zero x padding.
|
|
16
|
+
expect(resolveStep([0, 0, 5, 5, 10])).toBe(5);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("ignores non-finite values", () => {
|
|
20
|
+
expect(resolveStep([0, Number.NaN, 2, Number.POSITIVE_INFINITY])).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("falls back to one with fewer than two distinct values", () => {
|
|
24
|
+
expect(resolveStep([])).toBe(1);
|
|
25
|
+
expect(resolveStep([7])).toBe(1);
|
|
26
|
+
expect(resolveStep([7, 7, 7])).toBe(1);
|
|
27
|
+
expect(resolveStep([Number.NaN])).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("never returns zero or NaN", () => {
|
|
31
|
+
expect(resolveStep([1, 1 + Number.EPSILON])).toBeGreaterThan(0);
|
|
32
|
+
expect(Number.isFinite(resolveStep([1e308, -1e308]))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest gap between two distinct finite values — the width one datum
|
|
3
|
+
* can claim before it touches its neighbour.
|
|
4
|
+
*
|
|
5
|
+
* Measured in domain units, so a monthly series on a time scale reports
|
|
6
|
+
* roughly a month and a categorical one reports one. It is what `xStep` and
|
|
7
|
+
* the x domain padding are stated in.
|
|
8
|
+
*
|
|
9
|
+
* Fewer than two distinct values return one, never zero: a zero step gives
|
|
10
|
+
* every bar a zero width and pads the domain by nothing, and a lone bar
|
|
11
|
+
* vanishes with nothing logged.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveStep(values: readonly number[]): number {
|
|
14
|
+
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
15
|
+
let step = Number.POSITIVE_INFINITY;
|
|
16
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
17
|
+
const gap = (sorted[index] as number) - (sorted[index - 1] as number);
|
|
18
|
+
if (gap > 0 && gap < step) step = gap;
|
|
19
|
+
}
|
|
20
|
+
return Number.isFinite(step) && step > 0 ? step : 1;
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
3
|
+
import { collectYValues, transformInputData } from "./transform-input-data";
|
|
4
|
+
|
|
5
|
+
const xScale: ScaleDescriptor = { kind: "linear", domain: [0, 2], range: [0, 200] };
|
|
6
|
+
const yScale: ScaleDescriptor = { kind: "linear", domain: [0, 100], range: [100, 0] };
|
|
7
|
+
|
|
8
|
+
const data = [
|
|
9
|
+
{ m: "Jan", sales: 0, cost: 50 },
|
|
10
|
+
{ m: "Feb", sales: 50, cost: null },
|
|
11
|
+
{ m: "Mar", sales: 100, cost: 25 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("transformInputData", () => {
|
|
15
|
+
test("carries canvas and domain coordinates on the same point", () => {
|
|
16
|
+
const { points } = transformInputData({ data, yKeys: ["sales"], xValues: [0, 1, 2], xScale, yScale });
|
|
17
|
+
expect(points.sales?.[0]).toEqual({ x: 0, y: 100, xValue: 0, yValue: 0 });
|
|
18
|
+
expect(points.sales?.[2]).toEqual({ x: 200, y: 0, xValue: 2, yValue: 100 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("keeps one point per row, marking a missing value as a gap", () => {
|
|
22
|
+
// Dropping the row would shift every later point one place left against
|
|
23
|
+
// the x positions, and the line would draw the wrong shape.
|
|
24
|
+
const { points } = transformInputData({ data, yKeys: ["cost"], xValues: [0, 1, 2], xScale, yScale });
|
|
25
|
+
expect(points.cost).toHaveLength(3);
|
|
26
|
+
expect(points.cost?.[1]?.y).toBeNull();
|
|
27
|
+
expect(points.cost?.[1]?.yValue).toBeNull();
|
|
28
|
+
expect(points.cost?.[1]?.x).toBe(100);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("shares one set of x positions across every series", () => {
|
|
32
|
+
const result = transformInputData({ data, yKeys: ["sales", "cost"], xValues: [0, 1, 2], xScale, yScale });
|
|
33
|
+
expect(result.xPositions).toEqual([0, 100, 200]);
|
|
34
|
+
for (const key of ["sales", "cost"]) {
|
|
35
|
+
expect(result.points[key]?.map((point) => point.x)).toEqual([...result.xPositions]);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("handles an empty dataset", () => {
|
|
40
|
+
const result = transformInputData({ data: [], yKeys: ["sales"], xValues: [], xScale, yScale });
|
|
41
|
+
expect(result.points.sales).toEqual([]);
|
|
42
|
+
expect(result.xPositions).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("treats an absent key as a series of gaps rather than throwing", () => {
|
|
46
|
+
const { points } = transformInputData({ data, yKeys: ["missing"], xValues: [0, 1, 2], xScale, yScale });
|
|
47
|
+
expect(points.missing?.every((point) => point.y === null)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("collectYValues", () => {
|
|
52
|
+
test("gathers every finite value across every series", () => {
|
|
53
|
+
expect(collectYValues(data, ["sales", "cost"]).sort((a, b) => a - b)).toEqual([0, 25, 50, 50, 100]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("skips gaps", () => {
|
|
57
|
+
expect(collectYValues(data, ["cost"])).toEqual([50, 25]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("horizontal", () => {
|
|
61
|
+
// Categories run down y, values across x. The category scale is the one
|
|
62
|
+
// on y; the value scale is the one on x.
|
|
63
|
+
const categoryScale: ScaleDescriptor = { kind: "linear", domain: [0, 2], range: [0, 200] };
|
|
64
|
+
const valueScale: ScaleDescriptor = { kind: "linear", domain: [0, 100], range: [0, 100] };
|
|
65
|
+
|
|
66
|
+
test("puts the value on x and the category on y", () => {
|
|
67
|
+
const { points } = transformInputData({
|
|
68
|
+
data,
|
|
69
|
+
yKeys: ["sales"],
|
|
70
|
+
xValues: [0, 1, 2],
|
|
71
|
+
xScale: valueScale,
|
|
72
|
+
yScale: categoryScale,
|
|
73
|
+
orientation: "horizontal",
|
|
74
|
+
});
|
|
75
|
+
expect(points.sales?.[2]).toEqual({ x: 100, y: 200, xValue: 2, yValue: 100 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a gap keeps its category position and has no x", () => {
|
|
79
|
+
const { points } = transformInputData({
|
|
80
|
+
data,
|
|
81
|
+
yKeys: ["cost"],
|
|
82
|
+
xValues: [0, 1, 2],
|
|
83
|
+
xScale: valueScale,
|
|
84
|
+
yScale: categoryScale,
|
|
85
|
+
orientation: "horizontal",
|
|
86
|
+
});
|
|
87
|
+
expect(points.cost?.[1]?.y).toBe(100);
|
|
88
|
+
expect(Number.isNaN(points.cost?.[1]?.x)).toBe(true);
|
|
89
|
+
expect(points.cost?.[1]?.yValue).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("xPositions are the category positions, whichever axis they lie on", () => {
|
|
93
|
+
const result = transformInputData({
|
|
94
|
+
data,
|
|
95
|
+
yKeys: ["sales"],
|
|
96
|
+
xValues: [0, 1, 2],
|
|
97
|
+
xScale: valueScale,
|
|
98
|
+
yScale: categoryScale,
|
|
99
|
+
orientation: "horizontal",
|
|
100
|
+
});
|
|
101
|
+
expect(result.xPositions).toEqual([0, 100, 200]);
|
|
102
|
+
expect(result.points.sales?.map((point) => point.y)).toEqual([0, 100, 200]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|