@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,106 @@
1
+ import type { CurvePath } from "./path-segments";
2
+
3
+ /**
4
+ * Bisection steps.
5
+ *
6
+ * 32 resolves `t` to 2⁻³² of a segment, which on a 1000-point-wide canvas is
7
+ * about 2e-7 points of `y` — seven orders of magnitude below anything a screen
8
+ * can show. Each step is a handful of multiplies, so the whole solve is well
9
+ * under a microsecond and the extra headroom over the ~1e-6 that 24 steps give
10
+ * costs nothing worth measuring.
11
+ */
12
+ const BISECTIONS = 32;
13
+
14
+ /** Below this the segment is vertical and `x` does not select a single `y`. */
15
+ const VERTICAL_EPSILON = 1e-9;
16
+
17
+ /**
18
+ * The `y` on the drawn curve at canvas `x`, or `NaN` where the path has none.
19
+ *
20
+ * This is what makes a scrub dot glide along the line rather than hop between
21
+ * data points. It reads the same cubics the renderer drew, so the dot cannot
22
+ * drift off the stroke however the curve was interpolated.
23
+ *
24
+ * **Bisection, not the analytic cubic root.** The closed-form solve is three
25
+ * functions calling each other — `solveCubic` calling `cuberoot` — and a
26
+ * module-scope worklet that calls another module-scope worklet binds at import
27
+ * time in source order, so the UI thread gets `undefined is not a function`.
28
+ * Since `x` is monotone along every segment a chart draws, bisection converges
29
+ * provably and fits in one flat function. The exact solver lives in this
30
+ * module's test as an oracle, which is the right place for a second
31
+ * implementation.
32
+ *
33
+ * Outside the path it clamps to the nearer end. Inside a gap it returns `NaN`,
34
+ * and the caller falls back to the nearest datum.
35
+ *
36
+ * Flat worklet: calls nothing, closes over nothing but the two constants above.
37
+ *
38
+ * It is one long function for that reason and not by preference. The obvious
39
+ * refactor — a run scan, a segment search and a bisection as three helpers —
40
+ * is four module-scope functions calling each other, which binds at import
41
+ * time in source order and hands the UI thread `undefined is not a function`
42
+ * the moment a finger lands. The complexity rule is suppressed below because
43
+ * satisfying it here would break the code on a device.
44
+ */
45
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the flat-worklet rule forbids extracting helpers; see the doc comment above.
46
+ export function getYForX(path: CurvePath, x: number): number {
47
+ "worklet";
48
+ if (path.length === 0 || !Number.isFinite(x)) return Number.NaN;
49
+
50
+ const firstRun = path[0] as readonly number[];
51
+ if (x <= (firstRun[0] as number)) return firstRun[1] as number;
52
+
53
+ const lastRun = path[path.length - 1] as readonly number[];
54
+ if (x >= (lastRun[lastRun.length - 2] as number)) return lastRun[lastRun.length - 1] as number;
55
+
56
+ for (let runIndex = 0; runIndex < path.length; runIndex += 1) {
57
+ const run = path[runIndex] as readonly number[];
58
+ const count = (run.length - 2) / 6;
59
+ if (count < 1) continue;
60
+ if (x < (run[0] as number) || x > (run[run.length - 2] as number)) continue;
61
+
62
+ let lo = 0;
63
+ let hi = count - 1;
64
+ let index = -1;
65
+ while (lo <= hi) {
66
+ const mid = (lo + hi) >> 1;
67
+ const startX = run[mid * 6] as number;
68
+ const endX = run[mid * 6 + 6] as number;
69
+ if (x < startX) hi = mid - 1;
70
+ else if (x > endX) lo = mid + 1;
71
+ else {
72
+ index = mid;
73
+ break;
74
+ }
75
+ }
76
+ if (index === -1) continue;
77
+
78
+ const base = index * 6;
79
+ const p0x = run[base] as number;
80
+ const p0y = run[base + 1] as number;
81
+ const c1x = run[base + 2] as number;
82
+ const c1y = run[base + 3] as number;
83
+ const c2x = run[base + 4] as number;
84
+ const c2y = run[base + 5] as number;
85
+ const p1x = run[base + 6] as number;
86
+ const p1y = run[base + 7] as number;
87
+
88
+ if (Math.abs(p1x - p0x) < VERTICAL_EPSILON) return p1y;
89
+
90
+ let t0 = 0;
91
+ let t1 = 1;
92
+ let t = 0.5;
93
+ for (let step = 0; step < BISECTIONS; step += 1) {
94
+ t = (t0 + t1) / 2;
95
+ const mt = 1 - t;
96
+ const bx = mt * mt * mt * p0x + 3 * mt * mt * t * c1x + 3 * mt * t * t * c2x + t * t * t * p1x;
97
+ if (bx < x) t0 = t;
98
+ else t1 = t;
99
+ }
100
+
101
+ const mt = 1 - t;
102
+ return mt * mt * mt * p0y + 3 * mt * mt * t * c1y + 3 * mt * t * t * c2y + t * t * t * p1y;
103
+ }
104
+
105
+ return Number.NaN;
106
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ARC_SEGMENTS, buildSliceEdgePath, buildSlicePath } from "./build-slice-path";
3
+ import type { PieSliceData, PolarPoint } from "./polar.types";
4
+ import { polarToCartesian } from "./polar-point";
5
+
6
+ const center: PolarPoint = { x: 100, y: 100 };
7
+
8
+ function slice(sweep: number, innerRadius = 0, startAngle = 0, radius = 80): PieSliceData {
9
+ return {
10
+ index: 0,
11
+ label: "",
12
+ value: sweep,
13
+ fraction: sweep / 360,
14
+ startAngle,
15
+ endAngle: startAngle + sweep,
16
+ sweepAngle: sweep,
17
+ center,
18
+ radius,
19
+ innerRadius,
20
+ sliceIsEntireCircle: sweep === 360,
21
+ };
22
+ }
23
+
24
+ /** The command letters of a path, which is what `isInterpolatable` compares. */
25
+ function commands(path: string): string {
26
+ return (path.match(/[A-Za-z]/g) ?? []).join("");
27
+ }
28
+
29
+ /** Every number in a path, in order. */
30
+ function numbers(path: string): number[] {
31
+ return (path.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
32
+ }
33
+
34
+ /** The cubic segments of a path as `[p0, c1, c2, p1]` tuples, walking the current point. */
35
+ function cubics(path: string): PolarPoint[][] {
36
+ const found: PolarPoint[][] = [];
37
+ let current: PolarPoint = { x: 0, y: 0 };
38
+ const tokens = path.match(/[A-Za-z][^A-Za-z]*/g) ?? [];
39
+ for (const token of tokens) {
40
+ const verb = token[0];
41
+ const values = numbers(token.slice(1));
42
+ if (verb === "M" || verb === "L") {
43
+ current = { x: values[0] as number, y: values[1] as number };
44
+ } else if (verb === "C") {
45
+ const c1 = { x: values[0] as number, y: values[1] as number };
46
+ const c2 = { x: values[2] as number, y: values[3] as number };
47
+ const p1 = { x: values[4] as number, y: values[5] as number };
48
+ found.push([current, c1, c2, p1]);
49
+ current = p1;
50
+ }
51
+ }
52
+ return found;
53
+ }
54
+
55
+ /** A point on a cubic Bézier at parameter `t` — the oracle. */
56
+ function bezierAt([p0, c1, c2, p1]: PolarPoint[], t: number): PolarPoint {
57
+ const u = 1 - t;
58
+ const a = u * u * u;
59
+ const b = 3 * u * u * t;
60
+ const c = 3 * u * t * t;
61
+ const d = t * t * t;
62
+ return {
63
+ x: a * (p0 as PolarPoint).x + b * (c1 as PolarPoint).x + c * (c2 as PolarPoint).x + d * (p1 as PolarPoint).x,
64
+ y: a * (p0 as PolarPoint).y + b * (c1 as PolarPoint).y + c * (c2 as PolarPoint).y + d * (p1 as PolarPoint).y,
65
+ };
66
+ }
67
+
68
+ function distance(a: PolarPoint, b: PolarPoint): number {
69
+ return Math.hypot(a.x - b.x, a.y - b.y);
70
+ }
71
+
72
+ describe("buildSlicePath", () => {
73
+ test("emits four cubics per arc", () => {
74
+ expect(ARC_SEGMENTS).toBe(4);
75
+ });
76
+
77
+ test("always has the same eleven verbs, whatever the sweep or the hole", () => {
78
+ for (const sweep of [0, 1, 90, 180, 270, 360]) {
79
+ for (const inner of [0, 40]) {
80
+ expect(commands(buildSlicePath(slice(sweep, inner)))).toBe("MCCCCLCCCCZ");
81
+ }
82
+ }
83
+ });
84
+
85
+ test("starts at the outer start point and lines to the inner end point", () => {
86
+ const path = buildSlicePath(slice(90, 40));
87
+ const values = numbers(path);
88
+ const outerStart = polarToCartesian(center, 80, 0);
89
+ expect(values[0]).toBeCloseTo(outerStart.x, 2);
90
+ expect(values[1]).toBeCloseTo(outerStart.y, 2);
91
+
92
+ const segments = cubics(path);
93
+ const outerEnd = polarToCartesian(center, 80, 90);
94
+ expect(segments[3]?.[3]?.x).toBeCloseTo(outerEnd.x, 2);
95
+ expect(segments[3]?.[3]?.y).toBeCloseTo(outerEnd.y, 2);
96
+
97
+ const innerEnd = polarToCartesian(center, 40, 90);
98
+ expect(segments[4]?.[0]?.x).toBeCloseTo(innerEnd.x, 2);
99
+ expect(segments[4]?.[0]?.y).toBeCloseTo(innerEnd.y, 2);
100
+
101
+ const innerStart = polarToCartesian(center, 40, 0);
102
+ expect(segments[7]?.[3]?.x).toBeCloseTo(innerStart.x, 2);
103
+ expect(segments[7]?.[3]?.y).toBeCloseTo(innerStart.y, 2);
104
+ });
105
+
106
+ test("stays within 0.05% of the radius along every arc", () => {
107
+ for (const sweep of [30, 90, 180, 270, 360]) {
108
+ const segments = cubics(buildSlicePath(slice(sweep, 40, 15)));
109
+ segments.forEach((segment, index) => {
110
+ const radius = index < ARC_SEGMENTS ? 80 : 40;
111
+ for (let step = 0; step <= 10; step += 1) {
112
+ const point = bezierAt(segment, step / 10);
113
+ expect(Math.abs(distance(point, center) - radius) / radius).toBeLessThan(0.0005);
114
+ }
115
+ });
116
+ }
117
+ });
118
+
119
+ test("a full circle with a hole closes into a ring, not a disc", () => {
120
+ // Outer arc clockwise, inner arc counter-clockwise: opposite winding is
121
+ // what leaves the hole empty under nonzero winding.
122
+ const segments = cubics(buildSlicePath(slice(360, 40)));
123
+ const outerFirst = segments[0] as PolarPoint[];
124
+ const innerFirst = segments[4] as PolarPoint[];
125
+ const outerEnd = bezierAt(outerFirst, 1);
126
+ const innerEnd = bezierAt(innerFirst, 1);
127
+ // Both arcs start at 12 o'clock. Clockwise moves right; counter-clockwise moves left.
128
+ expect(outerEnd.x).toBeGreaterThan(center.x);
129
+ expect(innerEnd.x).toBeLessThan(center.x);
130
+ // And both come back to where they began.
131
+ const lastOuter = bezierAt(segments[3] as PolarPoint[], 1);
132
+ expect(lastOuter.x).toBeCloseTo(center.x, 2);
133
+ expect(lastOuter.y).toBeCloseTo(center.y - 80, 2);
134
+ });
135
+
136
+ test("a slice at radius 0 collapses to the centre and keeps its verbs", () => {
137
+ const path = buildSlicePath(slice(90, 0, 0, 0));
138
+ expect(commands(path)).toBe("MCCCCLCCCCZ");
139
+ for (const value of numbers(path)) expect(value).toBe(100);
140
+ });
141
+
142
+ test("emits no NaN for any degenerate input", () => {
143
+ for (const path of [
144
+ buildSlicePath(slice(0, 0)),
145
+ buildSlicePath(slice(0, 40)),
146
+ buildSlicePath(slice(Number.NaN, 0)),
147
+ buildSlicePath({ ...slice(90), startAngle: Number.NaN }),
148
+ ]) {
149
+ expect(path).not.toContain("NaN");
150
+ expect(commands(path)).toBe("MCCCCLCCCCZ");
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("buildSliceEdgePath", () => {
156
+ test("is a single line from the inner edge to the outer edge", () => {
157
+ const path = buildSliceEdgePath(slice(90, 40), "start");
158
+ expect(commands(path)).toBe("ML");
159
+ const values = numbers(path);
160
+ expect(values[0]).toBeCloseTo(100, 2);
161
+ expect(values[1]).toBeCloseTo(60, 2);
162
+ expect(values[2]).toBeCloseTo(100, 2);
163
+ expect(values[3]).toBeCloseTo(20, 2);
164
+ });
165
+
166
+ test("the end edge sits at the end angle", () => {
167
+ const values = numbers(buildSliceEdgePath(slice(90, 0), "end"));
168
+ expect(values[0]).toBeCloseTo(100, 2);
169
+ expect(values[1]).toBeCloseTo(100, 2);
170
+ expect(values[2]).toBeCloseTo(180, 2);
171
+ expect(values[3]).toBeCloseTo(100, 2);
172
+ });
173
+ });
@@ -0,0 +1,98 @@
1
+ import type { PieSliceData, PolarPoint } from "./polar.types";
2
+ import { polarToCartesian } from "./polar-point";
3
+
4
+ /** Cubics per arc. Four keeps a quarter turn each, where the error is 0.03%. */
5
+ export const ARC_SEGMENTS = 4;
6
+
7
+ /**
8
+ * A slice as an SVG path: outer arc, a line inward, inner arc, close.
9
+ *
10
+ * The verb sequence is **always** `M C C C C L C C C C Z` — eleven verbs,
11
+ * whatever the sweep and whether there is a hole. A zero sweep emits four
12
+ * cubics with every control point on one ray; a pie with no hole emits an
13
+ * inner arc of radius zero sitting on the centre. That constancy is what lets
14
+ * Skia interpolate one slice into another when the data changes, and it is
15
+ * why a slice never uses an `A` arc verb: an arc's parameters are not
16
+ * pointwise-interpolatable and Skia would snap instead of morphing.
17
+ *
18
+ * The inner arc runs **counter-clockwise**. Under the nonzero winding rule the
19
+ * opposite direction is what leaves the hole of a 360° donut empty — with the
20
+ * arcs wound the same way, a single-slice donut fills into a disc.
21
+ */
22
+ export function buildSlicePath(slice: PieSliceData): string {
23
+ const { center } = slice;
24
+ const radius = finite(slice.radius);
25
+ const inner = finite(slice.innerRadius);
26
+ const start = finite(slice.startAngle);
27
+ const sweep = finite(slice.sweepAngle);
28
+ const end = start + sweep;
29
+
30
+ const outerStart = polarToCartesian(center, radius, start);
31
+ const innerEnd = polarToCartesian(center, inner, end);
32
+
33
+ return [
34
+ `M${point(outerStart)}`,
35
+ ...arcCubics(center, radius, start, sweep),
36
+ `L${point(innerEnd)}`,
37
+ ...arcCubics(center, inner, end, -sweep),
38
+ "Z",
39
+ ].join("");
40
+ }
41
+
42
+ /**
43
+ * One edge of a slice, from the inner radius to the outer, as `M L`.
44
+ *
45
+ * What a hairline between slices is drawn with: stroking the slice itself
46
+ * would also outline the arcs, and a stroked arc under a fill of the same
47
+ * colour reads as a slice that is slightly too big.
48
+ */
49
+ export function buildSliceEdgePath(slice: PieSliceData, edge: "start" | "end"): string {
50
+ const angle = finite(edge === "start" ? slice.startAngle : slice.endAngle);
51
+ const from = polarToCartesian(slice.center, finite(slice.innerRadius), angle);
52
+ const to = polarToCartesian(slice.center, finite(slice.radius), angle);
53
+ return `M${point(from)}L${point(to)}`;
54
+ }
55
+
56
+ /**
57
+ * An arc of `sweep` degrees from `start`, as `ARC_SEGMENTS` cubic Béziers.
58
+ *
59
+ * Each cubic covers an equal share of the sweep; its control points sit on the
60
+ * tangents at either end, `4/3 · tan(θ/4)` of the radius along them, which is
61
+ * the standard circular approximation. A negative sweep walks the other way
62
+ * round, and the same formula holds because `tan` is odd.
63
+ */
64
+ function arcCubics(center: PolarPoint, radius: number, start: number, sweep: number): string[] {
65
+ const segments: string[] = [];
66
+ const step = sweep / ARC_SEGMENTS;
67
+ const theta = (step * Math.PI) / 180;
68
+ const handle = (4 / 3) * Math.tan(theta / 4) * radius;
69
+
70
+ for (let index = 0; index < ARC_SEGMENTS; index += 1) {
71
+ const from = start + step * index;
72
+ const to = from + step;
73
+ const fromRadians = (from * Math.PI) / 180;
74
+ const toRadians = (to * Math.PI) / 180;
75
+ const p0 = polarToCartesian(center, radius, from);
76
+ const p1 = polarToCartesian(center, radius, to);
77
+ // The tangent at angle a, clockwise from 12 o'clock, is (cos a, sin a).
78
+ const c1 = { x: p0.x + handle * Math.cos(fromRadians), y: p0.y + handle * Math.sin(fromRadians) };
79
+ const c2 = { x: p1.x - handle * Math.cos(toRadians), y: p1.y - handle * Math.sin(toRadians) };
80
+ segments.push(`C${point(c1)} ${point(c2)} ${point(p1)}`);
81
+ }
82
+
83
+ return segments;
84
+ }
85
+
86
+ function point({ x, y }: PolarPoint): string {
87
+ return `${round(x)},${round(y)}`;
88
+ }
89
+
90
+ /** Three decimals — sub-pixel, and short enough that a path string stays small. */
91
+ function round(value: number): number {
92
+ const rounded = Math.round(value * 1000) / 1000;
93
+ return rounded === 0 ? 0 : rounded;
94
+ }
95
+
96
+ function finite(value: number): number {
97
+ return Number.isFinite(value) ? value : 0;
98
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sliceLabelPosition } from "./label-position";
3
+ import type { PieSliceData } from "./polar.types";
4
+
5
+ const slice: PieSliceData = {
6
+ index: 0,
7
+ label: "a",
8
+ value: 1,
9
+ fraction: 0.25,
10
+ startAngle: 0,
11
+ endAngle: 90,
12
+ sweepAngle: 90,
13
+ center: { x: 100, y: 100 },
14
+ radius: 80,
15
+ innerRadius: 40,
16
+ sliceIsEntireCircle: false,
17
+ };
18
+
19
+ describe("sliceLabelPosition", () => {
20
+ test("sits on the slice's bisector, halfway across the annulus by default", () => {
21
+ const point = sliceLabelPosition(slice);
22
+ // 45°, at radius 60.
23
+ expect(point.x).toBeCloseTo(100 + 60 * Math.SQRT1_2);
24
+ expect(point.y).toBeCloseTo(100 - 60 * Math.SQRT1_2);
25
+ });
26
+
27
+ test("0 is the inner edge and 1 the outer", () => {
28
+ expect(Math.hypot(sliceLabelPosition(slice, 0).x - 100, sliceLabelPosition(slice, 0).y - 100)).toBeCloseTo(40);
29
+ expect(Math.hypot(sliceLabelPosition(slice, 1).x - 100, sliceLabelPosition(slice, 1).y - 100)).toBeCloseTo(80);
30
+ });
31
+
32
+ test("more than 1 sits outside the circle", () => {
33
+ const point = sliceLabelPosition(slice, 1.25);
34
+ expect(Math.hypot(point.x - 100, point.y - 100)).toBeCloseTo(90);
35
+ });
36
+
37
+ test("a pie with no hole measures from the centre", () => {
38
+ const point = sliceLabelPosition({ ...slice, innerRadius: 0 }, 0.5);
39
+ expect(Math.hypot(point.x - 100, point.y - 100)).toBeCloseTo(40);
40
+ });
41
+ });
@@ -0,0 +1,18 @@
1
+ import type { PieSliceData, PolarPoint } from "./polar.types";
2
+ import { polarToCartesian } from "./polar-point";
3
+
4
+ /**
5
+ * Where a slice's label sits: on the bisector, `radiusOffset` of the way
6
+ * across the annulus.
7
+ *
8
+ * The offset is a fraction of the **annulus** — `0` is the inner edge, `1` the
9
+ * outer, more than `1` outside the circle — rather than of the radius. A donut
10
+ * with a 60% hole and a label at half the radius would put the label in the
11
+ * hole; measuring from the inner edge keeps the same offset meaningful for a
12
+ * pie and a donut alike.
13
+ */
14
+ export function sliceLabelPosition(slice: PieSliceData, radiusOffset = 0.5): PolarPoint {
15
+ const midAngle = slice.startAngle + slice.sweepAngle / 2;
16
+ const radius = slice.innerRadius + (slice.radius - slice.innerRadius) * radiusOffset;
17
+ return polarToCartesian(slice.center, radius, midAngle);
18
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { collectPieInput } from "./pie-input";
3
+
4
+ describe("collectPieInput", () => {
5
+ const rows = [
6
+ { name: "chrome", share: 64 },
7
+ { name: "safari", share: "19" },
8
+ { name: "other", share: null },
9
+ ];
10
+
11
+ test("reads values through asNumber, so a numeric string counts", () => {
12
+ expect(collectPieInput(rows, "share").values).toEqual([64, 19, Number.NaN]);
13
+ });
14
+
15
+ test("labels come from the label key as strings", () => {
16
+ expect(collectPieInput(rows, "share", "name").labels).toEqual(["chrome", "safari", "other"]);
17
+ });
18
+
19
+ test("labels fall back to the row's index when no key is given", () => {
20
+ expect(collectPieInput(rows, "share").labels).toEqual(["0", "1", "2"]);
21
+ });
22
+
23
+ test("a missing label reads as an empty string, not the word undefined", () => {
24
+ expect(collectPieInput([{ share: 1 }], "share", "name").labels).toEqual([""]);
25
+ });
26
+
27
+ test("keeps one entry per row, so indices line up with the data", () => {
28
+ const input = collectPieInput(rows, "share", "name");
29
+ expect(input.values.length).toBe(rows.length);
30
+ expect(input.labels.length).toBe(rows.length);
31
+ });
32
+
33
+ test("an empty table gives empty arrays", () => {
34
+ expect(collectPieInput([], "share")).toEqual({ values: [], labels: [] });
35
+ });
36
+ });
@@ -0,0 +1,32 @@
1
+ import type { ChartRow } from "../chart.types";
2
+ import { asNumber } from "../util/as-number";
3
+
4
+ export type PieInput = {
5
+ /** One per row, `NaN` where the field could not be read. */
6
+ readonly values: readonly number[];
7
+ /** One per row, the index when there is no label field. */
8
+ readonly labels: readonly string[];
9
+ };
10
+
11
+ /**
12
+ * The value and label of every row, by key.
13
+ *
14
+ * Nothing is dropped here. A row that cannot be read stays in place as `NaN`
15
+ * so that indices still line up with the caller's data — `resolveSlices` turns
16
+ * it into a zero-sweep slice, and a tap readout reporting index 3 means the
17
+ * fourth row and not the fourth *readable* row.
18
+ */
19
+ export function collectPieInput(rows: readonly ChartRow[], valueKey: string, labelKey?: string): PieInput {
20
+ const values: number[] = [];
21
+ const labels: string[] = [];
22
+ rows.forEach((row, index) => {
23
+ values.push(asNumber(row[valueKey]));
24
+ if (labelKey === undefined) {
25
+ labels.push(String(index));
26
+ return;
27
+ }
28
+ const label = row[labelKey];
29
+ labels.push(label === null || label === undefined ? "" : String(label));
30
+ });
31
+ return { values, labels };
32
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeDegrees, polarToCartesian } from "./polar-point";
3
+
4
+ const center = { x: 100, y: 100 };
5
+
6
+ describe("polarToCartesian", () => {
7
+ test("0° is straight up", () => {
8
+ const point = polarToCartesian(center, 50, 0);
9
+ expect(point.x).toBeCloseTo(100);
10
+ expect(point.y).toBeCloseTo(50);
11
+ });
12
+
13
+ test("90° is to the right, so angles run clockwise", () => {
14
+ const point = polarToCartesian(center, 50, 90);
15
+ expect(point.x).toBeCloseTo(150);
16
+ expect(point.y).toBeCloseTo(100);
17
+ });
18
+
19
+ test("180° is straight down and 270° is to the left", () => {
20
+ expect(polarToCartesian(center, 50, 180).y).toBeCloseTo(150);
21
+ expect(polarToCartesian(center, 50, 270).x).toBeCloseTo(50);
22
+ });
23
+
24
+ test("a full turn lands where it started", () => {
25
+ const start = polarToCartesian(center, 50, 30);
26
+ const end = polarToCartesian(center, 50, 390);
27
+ expect(end.x).toBeCloseTo(start.x);
28
+ expect(end.y).toBeCloseTo(start.y);
29
+ });
30
+
31
+ test("radius zero is the centre at any angle", () => {
32
+ expect(polarToCartesian(center, 0, 123)).toEqual(center);
33
+ });
34
+ });
35
+
36
+ describe("normalizeDegrees", () => {
37
+ test("keeps a value already in range", () => {
38
+ expect(normalizeDegrees(45)).toBe(45);
39
+ });
40
+
41
+ test("wraps past a full turn, in both directions", () => {
42
+ expect(normalizeDegrees(370)).toBe(10);
43
+ expect(normalizeDegrees(-10)).toBe(350);
44
+ expect(normalizeDegrees(720)).toBe(0);
45
+ });
46
+
47
+ test("never returns 360", () => {
48
+ expect(normalizeDegrees(360)).toBe(0);
49
+ });
50
+
51
+ test("turns a non-finite angle into 0 rather than NaN", () => {
52
+ expect(normalizeDegrees(Number.NaN)).toBe(0);
53
+ expect(normalizeDegrees(Number.POSITIVE_INFINITY)).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,29 @@
1
+ import type { PolarPoint } from "./polar.types";
2
+
3
+ /**
4
+ * A canvas point at `radius` from `center`, `degrees` clockwise from 12 o'clock.
5
+ *
6
+ * The trigonometric convention has 0° at 3 o'clock and runs counter-clockwise;
7
+ * a pie chart's has 0° at the top and runs clockwise. The swap is done here,
8
+ * once, so every angle elsewhere in the package can be stated the way a chart
9
+ * reads and no mark has to remember which axis is `sin`.
10
+ */
11
+ export function polarToCartesian(center: PolarPoint, radius: number, degrees: number): PolarPoint {
12
+ const radians = (degrees * Math.PI) / 180;
13
+ return {
14
+ x: center.x + radius * Math.sin(radians),
15
+ y: center.y - radius * Math.cos(radians),
16
+ };
17
+ }
18
+
19
+ /**
20
+ * `degrees` folded into `[0, 360)`.
21
+ *
22
+ * A non-finite angle becomes `0` rather than `NaN` — a `NaN` here would put a
23
+ * slice nowhere and the whole chart would go blank with nothing logged.
24
+ */
25
+ export function normalizeDegrees(degrees: number): number {
26
+ if (!Number.isFinite(degrees)) return 0;
27
+ const folded = ((degrees % 360) + 360) % 360;
28
+ return folded === 360 ? 0 : folded;
29
+ }
@@ -0,0 +1,47 @@
1
+ /** A point on the canvas, in points. */
2
+ export type PolarPoint = {
3
+ readonly x: number;
4
+ readonly y: number;
5
+ };
6
+
7
+ /**
8
+ * The hole in a donut — points, or a percentage of the outer radius.
9
+ *
10
+ * A percentage is the form a themed wrapper wants, because it reads the same
11
+ * at every chart size; points are what a caller who has measured wants.
12
+ */
13
+ export type InnerRadius = number | `${number}%`;
14
+
15
+ /** Where the circle sits on the canvas, and how big it is. */
16
+ export type PolarLayout = {
17
+ readonly center: PolarPoint;
18
+ readonly radius: number;
19
+ readonly innerRadius: number;
20
+ };
21
+
22
+ /**
23
+ * One slice, resolved: its share of the circle and where it is drawn.
24
+ *
25
+ * Angles are **degrees, 0° at 12 o'clock, clockwise** — the convention every
26
+ * pie chart library on every platform shares, and the one a designer's
27
+ * "start the first slice at the top" maps to without arithmetic. The layout
28
+ * is carried on every slice so a mark can be handed one slice and draw it
29
+ * with nothing else in scope.
30
+ */
31
+ export type PieSliceData = {
32
+ /** Position in the data, kept even for a slice with no sweep. */
33
+ readonly index: number;
34
+ readonly label: string;
35
+ /** The datum's value; `0` for anything that could not be read. */
36
+ readonly value: number;
37
+ /** Share of the total, in `[0, 1]`. `0` when the total is zero. */
38
+ readonly fraction: number;
39
+ readonly startAngle: number;
40
+ readonly endAngle: number;
41
+ readonly sweepAngle: number;
42
+ readonly center: PolarPoint;
43
+ readonly radius: number;
44
+ readonly innerRadius: number;
45
+ /** The slice is the whole 360°, which makes its two edges coincide. */
46
+ readonly sliceIsEntireCircle: boolean;
47
+ };