@delacour/react-native-charts 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/package.json +61 -0
- package/src/animation/AGENTS.md +40 -0
- package/src/animation/animation.types.ts +16 -0
- package/src/animation/index.ts +2 -0
- package/src/animation/use-animated-path.ts +74 -0
- package/src/cartesian/AGENTS.md +110 -0
- package/src/cartesian/axes/chart-x-axis.tsx +52 -0
- package/src/cartesian/axes/chart-y-axis.tsx +56 -0
- package/src/cartesian/cartesian-chart.context.tsx +43 -0
- package/src/cartesian/cartesian-chart.tsx +132 -0
- package/src/cartesian/cartesian-chart.types.ts +181 -0
- package/src/cartesian/hooks/use-canvas-size.ts +37 -0
- package/src/cartesian/hooks/use-chart-model.ts +278 -0
- package/src/cartesian/index.ts +41 -0
- package/src/cartesian/marks/chart-area.tsx +83 -0
- package/src/cartesian/marks/chart-bar-group.tsx +132 -0
- package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
- package/src/cartesian/marks/chart-bar.tsx +195 -0
- package/src/cartesian/marks/chart-candlestick.tsx +148 -0
- package/src/cartesian/marks/chart-cursor.tsx +168 -0
- package/src/cartesian/marks/chart-grid.tsx +46 -0
- package/src/cartesian/marks/chart-line.tsx +73 -0
- package/src/cartesian/marks/chart-scatter.tsx +71 -0
- package/src/core/AGENTS.md +77 -0
- package/src/core/animation/morph-strategy.test.ts +38 -0
- package/src/core/animation/morph-strategy.ts +53 -0
- package/src/core/animation/resample-points.test.ts +68 -0
- package/src/core/animation/resample-points.ts +64 -0
- package/src/core/chart.types.ts +62 -0
- package/src/core/curve/build-area.test.ts +48 -0
- package/src/core/curve/build-area.ts +58 -0
- package/src/core/curve/build-line.test.ts +64 -0
- package/src/core/curve/build-line.ts +53 -0
- package/src/core/curve/curves.test.ts +20 -0
- package/src/core/curve/curves.ts +48 -0
- package/src/core/geometry/band.test.ts +79 -0
- package/src/core/geometry/band.ts +111 -0
- package/src/core/geometry/category-domain.test.ts +33 -0
- package/src/core/geometry/category-domain.ts +18 -0
- package/src/core/geometry/chart-bounds.test.ts +36 -0
- package/src/core/geometry/chart-bounds.ts +47 -0
- package/src/core/geometry/chart-layout.test.ts +230 -0
- package/src/core/geometry/chart-layout.ts +162 -0
- package/src/core/geometry/domain.test.ts +87 -0
- package/src/core/geometry/domain.ts +94 -0
- package/src/core/geometry/grid-lines.test.ts +48 -0
- package/src/core/geometry/grid-lines.ts +43 -0
- package/src/core/geometry/stack.test.ts +151 -0
- package/src/core/geometry/stack.ts +109 -0
- package/src/core/geometry/step.test.ts +34 -0
- package/src/core/geometry/step.ts +21 -0
- package/src/core/geometry/transform-input-data.test.ts +105 -0
- package/src/core/geometry/transform-input-data.ts +88 -0
- package/src/core/geometry/x-values.test.ts +37 -0
- package/src/core/geometry/x-values.ts +36 -0
- package/src/core/index.ts +109 -0
- package/src/core/interaction/closest-index.test.ts +36 -0
- package/src/core/interaction/closest-index.ts +27 -0
- package/src/core/interaction/path-segments.test.ts +60 -0
- package/src/core/interaction/path-segments.ts +155 -0
- package/src/core/interaction/y-for-x.test.ts +135 -0
- package/src/core/interaction/y-for-x.ts +106 -0
- package/src/core/polar/build-slice-path.test.ts +173 -0
- package/src/core/polar/build-slice-path.ts +98 -0
- package/src/core/polar/label-position.test.ts +41 -0
- package/src/core/polar/label-position.ts +18 -0
- package/src/core/polar/pie-input.test.ts +36 -0
- package/src/core/polar/pie-input.ts +32 -0
- package/src/core/polar/polar-point.test.ts +55 -0
- package/src/core/polar/polar-point.ts +29 -0
- package/src/core/polar/polar.types.ts +47 -0
- package/src/core/polar/resolve-layout.test.ts +63 -0
- package/src/core/polar/resolve-layout.ts +55 -0
- package/src/core/polar/resolve-slices.test.ts +89 -0
- package/src/core/polar/resolve-slices.ts +58 -0
- package/src/core/polar/slice-index-at.test.ts +52 -0
- package/src/core/polar/slice-index-at.ts +42 -0
- package/src/core/polar/slice-opacity.test.ts +29 -0
- package/src/core/polar/slice-opacity.ts +12 -0
- package/src/core/scale/make-scale.test.ts +55 -0
- package/src/core/scale/make-scale.ts +78 -0
- package/src/core/scale/scale.test.ts +76 -0
- package/src/core/scale/scale.ts +65 -0
- package/src/core/scale/scale.types.ts +30 -0
- package/src/core/shape/build-bars.test.ts +203 -0
- package/src/core/shape/build-bars.ts +167 -0
- package/src/core/shape/build-candles.test.ts +156 -0
- package/src/core/shape/build-candles.ts +153 -0
- package/src/core/shape/build-scatter.test.ts +103 -0
- package/src/core/shape/build-scatter.ts +81 -0
- package/src/core/shape/rect-path.test.ts +61 -0
- package/src/core/shape/rect-path.ts +69 -0
- package/src/core/text/axis-gutters.test.ts +70 -0
- package/src/core/text/axis-gutters.ts +70 -0
- package/src/core/text/bar-label.test.ts +92 -0
- package/src/core/text/bar-label.ts +61 -0
- package/src/core/text/format-tick.test.ts +87 -0
- package/src/core/text/format-tick.ts +76 -0
- package/src/core/text/label-anchor.test.ts +59 -0
- package/src/core/text/label-anchor.ts +62 -0
- package/src/core/ticks/build-ticks.test.ts +38 -0
- package/src/core/ticks/build-ticks.ts +36 -0
- package/src/core/ticks/linear-ticks.test.ts +56 -0
- package/src/core/ticks/linear-ticks.ts +67 -0
- package/src/core/ticks/tick-count.test.ts +46 -0
- package/src/core/ticks/tick-count.ts +35 -0
- package/src/core/ticks/tick.types.ts +7 -0
- package/src/core/ticks/time-ticks.test.ts +61 -0
- package/src/core/ticks/time-ticks.ts +22 -0
- package/src/core/util/as-number.test.ts +45 -0
- package/src/core/util/as-number.ts +22 -0
- package/src/core/util/clamp.test.ts +21 -0
- package/src/core/util/clamp.ts +16 -0
- package/src/core/util/extent.test.ts +33 -0
- package/src/core/util/extent.ts +37 -0
- package/src/core/util/read-at.test.ts +14 -0
- package/src/core/util/read-at.ts +14 -0
- package/src/core/util/sided-number.test.ts +28 -0
- package/src/core/util/sided-number.ts +34 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +84 -0
- package/src/gesture/gesture-overlay.tsx +21 -0
- package/src/gesture/gesture.types.ts +143 -0
- package/src/gesture/index.ts +12 -0
- package/src/gesture/use-chart-scrub.ts +44 -0
- package/src/gesture/use-scrub-gesture.ts +162 -0
- package/src/index.ts +6 -0
- package/src/no-classname.test.ts +27 -0
- package/src/polar/AGENTS.md +55 -0
- package/src/polar/hooks/use-polar-model.ts +88 -0
- package/src/polar/index.ts +14 -0
- package/src/polar/marks/pie-inset.tsx +70 -0
- package/src/polar/marks/pie-label.tsx +71 -0
- package/src/polar/marks/pie-slice.tsx +82 -0
- package/src/polar/marks/pie-slices.tsx +60 -0
- package/src/polar/polar-chart.context.tsx +37 -0
- package/src/polar/polar-chart.tsx +91 -0
- package/src/polar/polar-chart.types.ts +82 -0
- package/src/purity.test.ts +75 -0
- package/src/skia/AGENTS.md +39 -0
- package/src/skia/build-path.ts +22 -0
- package/src/skia/font.ts +78 -0
- package/src/skia/index.ts +9 -0
- package/src/source-tree.test.ts +33 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ChartOrientation, ChartPoint } from "../chart.types";
|
|
2
|
+
import { rectPath } from "./rect-path";
|
|
3
|
+
|
|
4
|
+
/** Which way a candle went: close above open, below it, or unchanged. */
|
|
5
|
+
export type Sentiment = "positive" | "negative" | "neutral";
|
|
6
|
+
|
|
7
|
+
export const SENTIMENTS: readonly Sentiment[] = ["positive", "negative", "neutral"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One candle, in canvas positions and domain values.
|
|
11
|
+
*
|
|
12
|
+
* `x` is the category's position and the four `…Y` fields are positions
|
|
13
|
+
* along the value axis. On a horizontal chart those axes swap — `x` is then
|
|
14
|
+
* a canvas y and `openY` a canvas x — and the names keep the vertical case,
|
|
15
|
+
* like `xValue` and `yValue` do.
|
|
16
|
+
*/
|
|
17
|
+
export type Candle = {
|
|
18
|
+
readonly index: number;
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly openY: number;
|
|
21
|
+
readonly highY: number;
|
|
22
|
+
readonly lowY: number;
|
|
23
|
+
readonly closeY: number;
|
|
24
|
+
readonly openValue: number | null;
|
|
25
|
+
readonly highValue: number | null;
|
|
26
|
+
readonly lowValue: number | null;
|
|
27
|
+
readonly closeValue: number | null;
|
|
28
|
+
readonly sentiment: Sentiment;
|
|
29
|
+
/** Any of the four values missing. The canvas fields are then `NaN`. */
|
|
30
|
+
readonly gap: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Close above open is positive. Equal, or anything unreadable, is neutral. */
|
|
34
|
+
export function candleSentiment(openValue: number | null, closeValue: number | null): Sentiment {
|
|
35
|
+
if (openValue === null || closeValue === null || !Number.isFinite(openValue) || !Number.isFinite(closeValue)) {
|
|
36
|
+
return "neutral";
|
|
37
|
+
}
|
|
38
|
+
if (closeValue > openValue) return "positive";
|
|
39
|
+
if (closeValue < openValue) return "negative";
|
|
40
|
+
return "neutral";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Four series zipped into candles by index.
|
|
45
|
+
*
|
|
46
|
+
* A row missing any of the four is a gap candle, kept in its place, so the
|
|
47
|
+
* candle count — and therefore the verb count of every path — matches the
|
|
48
|
+
* row count.
|
|
49
|
+
*/
|
|
50
|
+
export function candlePoints(
|
|
51
|
+
open: readonly ChartPoint[],
|
|
52
|
+
high: readonly ChartPoint[],
|
|
53
|
+
low: readonly ChartPoint[],
|
|
54
|
+
close: readonly ChartPoint[],
|
|
55
|
+
orientation: ChartOrientation = "vertical"
|
|
56
|
+
): Candle[] {
|
|
57
|
+
const length = Math.min(open.length, high.length, low.length, close.length);
|
|
58
|
+
const horizontal = orientation === "horizontal";
|
|
59
|
+
const valueAt = (point: ChartPoint): number | null => (horizontal ? point.x : point.y);
|
|
60
|
+
const candles: Candle[] = [];
|
|
61
|
+
|
|
62
|
+
for (let index = 0; index < length; index += 1) {
|
|
63
|
+
const o = open[index] as ChartPoint;
|
|
64
|
+
const h = high[index] as ChartPoint;
|
|
65
|
+
const l = low[index] as ChartPoint;
|
|
66
|
+
const c = close[index] as ChartPoint;
|
|
67
|
+
const gap = [o, h, l, c].some((point) => {
|
|
68
|
+
const value = valueAt(point);
|
|
69
|
+
return value === null || !Number.isFinite(value) || point.yValue === null;
|
|
70
|
+
});
|
|
71
|
+
candles.push({
|
|
72
|
+
index,
|
|
73
|
+
x: (horizontal ? o.y : o.x) ?? Number.NaN,
|
|
74
|
+
openY: gap ? Number.NaN : (valueAt(o) as number),
|
|
75
|
+
highY: gap ? Number.NaN : (valueAt(h) as number),
|
|
76
|
+
lowY: gap ? Number.NaN : (valueAt(l) as number),
|
|
77
|
+
closeY: gap ? Number.NaN : (valueAt(c) as number),
|
|
78
|
+
openValue: o.yValue,
|
|
79
|
+
highValue: h.yValue,
|
|
80
|
+
lowValue: l.yValue,
|
|
81
|
+
closeValue: c.yValue,
|
|
82
|
+
sentiment: gap ? "neutral" : candleSentiment(o.yValue, c.yValue),
|
|
83
|
+
gap,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return candles;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type CandlePathOptions = {
|
|
91
|
+
readonly bandwidth: number;
|
|
92
|
+
/** A body thinner than this is expanded about its midpoint, so a flat candle is still visible. Defaults to 1. */
|
|
93
|
+
readonly minBodyHeight?: number;
|
|
94
|
+
/** Canvas position along the value axis a gap candle collapses to. Defaults to 0. */
|
|
95
|
+
readonly baseline?: number;
|
|
96
|
+
/** Bodies span open to close along y (the default) or along x. */
|
|
97
|
+
readonly orientation?: ChartOrientation;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type CandlePaths = {
|
|
101
|
+
readonly bodies: string;
|
|
102
|
+
readonly wicks: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bodies and wicks, one pair of paths per sentiment.
|
|
107
|
+
*
|
|
108
|
+
* **Every candle appears in all three sentiment paths.** In the two whose
|
|
109
|
+
* sentiment it does not have it is a zero-size rect and a zero-length wick at
|
|
110
|
+
* its own midpoint. Six paths of N shapes each, always, so a candle whose
|
|
111
|
+
* close crosses its open morphs from one colour's path into the other rather
|
|
112
|
+
* than vanishing from one and appearing in the next — and so the paths stay
|
|
113
|
+
* interpolatable across the change at all.
|
|
114
|
+
*
|
|
115
|
+
* A gap candle is degenerate in all six, at the baseline. Wicks are drawn
|
|
116
|
+
* with butt caps by the mark, which is what makes a zero-length wick draw
|
|
117
|
+
* nothing rather than a dot.
|
|
118
|
+
*/
|
|
119
|
+
export function buildCandlePaths(
|
|
120
|
+
candles: readonly Candle[],
|
|
121
|
+
options: CandlePathOptions
|
|
122
|
+
): Record<Sentiment, CandlePaths> {
|
|
123
|
+
const { bandwidth, minBodyHeight = 1, baseline = 0, orientation = "vertical" } = options;
|
|
124
|
+
const horizontal = orientation === "horizontal";
|
|
125
|
+
const half = bandwidth / 2;
|
|
126
|
+
// A rect stated as (category from, value from, category to, value to).
|
|
127
|
+
const rect = (c0: number, v0: number, c1: number, v1: number): string =>
|
|
128
|
+
horizontal ? rectPath(v0, c0, v1, c1, {}) : rectPath(c0, v0, c1, v1, {});
|
|
129
|
+
const line = (c: number, v0: number, v1: number): string =>
|
|
130
|
+
horizontal ? `M${v0},${c}L${v1},${c}` : `M${c},${v0}L${c},${v1}`;
|
|
131
|
+
const bodies: Record<Sentiment, string> = { positive: "", negative: "", neutral: "" };
|
|
132
|
+
const wicks: Record<Sentiment, string> = { positive: "", negative: "", neutral: "" };
|
|
133
|
+
|
|
134
|
+
for (const candle of candles) {
|
|
135
|
+
const mid = candle.gap ? baseline : (candle.openY + candle.closeY) / 2;
|
|
136
|
+
for (const sentiment of SENTIMENTS) {
|
|
137
|
+
if (candle.gap || sentiment !== candle.sentiment) {
|
|
138
|
+
bodies[sentiment] += rect(candle.x, mid, candle.x, mid);
|
|
139
|
+
wicks[sentiment] += line(candle.x, mid, mid);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const height = Math.max(Math.abs(candle.closeY - candle.openY), minBodyHeight);
|
|
143
|
+
bodies[sentiment] += rect(candle.x - half, mid - height / 2, candle.x + half, mid + height / 2);
|
|
144
|
+
wicks[sentiment] += line(candle.x, candle.highY, candle.lowY);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
positive: { bodies: bodies.positive, wicks: wicks.positive },
|
|
150
|
+
negative: { bodies: bodies.negative, wicks: wicks.negative },
|
|
151
|
+
neutral: { bodies: bodies.neutral, wicks: wicks.neutral },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildScatterPath } from "./build-scatter";
|
|
4
|
+
|
|
5
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
6
|
+
|
|
7
|
+
function commands(path: string): string {
|
|
8
|
+
return (path.match(/[A-Za-z]/g) ?? []).join("");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function numbers(path: string): number[] {
|
|
12
|
+
return (path.match(/-?\d+(?:\.\d+)?(?:e[-+]?\d+)?/g) ?? []).map(Number);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("buildScatterPath", () => {
|
|
16
|
+
test("a circle is a move and four cubics, closed", () => {
|
|
17
|
+
expect(commands(buildScatterPath([at(10, 10)], { radius: 4, baseline: 100 }))).toBe("MCCCCZ");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("a square is a move and three lines, closed", () => {
|
|
21
|
+
expect(commands(buildScatterPath([at(10, 10)], { radius: 4, shape: "square", baseline: 100 }))).toBe("MLLLZ");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a star is a move and nine lines, closed", () => {
|
|
25
|
+
expect(commands(buildScatterPath([at(10, 10)], { radius: 4, shape: "star", baseline: 100 }))).toBe("MLLLLLLLLLZ");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("one shape's worth of verbs per point", () => {
|
|
29
|
+
const points = [at(10, 10), at(20, 20), at(30, 30)];
|
|
30
|
+
expect(commands(buildScatterPath(points, { radius: 4, baseline: 100 }))).toBe("MCCCCZ".repeat(3));
|
|
31
|
+
expect(commands(buildScatterPath(points, { radius: 4, shape: "square", baseline: 100 }))).toBe("MLLLZ".repeat(3));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a square's corners are a radius from the centre on each axis", () => {
|
|
35
|
+
const path = buildScatterPath([at(10, 20)], { radius: 4, shape: "square", baseline: 100 });
|
|
36
|
+
expect(numbers(path)).toEqual([6, 16, 14, 16, 14, 24, 6, 24]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a circle's cubics start at the top and pass through the four compass points", () => {
|
|
40
|
+
const path = buildScatterPath([at(10, 20)], { radius: 4, baseline: 100 });
|
|
41
|
+
expect(path.startsWith("M10,16")).toBe(true);
|
|
42
|
+
const values = numbers(path);
|
|
43
|
+
// Each cubic's end point is every sixth pair after the move.
|
|
44
|
+
expect([values[6], values[7]]).toEqual([14, 20]);
|
|
45
|
+
expect([values[12], values[13]]).toEqual([10, 24]);
|
|
46
|
+
expect([values[18], values[19]]).toEqual([6, 20]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a star alternates outer and inner vertices, the inner at 0.382 of the radius", () => {
|
|
50
|
+
const values = numbers(buildScatterPath([at(0, 0)], { radius: 10, shape: "star", baseline: 100 }));
|
|
51
|
+
const distances: number[] = [];
|
|
52
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
53
|
+
distances.push(Math.hypot(values[index] as number, values[index + 1] as number));
|
|
54
|
+
}
|
|
55
|
+
expect(distances).toHaveLength(10);
|
|
56
|
+
for (let index = 0; index < distances.length; index += 1) {
|
|
57
|
+
expect(distances[index]).toBeCloseTo(index % 2 === 0 ? 10 : 3.82, 5);
|
|
58
|
+
}
|
|
59
|
+
// The first vertex points straight up.
|
|
60
|
+
expect(values[0]).toBeCloseTo(0, 10);
|
|
61
|
+
expect(values[1]).toBeCloseTo(-10, 10);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a radius function receives the point and its index", () => {
|
|
65
|
+
const seen: [number, number][] = [];
|
|
66
|
+
buildScatterPath([at(1, 1), at(2, 2)], {
|
|
67
|
+
radius: (point, index) => {
|
|
68
|
+
seen.push([point.x, index]);
|
|
69
|
+
return 3;
|
|
70
|
+
},
|
|
71
|
+
baseline: 100,
|
|
72
|
+
});
|
|
73
|
+
expect(seen).toEqual([
|
|
74
|
+
[1, 0],
|
|
75
|
+
[2, 1],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a gap keeps its verbs as a zero-radius shape at the baseline", () => {
|
|
80
|
+
// The animation invariant again: a datum that gains a value morphs out
|
|
81
|
+
// of the baseline rather than the whole series snapping.
|
|
82
|
+
const path = buildScatterPath([at(10, 10), at(20, null), at(30, 30)], { radius: 4, baseline: 100 });
|
|
83
|
+
expect(commands(path)).toBe("MCCCCZ".repeat(3));
|
|
84
|
+
expect(path).toContain("M20,100");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a non-finite radius collapses the shape at its own point", () => {
|
|
88
|
+
const path = buildScatterPath([at(10, 10)], { radius: () => Number.NaN, baseline: 100 });
|
|
89
|
+
expect(commands(path)).toBe("MCCCCZ");
|
|
90
|
+
expect(path.startsWith("M10,10")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is empty for no points", () => {
|
|
94
|
+
expect(buildScatterPath([], { radius: 4, baseline: 100 })).toBe("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a horizontal gap collapses at the baseline x, keeping its category y", () => {
|
|
98
|
+
const gap: ChartPoint = { x: Number.NaN, y: 40, xValue: 1, yValue: null };
|
|
99
|
+
const path = buildScatterPath([gap], { radius: 4, baseline: 7, orientation: "horizontal" });
|
|
100
|
+
expect(commands(path)).toBe("MCCCCZ");
|
|
101
|
+
expect(path.startsWith("M7,40")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ChartOrientation, ChartPoint } from "../chart.types";
|
|
2
|
+
import { KAPPA } from "./rect-path";
|
|
3
|
+
|
|
4
|
+
export type ScatterShape = "circle" | "square" | "star";
|
|
5
|
+
|
|
6
|
+
/** A star's inner vertices sit at this fraction of the radius — the golden-ratio pentagram. */
|
|
7
|
+
const STAR_INNER = 0.382;
|
|
8
|
+
|
|
9
|
+
export type ScatterPathOptions = {
|
|
10
|
+
/** One radius for every point, or one per point. */
|
|
11
|
+
readonly radius: number | ((point: ChartPoint, index: number) => number);
|
|
12
|
+
readonly shape?: ScatterShape;
|
|
13
|
+
/** Canvas position along the value axis a gap's collapsed shape sits at, so it grows out of the axis when it gains a value. */
|
|
14
|
+
readonly baseline: number;
|
|
15
|
+
/** Whether the value axis is y (the default) or x. */
|
|
16
|
+
readonly orientation?: ChartOrientation;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One shape per point, all in one path.
|
|
21
|
+
*
|
|
22
|
+
* Each shape has a fixed verb sequence — a circle is four cubics, a square
|
|
23
|
+
* three lines, a star nine — and a gap or an unreadable radius emits the same
|
|
24
|
+
* shape at radius zero rather than nothing. That keeps two series of equal
|
|
25
|
+
* length interpolatable whatever their values, which is what lets a scatter
|
|
26
|
+
* animate a data change instead of snapping.
|
|
27
|
+
*/
|
|
28
|
+
export function buildScatterPath(points: readonly ChartPoint[], options: ScatterPathOptions): string {
|
|
29
|
+
const { radius, shape = "circle", baseline, orientation = "vertical" } = options;
|
|
30
|
+
const horizontal = orientation === "horizontal";
|
|
31
|
+
let path = "";
|
|
32
|
+
|
|
33
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
34
|
+
const point = points[index] as ChartPoint;
|
|
35
|
+
const gap = point.y === null || !Number.isFinite(point.y) || !Number.isFinite(point.x);
|
|
36
|
+
const requested = typeof radius === "function" ? radius(point, index) : radius;
|
|
37
|
+
const r = gap || !Number.isFinite(requested) || requested < 0 ? 0 : requested;
|
|
38
|
+
const x = gap && horizontal ? baseline : point.x;
|
|
39
|
+
const y = gap && !horizontal ? baseline : (point.y as number);
|
|
40
|
+
path += shapePath(shape, x, y, r);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shapePath(shape: ScatterShape, cx: number, cy: number, r: number): string {
|
|
47
|
+
switch (shape) {
|
|
48
|
+
case "circle":
|
|
49
|
+
return circlePath(cx, cy, r);
|
|
50
|
+
case "square":
|
|
51
|
+
return `M${cx - r},${cy - r}L${cx + r},${cy - r}L${cx + r},${cy + r}L${cx - r},${cy + r}Z`;
|
|
52
|
+
case "star":
|
|
53
|
+
return starPath(cx, cy, r);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A circle as four cubics, starting at the top and running clockwise. */
|
|
58
|
+
function circlePath(cx: number, cy: number, r: number): string {
|
|
59
|
+
const k = r * KAPPA;
|
|
60
|
+
return (
|
|
61
|
+
`M${cx},${cy - r}` +
|
|
62
|
+
`C${cx + k},${cy - r},${cx + r},${cy - k},${cx + r},${cy}` +
|
|
63
|
+
`C${cx + r},${cy + k},${cx + k},${cy + r},${cx},${cy + r}` +
|
|
64
|
+
`C${cx - k},${cy + r},${cx - r},${cy + k},${cx - r},${cy}` +
|
|
65
|
+
`C${cx - r},${cy - k},${cx - k},${cy - r},${cx},${cy - r}` +
|
|
66
|
+
"Z"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A five-pointed star, first vertex straight up, alternating outer and inner radii. */
|
|
71
|
+
function starPath(cx: number, cy: number, r: number): string {
|
|
72
|
+
let path = "";
|
|
73
|
+
for (let vertex = 0; vertex < 10; vertex += 1) {
|
|
74
|
+
const distance = vertex % 2 === 0 ? r : r * STAR_INNER;
|
|
75
|
+
const angle = -Math.PI / 2 + (vertex * Math.PI) / 5;
|
|
76
|
+
const x = cx + Math.cos(angle) * distance;
|
|
77
|
+
const y = cy + Math.sin(angle) * distance;
|
|
78
|
+
path += `${vertex === 0 ? "M" : "L"}${x},${y}`;
|
|
79
|
+
}
|
|
80
|
+
return `${path}Z`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { KAPPA, rectPath } from "./rect-path";
|
|
3
|
+
|
|
4
|
+
/** The command letters of a path, which is what `isInterpolatable` compares. */
|
|
5
|
+
function commands(path: string): string {
|
|
6
|
+
return (path.match(/[A-Za-z]/g) ?? []).join("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Every number in a path, in order. */
|
|
10
|
+
function numbers(path: string): number[] {
|
|
11
|
+
return (path.match(/-?\d+(?:\.\d+)?(?:e[-+]?\d+)?/g) ?? []).map(Number);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("rectPath", () => {
|
|
15
|
+
test("is always a move, four cubic corners and three lines, closed", () => {
|
|
16
|
+
// The animation invariant: a bar that rounds its corners when the data
|
|
17
|
+
// changes must keep the same verbs, or Skia snaps instead of morphing.
|
|
18
|
+
expect(commands(rectPath(0, 0, 10, 20, {}))).toBe("MCLCLCLCZ");
|
|
19
|
+
expect(commands(rectPath(0, 0, 10, 20, { topLeft: 4, topRight: 4 }))).toBe("MCLCLCLCZ");
|
|
20
|
+
expect(commands(rectPath(0, 0, 10, 20, { topLeft: 100, bottomRight: 100 }))).toBe("MCLCLCLCZ");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("with square corners every point sits on the rect's edge", () => {
|
|
24
|
+
for (const value of numbers(rectPath(2, 3, 12, 23, {}))) {
|
|
25
|
+
expect([2, 3, 12, 23]).toContain(value);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("starts on the left edge, one radius below the top", () => {
|
|
30
|
+
expect(rectPath(0, 0, 10, 20, { topLeft: 4 }).startsWith("M0,4")).toBe(true);
|
|
31
|
+
expect(rectPath(0, 0, 10, 20, {}).startsWith("M0,0")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a rounded corner's cubic reaches the tangent points, not the corner", () => {
|
|
35
|
+
const path = rectPath(0, 0, 10, 20, { topLeft: 4 });
|
|
36
|
+
// M0,4 then C toward (4,0): the cubic's end point is the tangent point.
|
|
37
|
+
expect(path.startsWith(`M0,4C0,${4 - 4 * KAPPA},${4 - 4 * KAPPA},0,4,0`)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("clamps a radius to half the shorter side", () => {
|
|
41
|
+
// A 10×20 rect can round by at most 5; more would fold the path over.
|
|
42
|
+
const path = rectPath(0, 0, 10, 20, { topLeft: 100, topRight: 100 });
|
|
43
|
+
expect(path.startsWith("M0,5")).toBe(true);
|
|
44
|
+
expect(path).toContain("L5,0");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("normalises a rect given upside down or back to front", () => {
|
|
48
|
+
expect(rectPath(10, 20, 0, 0, {})).toBe(rectPath(0, 0, 10, 20, {}));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a zero-height rect still has all nine verbs", () => {
|
|
52
|
+
// A gap in a bar series is a zero-height bar at the baseline, so its
|
|
53
|
+
// path must match the bar it may animate into.
|
|
54
|
+
expect(commands(rectPath(0, 5, 10, 5, { topLeft: 4 }))).toBe("MCLCLCLCZ");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("treats a negative or unreadable radius as square", () => {
|
|
58
|
+
expect(rectPath(0, 0, 10, 20, { topLeft: -4 })).toBe(rectPath(0, 0, 10, 20, {}));
|
|
59
|
+
expect(rectPath(0, 0, 10, 20, { topLeft: Number.NaN })).toBe(rectPath(0, 0, 10, 20, {}));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** A radius per corner. A missing corner is square. */
|
|
2
|
+
export type CornerRadii = {
|
|
3
|
+
readonly topLeft?: number;
|
|
4
|
+
readonly topRight?: number;
|
|
5
|
+
readonly bottomRight?: number;
|
|
6
|
+
readonly bottomLeft?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The control-point distance, as a fraction of the radius, at which a cubic
|
|
11
|
+
* best approximates a quarter circle. Standard value: `4(√2 − 1) / 3`.
|
|
12
|
+
*/
|
|
13
|
+
export const KAPPA = 0.5522847498;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A rectangle as an SVG path whose every corner is a cubic.
|
|
17
|
+
*
|
|
18
|
+
* Always `M C L C L C L C Z` — nine verbs — whether a corner is rounded or
|
|
19
|
+
* not. A square corner is a cubic whose four points coincide. That is the
|
|
20
|
+
* animation invariant: Skia interpolates two paths only when their verbs
|
|
21
|
+
* match, so a bar that goes from square to rounded, or from a value to a gap,
|
|
22
|
+
* has to keep the same verb sequence or it snaps instead of morphing.
|
|
23
|
+
*
|
|
24
|
+
* Radii are clamped to half the shorter side so a tall thin bar rounds into a
|
|
25
|
+
* capsule rather than folding the path over itself.
|
|
26
|
+
*/
|
|
27
|
+
export function rectPath(left: number, top: number, right: number, bottom: number, radii: CornerRadii): string {
|
|
28
|
+
const x0 = Math.min(left, right);
|
|
29
|
+
const x1 = Math.max(left, right);
|
|
30
|
+
const y0 = Math.min(top, bottom);
|
|
31
|
+
const y1 = Math.max(top, bottom);
|
|
32
|
+
const limit = Math.min((x1 - x0) / 2, (y1 - y0) / 2);
|
|
33
|
+
|
|
34
|
+
const tl = clampRadius(radii.topLeft, limit);
|
|
35
|
+
const tr = clampRadius(radii.topRight, limit);
|
|
36
|
+
const br = clampRadius(radii.bottomRight, limit);
|
|
37
|
+
const bl = clampRadius(radii.bottomLeft, limit);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
`M${x0},${y0 + tl}` +
|
|
41
|
+
corner(x0, y0 + tl, x0, y0, x0 + tl, y0) +
|
|
42
|
+
`L${x1 - tr},${y0}` +
|
|
43
|
+
corner(x1 - tr, y0, x1, y0, x1, y0 + tr) +
|
|
44
|
+
`L${x1},${y1 - br}` +
|
|
45
|
+
corner(x1, y1 - br, x1, y1, x1 - br, y1) +
|
|
46
|
+
`L${x0 + bl},${y1}` +
|
|
47
|
+
corner(x0 + bl, y1, x0, y1, x0, y1 - bl) +
|
|
48
|
+
"Z"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A radius that is finite, non-negative and no more than `limit`. */
|
|
53
|
+
function clampRadius(radius: number | undefined, limit: number): number {
|
|
54
|
+
if (radius === undefined || !Number.isFinite(radius) || radius <= 0) return 0;
|
|
55
|
+
return Math.min(radius, Math.max(limit, 0));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A cubic from `(ax, ay)` to `(bx, by)` bowing toward the corner `(cx, cy)`.
|
|
60
|
+
* With the corner on both points it is a cubic of zero length, which is what
|
|
61
|
+
* keeps the verb count fixed.
|
|
62
|
+
*/
|
|
63
|
+
function corner(ax: number, ay: number, cx: number, cy: number, bx: number, by: number): string {
|
|
64
|
+
const c1x = ax + (cx - ax) * KAPPA;
|
|
65
|
+
const c1y = ay + (cy - ay) * KAPPA;
|
|
66
|
+
const c2x = bx + (cx - bx) * KAPPA;
|
|
67
|
+
const c2y = by + (cy - by) * KAPPA;
|
|
68
|
+
return `C${c1x},${c1y},${c2x},${c2y},${bx},${by}`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveAxisGutters } from "./axis-gutters";
|
|
3
|
+
|
|
4
|
+
const base = { yLabelWidths: [10, 24, 18], xLabelWidths: [30, 30, 30], fontHeight: 12 };
|
|
5
|
+
|
|
6
|
+
describe("resolveAxisGutters", () => {
|
|
7
|
+
test("reserves the widest y label plus the tick padding", () => {
|
|
8
|
+
expect(resolveAxisGutters({ ...base, tickPadding: 6 }).left).toBe(30);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("reserves one line height under the plot for the x labels", () => {
|
|
12
|
+
expect(resolveAxisGutters({ ...base, tickPadding: 6 }).bottom).toBe(18);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("moves the gutter to the other side when the axis does", () => {
|
|
16
|
+
const right = resolveAxisGutters({ ...base, yAxisSide: "right" });
|
|
17
|
+
expect(right.left).toBeLessThan(right.right);
|
|
18
|
+
const top = resolveAxisGutters({ ...base, xAxisSide: "top" });
|
|
19
|
+
// The bottom keeps the y labels' half-line overhang, but loses the x
|
|
20
|
+
// gutter entirely — 6 rather than 18.
|
|
21
|
+
expect(top.bottom).toBe(6);
|
|
22
|
+
expect(top.top).toBeGreaterThan(base.fontHeight);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("carries the first and last x label's overhang", () => {
|
|
26
|
+
// Without it the leftmost label is clipped in half by the canvas edge,
|
|
27
|
+
// which reads as a rendering bug rather than a layout one.
|
|
28
|
+
const gutters = resolveAxisGutters({ ...base, yLabelWidths: [], xLabelWidths: [80, 30, 80] });
|
|
29
|
+
expect(gutters.left).toBe(40);
|
|
30
|
+
expect(gutters.right).toBe(40);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("takes the wider of the y gutter and the label overhang, not their sum", () => {
|
|
34
|
+
const gutters = resolveAxisGutters({ ...base, yLabelWidths: [100], xLabelWidths: [20, 20], tickPadding: 6 });
|
|
35
|
+
expect(gutters.left).toBe(106);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("reserves half a line above and below for the outermost y labels", () => {
|
|
39
|
+
// A y label is centred on its tick and the outermost ticks sit on the
|
|
40
|
+
// plot rect's edges, so half a line escapes at each end. Without this the
|
|
41
|
+
// top label is sliced by the canvas — the vertical twin of the x labels'
|
|
42
|
+
// horizontal overhang.
|
|
43
|
+
const gutters = resolveAxisGutters({ ...base, xLabelWidths: [], fontHeight: 12 });
|
|
44
|
+
expect(gutters.top).toBe(6);
|
|
45
|
+
expect(gutters.bottom).toBe(6);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("takes the wider of the x gutter and the y overhang, not their sum", () => {
|
|
49
|
+
const gutters = resolveAxisGutters({ ...base, tickPadding: 6 });
|
|
50
|
+
expect(gutters.bottom).toBe(18);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("reserves no vertical overhang when the y axis draws no labels", () => {
|
|
54
|
+
expect(resolveAxisGutters({ ...base, yLabelWidths: [], xLabelWidths: [] }).top).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("reserves nothing for an axis that is not drawn", () => {
|
|
58
|
+
const gutters = resolveAxisGutters({ ...base, showXAxis: false, showYAxis: false });
|
|
59
|
+
expect(gutters).toEqual({ left: 0, right: 0, top: 0, bottom: 0 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("reserves nothing when there are no labels to draw", () => {
|
|
63
|
+
expect(resolveAxisGutters({ yLabelWidths: [], xLabelWidths: [], fontHeight: 12 })).toEqual({
|
|
64
|
+
left: 0,
|
|
65
|
+
right: 0,
|
|
66
|
+
top: 0,
|
|
67
|
+
bottom: 0,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ChartGutters } from "../geometry/chart-bounds";
|
|
2
|
+
|
|
3
|
+
export type AxisGutterInput = {
|
|
4
|
+
/** Measured widths of the y-axis labels, in points. */
|
|
5
|
+
readonly yLabelWidths: readonly number[];
|
|
6
|
+
/** Measured widths of the x-axis labels, in points. */
|
|
7
|
+
readonly xLabelWidths: readonly number[];
|
|
8
|
+
/** Line height of the axis font. */
|
|
9
|
+
readonly fontHeight: number;
|
|
10
|
+
/** Space between a label and the plot rect. */
|
|
11
|
+
readonly tickPadding?: number;
|
|
12
|
+
readonly showXAxis?: boolean;
|
|
13
|
+
readonly showYAxis?: boolean;
|
|
14
|
+
readonly yAxisSide?: "left" | "right";
|
|
15
|
+
readonly xAxisSide?: "top" | "bottom";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TICK_PADDING = 6;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How much room the axes need outside the plot rect.
|
|
22
|
+
*
|
|
23
|
+
* This is the second half of a two-pass layout, and the reason it is only two
|
|
24
|
+
* passes. Label width sets the gutter, the gutter sets the plot rect, and the
|
|
25
|
+
* plot rect sets how many ticks fit — a cycle. It is broken by computing the
|
|
26
|
+
* tick *values* against the whole canvas first, measuring those, and then
|
|
27
|
+
* moving only the scale's range. The values never change, so the layout is a
|
|
28
|
+
* pure function of the measurements and never oscillates.
|
|
29
|
+
*
|
|
30
|
+
* The horizontal gutters also carry the first and last x label's overhang.
|
|
31
|
+
* Without it a "Jan 2026" under the leftmost tick is clipped in half by the
|
|
32
|
+
* canvas edge, which reads as a rendering bug rather than a layout one.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveAxisGutters(input: AxisGutterInput): ChartGutters {
|
|
35
|
+
const padding = input.tickPadding ?? DEFAULT_TICK_PADDING;
|
|
36
|
+
const showY = input.showYAxis ?? true;
|
|
37
|
+
const showX = input.showXAxis ?? true;
|
|
38
|
+
const labels = showX ? input.xLabelWidths : [];
|
|
39
|
+
|
|
40
|
+
const widestY = showY ? widest(input.yLabelWidths) : 0;
|
|
41
|
+
const yGutter = widestY === 0 ? 0 : widestY + padding;
|
|
42
|
+
const xGutter = labels.length === 0 ? 0 : input.fontHeight + padding;
|
|
43
|
+
|
|
44
|
+
const overhangLeft = (labels[0] ?? 0) / 2;
|
|
45
|
+
const overhangRight = (labels[labels.length - 1] ?? 0) / 2;
|
|
46
|
+
|
|
47
|
+
const yOnLeft = (input.yAxisSide ?? "left") === "left";
|
|
48
|
+
const xOnBottom = (input.xAxisSide ?? "bottom") === "bottom";
|
|
49
|
+
|
|
50
|
+
// A y label is centred on its tick, and the outermost ticks sit exactly on
|
|
51
|
+
// the plot rect's edges — so half a line escapes at the top and half at the
|
|
52
|
+
// bottom. Without this the first and last labels are sliced by the canvas,
|
|
53
|
+
// which is the vertical twin of the x labels' horizontal overhang.
|
|
54
|
+
const yOverhang = yGutter === 0 ? 0 : input.fontHeight / 2;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
left: Math.max(yOnLeft ? yGutter : 0, overhangLeft),
|
|
58
|
+
right: Math.max(yOnLeft ? 0 : yGutter, overhangRight),
|
|
59
|
+
top: Math.max(xOnBottom ? 0 : xGutter, yOverhang),
|
|
60
|
+
bottom: Math.max(xOnBottom ? xGutter : 0, yOverhang),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function widest(widths: readonly number[]): number {
|
|
65
|
+
let max = 0;
|
|
66
|
+
for (const width of widths) {
|
|
67
|
+
if (Number.isFinite(width) && width > max) max = width;
|
|
68
|
+
}
|
|
69
|
+
return max;
|
|
70
|
+
}
|