@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.
Files changed (146) hide show
  1. package/README.md +97 -0
  2. package/package.json +61 -0
  3. package/src/animation/AGENTS.md +40 -0
  4. package/src/animation/animation.types.ts +16 -0
  5. package/src/animation/index.ts +2 -0
  6. package/src/animation/use-animated-path.ts +74 -0
  7. package/src/cartesian/AGENTS.md +110 -0
  8. package/src/cartesian/axes/chart-x-axis.tsx +52 -0
  9. package/src/cartesian/axes/chart-y-axis.tsx +56 -0
  10. package/src/cartesian/cartesian-chart.context.tsx +43 -0
  11. package/src/cartesian/cartesian-chart.tsx +132 -0
  12. package/src/cartesian/cartesian-chart.types.ts +181 -0
  13. package/src/cartesian/hooks/use-canvas-size.ts +37 -0
  14. package/src/cartesian/hooks/use-chart-model.ts +278 -0
  15. package/src/cartesian/index.ts +41 -0
  16. package/src/cartesian/marks/chart-area.tsx +83 -0
  17. package/src/cartesian/marks/chart-bar-group.tsx +132 -0
  18. package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
  19. package/src/cartesian/marks/chart-bar.tsx +195 -0
  20. package/src/cartesian/marks/chart-candlestick.tsx +148 -0
  21. package/src/cartesian/marks/chart-cursor.tsx +168 -0
  22. package/src/cartesian/marks/chart-grid.tsx +46 -0
  23. package/src/cartesian/marks/chart-line.tsx +73 -0
  24. package/src/cartesian/marks/chart-scatter.tsx +71 -0
  25. package/src/core/AGENTS.md +77 -0
  26. package/src/core/animation/morph-strategy.test.ts +38 -0
  27. package/src/core/animation/morph-strategy.ts +53 -0
  28. package/src/core/animation/resample-points.test.ts +68 -0
  29. package/src/core/animation/resample-points.ts +64 -0
  30. package/src/core/chart.types.ts +62 -0
  31. package/src/core/curve/build-area.test.ts +48 -0
  32. package/src/core/curve/build-area.ts +58 -0
  33. package/src/core/curve/build-line.test.ts +64 -0
  34. package/src/core/curve/build-line.ts +53 -0
  35. package/src/core/curve/curves.test.ts +20 -0
  36. package/src/core/curve/curves.ts +48 -0
  37. package/src/core/geometry/band.test.ts +79 -0
  38. package/src/core/geometry/band.ts +111 -0
  39. package/src/core/geometry/category-domain.test.ts +33 -0
  40. package/src/core/geometry/category-domain.ts +18 -0
  41. package/src/core/geometry/chart-bounds.test.ts +36 -0
  42. package/src/core/geometry/chart-bounds.ts +47 -0
  43. package/src/core/geometry/chart-layout.test.ts +230 -0
  44. package/src/core/geometry/chart-layout.ts +162 -0
  45. package/src/core/geometry/domain.test.ts +87 -0
  46. package/src/core/geometry/domain.ts +94 -0
  47. package/src/core/geometry/grid-lines.test.ts +48 -0
  48. package/src/core/geometry/grid-lines.ts +43 -0
  49. package/src/core/geometry/stack.test.ts +151 -0
  50. package/src/core/geometry/stack.ts +109 -0
  51. package/src/core/geometry/step.test.ts +34 -0
  52. package/src/core/geometry/step.ts +21 -0
  53. package/src/core/geometry/transform-input-data.test.ts +105 -0
  54. package/src/core/geometry/transform-input-data.ts +88 -0
  55. package/src/core/geometry/x-values.test.ts +37 -0
  56. package/src/core/geometry/x-values.ts +36 -0
  57. package/src/core/index.ts +109 -0
  58. package/src/core/interaction/closest-index.test.ts +36 -0
  59. package/src/core/interaction/closest-index.ts +27 -0
  60. package/src/core/interaction/path-segments.test.ts +60 -0
  61. package/src/core/interaction/path-segments.ts +155 -0
  62. package/src/core/interaction/y-for-x.test.ts +135 -0
  63. package/src/core/interaction/y-for-x.ts +106 -0
  64. package/src/core/polar/build-slice-path.test.ts +173 -0
  65. package/src/core/polar/build-slice-path.ts +98 -0
  66. package/src/core/polar/label-position.test.ts +41 -0
  67. package/src/core/polar/label-position.ts +18 -0
  68. package/src/core/polar/pie-input.test.ts +36 -0
  69. package/src/core/polar/pie-input.ts +32 -0
  70. package/src/core/polar/polar-point.test.ts +55 -0
  71. package/src/core/polar/polar-point.ts +29 -0
  72. package/src/core/polar/polar.types.ts +47 -0
  73. package/src/core/polar/resolve-layout.test.ts +63 -0
  74. package/src/core/polar/resolve-layout.ts +55 -0
  75. package/src/core/polar/resolve-slices.test.ts +89 -0
  76. package/src/core/polar/resolve-slices.ts +58 -0
  77. package/src/core/polar/slice-index-at.test.ts +52 -0
  78. package/src/core/polar/slice-index-at.ts +42 -0
  79. package/src/core/polar/slice-opacity.test.ts +29 -0
  80. package/src/core/polar/slice-opacity.ts +12 -0
  81. package/src/core/scale/make-scale.test.ts +55 -0
  82. package/src/core/scale/make-scale.ts +78 -0
  83. package/src/core/scale/scale.test.ts +76 -0
  84. package/src/core/scale/scale.ts +65 -0
  85. package/src/core/scale/scale.types.ts +30 -0
  86. package/src/core/shape/build-bars.test.ts +203 -0
  87. package/src/core/shape/build-bars.ts +167 -0
  88. package/src/core/shape/build-candles.test.ts +156 -0
  89. package/src/core/shape/build-candles.ts +153 -0
  90. package/src/core/shape/build-scatter.test.ts +103 -0
  91. package/src/core/shape/build-scatter.ts +81 -0
  92. package/src/core/shape/rect-path.test.ts +61 -0
  93. package/src/core/shape/rect-path.ts +69 -0
  94. package/src/core/text/axis-gutters.test.ts +70 -0
  95. package/src/core/text/axis-gutters.ts +70 -0
  96. package/src/core/text/bar-label.test.ts +92 -0
  97. package/src/core/text/bar-label.ts +61 -0
  98. package/src/core/text/format-tick.test.ts +87 -0
  99. package/src/core/text/format-tick.ts +76 -0
  100. package/src/core/text/label-anchor.test.ts +59 -0
  101. package/src/core/text/label-anchor.ts +62 -0
  102. package/src/core/ticks/build-ticks.test.ts +38 -0
  103. package/src/core/ticks/build-ticks.ts +36 -0
  104. package/src/core/ticks/linear-ticks.test.ts +56 -0
  105. package/src/core/ticks/linear-ticks.ts +67 -0
  106. package/src/core/ticks/tick-count.test.ts +46 -0
  107. package/src/core/ticks/tick-count.ts +35 -0
  108. package/src/core/ticks/tick.types.ts +7 -0
  109. package/src/core/ticks/time-ticks.test.ts +61 -0
  110. package/src/core/ticks/time-ticks.ts +22 -0
  111. package/src/core/util/as-number.test.ts +45 -0
  112. package/src/core/util/as-number.ts +22 -0
  113. package/src/core/util/clamp.test.ts +21 -0
  114. package/src/core/util/clamp.ts +16 -0
  115. package/src/core/util/extent.test.ts +33 -0
  116. package/src/core/util/extent.ts +37 -0
  117. package/src/core/util/read-at.test.ts +14 -0
  118. package/src/core/util/read-at.ts +14 -0
  119. package/src/core/util/sided-number.test.ts +28 -0
  120. package/src/core/util/sided-number.ts +34 -0
  121. package/src/docs.test.ts +45 -0
  122. package/src/flat-worklet.test.ts +130 -0
  123. package/src/gesture/AGENTS.md +84 -0
  124. package/src/gesture/gesture-overlay.tsx +21 -0
  125. package/src/gesture/gesture.types.ts +143 -0
  126. package/src/gesture/index.ts +12 -0
  127. package/src/gesture/use-chart-scrub.ts +44 -0
  128. package/src/gesture/use-scrub-gesture.ts +162 -0
  129. package/src/index.ts +6 -0
  130. package/src/no-classname.test.ts +27 -0
  131. package/src/polar/AGENTS.md +55 -0
  132. package/src/polar/hooks/use-polar-model.ts +88 -0
  133. package/src/polar/index.ts +14 -0
  134. package/src/polar/marks/pie-inset.tsx +70 -0
  135. package/src/polar/marks/pie-label.tsx +71 -0
  136. package/src/polar/marks/pie-slice.tsx +82 -0
  137. package/src/polar/marks/pie-slices.tsx +60 -0
  138. package/src/polar/polar-chart.context.tsx +37 -0
  139. package/src/polar/polar-chart.tsx +91 -0
  140. package/src/polar/polar-chart.types.ts +82 -0
  141. package/src/purity.test.ts +75 -0
  142. package/src/skia/AGENTS.md +39 -0
  143. package/src/skia/build-path.ts +22 -0
  144. package/src/skia/font.ts +78 -0
  145. package/src/skia/index.ts +9 -0
  146. 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
+ }