@delacour/react-native-charts 0.1.0-alpha.20260925053522

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +98 -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,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BarRect } from "../shape/build-bars";
3
+ import { barLabelAnchor } from "./bar-label";
4
+
5
+ const metrics = { ascent: 9, descent: 3 };
6
+ const positive: BarRect = { left: 40, top: 30, right: 60, bottom: 100, negative: false, index: 0 };
7
+ const negative: BarRect = { left: 40, top: 100, right: 60, bottom: 130, negative: true, index: 0 };
8
+
9
+ describe("barLabelAnchor", () => {
10
+ test("top centres the label above a positive bar, gap included", () => {
11
+ const anchor = barLabelAnchor({ rect: positive, position: "top", width: 20, metrics, gap: 4 });
12
+ expect(anchor.x).toBe(40);
13
+ // The glyph box's bottom edge sits 4 above the bar's top.
14
+ expect(anchor.y + metrics.descent).toBe(26);
15
+ });
16
+
17
+ test("top flips below the value end of a negative bar", () => {
18
+ // The value end is the canvas bottom; a label above the rect would sit
19
+ // on the baseline, in the middle of the chart.
20
+ const anchor = barLabelAnchor({ rect: negative, position: "top", width: 20, metrics, gap: 4 });
21
+ expect(anchor.y - metrics.ascent).toBe(134);
22
+ });
23
+
24
+ test("bottom sits against the base end", () => {
25
+ expect(barLabelAnchor({ rect: positive, position: "bottom", width: 20, metrics, gap: 4 }).y - metrics.ascent).toBe(
26
+ 104
27
+ );
28
+ expect(barLabelAnchor({ rect: negative, position: "bottom", width: 20, metrics, gap: 4 }).y + metrics.descent).toBe(
29
+ 96
30
+ );
31
+ });
32
+
33
+ test("left and right sit beside the bar, centred on its height", () => {
34
+ const left = barLabelAnchor({ rect: positive, position: "left", width: 20, metrics, gap: 4 });
35
+ expect(left.x + 20).toBe(36);
36
+ const right = barLabelAnchor({ rect: positive, position: "right", width: 20, metrics, gap: 4 });
37
+ expect(right.x).toBe(64);
38
+ const centre = (left.y - metrics.ascent + left.y + metrics.descent) / 2;
39
+ expect(centre).toBe(65);
40
+ });
41
+
42
+ test("defaults the gap to four", () => {
43
+ expect(barLabelAnchor({ rect: positive, position: "top", width: 0, metrics }).y + metrics.descent).toBe(26);
44
+ });
45
+
46
+ describe("horizontal", () => {
47
+ const right: BarRect = { left: 20, top: 40, right: 80, bottom: 60, negative: false, index: 0 };
48
+ const left: BarRect = { left: 20, top: 40, right: 80, bottom: 60, negative: true, index: 0 };
49
+
50
+ test("top sits beyond the value end: right of a positive bar, left of a negative one", () => {
51
+ const a = barLabelAnchor({ rect: right, position: "top", width: 20, metrics, gap: 4, orientation: "horizontal" });
52
+ expect(a.x).toBe(84);
53
+ const b = barLabelAnchor({ rect: left, position: "top", width: 20, metrics, gap: 4, orientation: "horizontal" });
54
+ expect(b.x + 20).toBe(16);
55
+ // Both centred on the bar's height.
56
+ expect((a.y - metrics.ascent + a.y + metrics.descent) / 2).toBe(50);
57
+ });
58
+
59
+ test("bottom sits against the base end", () => {
60
+ const a = barLabelAnchor({
61
+ rect: right,
62
+ position: "bottom",
63
+ width: 20,
64
+ metrics,
65
+ gap: 4,
66
+ orientation: "horizontal",
67
+ });
68
+ expect(a.x + 20).toBe(16);
69
+ const b = barLabelAnchor({
70
+ rect: left,
71
+ position: "bottom",
72
+ width: 20,
73
+ metrics,
74
+ gap: 4,
75
+ orientation: "horizontal",
76
+ });
77
+ expect(b.x).toBe(84);
78
+ });
79
+
80
+ test("left and right stay canvas sides", () => {
81
+ const a = barLabelAnchor({
82
+ rect: right,
83
+ position: "right",
84
+ width: 20,
85
+ metrics,
86
+ gap: 4,
87
+ orientation: "horizontal",
88
+ });
89
+ expect(a.x).toBe(84);
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,61 @@
1
+ import type { ChartOrientation } from "../chart.types";
2
+ import type { BarRect } from "../shape/build-bars";
3
+ import { anchorX, anchorY, type LabelMetrics } from "./label-anchor";
4
+
5
+ /**
6
+ * Where a bar's label sits, relative to the bar.
7
+ *
8
+ * `top` and `bottom` are the value end and the base end, so a negative bar
9
+ * flips them: its `top` label hangs below the bar, where its value is read.
10
+ * `left` and `right` are canvas sides and do not flip.
11
+ */
12
+ export type BarLabelPosition = "top" | "bottom" | "left" | "right";
13
+
14
+ export type BarLabelAnchorOptions = {
15
+ readonly rect: BarRect;
16
+ readonly position: BarLabelPosition;
17
+ /** The label's measured advance width. */
18
+ readonly width: number;
19
+ readonly metrics: LabelMetrics;
20
+ /** Space between the bar's edge and the glyph box. Defaults to 4. */
21
+ readonly gap?: number;
22
+ /** On a horizontal bar the value end is a side, so `top` and `bottom` follow it there. */
23
+ readonly orientation?: ChartOrientation;
24
+ };
25
+
26
+ /** Skia's text origin — left end of the baseline — for a label on a bar. */
27
+ export function barLabelAnchor(options: BarLabelAnchorOptions): { readonly x: number; readonly y: number } {
28
+ const { rect, position, width, metrics, gap = 4, orientation = "vertical" } = options;
29
+ const centreX = (rect.left + rect.right) / 2;
30
+ const centreY = (rect.top + rect.bottom) / 2;
31
+ const beside = (side: "left" | "right"): { readonly x: number; readonly y: number } =>
32
+ side === "left"
33
+ ? { x: anchorX(rect.left, width, "end") - gap, y: anchorY(centreY, metrics, "middle") }
34
+ : { x: rect.right + gap, y: anchorY(centreY, metrics, "middle") };
35
+
36
+ if (orientation === "horizontal") {
37
+ switch (position) {
38
+ case "top":
39
+ return beside(rect.negative ? "left" : "right");
40
+ case "bottom":
41
+ return beside(rect.negative ? "right" : "left");
42
+ case "left":
43
+ case "right":
44
+ return beside(position);
45
+ }
46
+ }
47
+
48
+ switch (position) {
49
+ case "top":
50
+ return rect.negative
51
+ ? { x: anchorX(centreX, width, "middle"), y: anchorY(rect.bottom, metrics, "below", gap) }
52
+ : { x: anchorX(centreX, width, "middle"), y: anchorY(rect.top, metrics, "above", gap) };
53
+ case "bottom":
54
+ return rect.negative
55
+ ? { x: anchorX(centreX, width, "middle"), y: anchorY(rect.top, metrics, "above", gap) }
56
+ : { x: anchorX(centreX, width, "middle"), y: anchorY(rect.bottom, metrics, "below", gap) };
57
+ case "left":
58
+ case "right":
59
+ return beside(position);
60
+ }
61
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { categoricalTickFormat, defaultTickFormat, formatDateTick, formatNumberTick } from "./format-tick";
3
+
4
+ const DAY = 86_400_000;
5
+ const YEAR = 365 * DAY;
6
+
7
+ describe("formatNumberTick", () => {
8
+ test("prints an integer plainly", () => {
9
+ expect(formatNumberTick(0)).toBe("0");
10
+ expect(formatNumberTick(-1200)).toBe("-1200");
11
+ });
12
+
13
+ test("strips floating-point noise", () => {
14
+ // What a tick generator dividing a domain actually produces.
15
+ expect(formatNumberTick(0.1 + 0.2)).toBe("0.3");
16
+ expect(formatNumberTick(1 / 3)).toBe("0.333333333333");
17
+ });
18
+
19
+ test("returns an empty label rather than the word NaN", () => {
20
+ expect(formatNumberTick(Number.NaN)).toBe("");
21
+ expect(formatNumberTick(Number.POSITIVE_INFINITY)).toBe("");
22
+ });
23
+ });
24
+
25
+ describe("formatDateTick", () => {
26
+ const noon = new Date(2026, 5, 17, 14, 5).getTime();
27
+
28
+ test("uses clock time inside a day", () => {
29
+ expect(formatDateTick(noon, 6 * 3_600_000)).toBe("14:05");
30
+ });
31
+
32
+ test("uses day and month inside a year", () => {
33
+ expect(formatDateTick(noon, 30 * DAY)).toBe("17 Jun");
34
+ });
35
+
36
+ test("uses month and two-digit year over a few years", () => {
37
+ expect(formatDateTick(noon, 2 * YEAR)).toBe("Jun 26");
38
+ });
39
+
40
+ test("uses the year alone over a long span", () => {
41
+ expect(formatDateTick(noon, 20 * YEAR)).toBe("2026");
42
+ });
43
+
44
+ test("pads the clock so labels stay the same width", () => {
45
+ // Ragged widths make an axis look broken and shift the measured gutter.
46
+ expect(formatDateTick(new Date(2026, 0, 1, 9, 5).getTime(), 3_600_000)).toBe("09:05");
47
+ });
48
+
49
+ test("returns an empty label for a non-finite value", () => {
50
+ expect(formatDateTick(Number.NaN, DAY)).toBe("");
51
+ });
52
+ });
53
+
54
+ describe("defaultTickFormat", () => {
55
+ test("gives a time scale the date formatter and everything else the number one", () => {
56
+ expect(defaultTickFormat("time", 30 * DAY)(new Date(2026, 5, 17).getTime())).toBe("17 Jun");
57
+ expect(defaultTickFormat("linear", 0)(42)).toBe("42");
58
+ expect(defaultTickFormat("log", 0)(1000)).toBe("1000");
59
+ });
60
+ });
61
+
62
+ describe("categoricalTickFormat", () => {
63
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
64
+
65
+ test("reads the label at the datum index the tick names", () => {
66
+ // Six months downsampled to four ticks leaves values [0, 2, 3, 5].
67
+ // Formatting by the tick's own ordinal would print Jan Feb Mar Apr
68
+ // against bars standing at Jan, Mar, Apr and Jun.
69
+ const format = categoricalTickFormat(months);
70
+ expect([0, 2, 3, 5].map(format)).toEqual(["Jan", "Mar", "Apr", "Jun"]);
71
+ });
72
+
73
+ test("rounds a fractional value onto a datum", () => {
74
+ expect(categoricalTickFormat(months)(2.4)).toBe("Mar");
75
+ });
76
+
77
+ test("returns an empty label outside the data rather than undefined", () => {
78
+ const format = categoricalTickFormat(months);
79
+ expect(format(-1)).toBe("");
80
+ expect(format(99)).toBe("");
81
+ });
82
+
83
+ test("stringifies a non-string label", () => {
84
+ expect(categoricalTickFormat([1, null])(0)).toBe("1");
85
+ expect(categoricalTickFormat([1, null])(1)).toBe("");
86
+ });
87
+ });
@@ -0,0 +1,76 @@
1
+ import type { ScaleType } from "../scale/scale.types";
2
+
3
+ const SECOND = 1000;
4
+ const MINUTE = 60 * SECOND;
5
+ const HOUR = 60 * MINUTE;
6
+ const DAY = 24 * HOUR;
7
+ const YEAR = 365 * DAY;
8
+
9
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
10
+
11
+ /**
12
+ * A number as a tick label.
13
+ *
14
+ * Rounded to twelve significant figures before printing, which is what turns
15
+ * `0.30000000000000004` back into `0.3`. Floating-point noise is not
16
+ * hypothetical on an axis: it is what `0.1 + 0.2` looks like, and a tick
17
+ * generator that divides a domain produces it routinely.
18
+ */
19
+ export function formatNumberTick(value: number): string {
20
+ if (!Number.isFinite(value)) return "";
21
+ if (Number.isInteger(value)) return String(value);
22
+ return String(Number(value.toPrecision(12)));
23
+ }
24
+
25
+ /**
26
+ * An epoch millisecond as a tick label, at a granularity the span deserves.
27
+ *
28
+ * Deliberately not `Intl`. It is unavailable inside a worklet, its output
29
+ * varies with the device locale in ways a screenshot test cannot pin, and a
30
+ * chart axis wants the shortest unambiguous label rather than a correct
31
+ * regional one. A caller who needs real localisation passes their own
32
+ * formatter — that is what the prop is for.
33
+ */
34
+ export function formatDateTick(value: number, spanMs: number): string {
35
+ if (!Number.isFinite(value)) return "";
36
+ const date = new Date(value);
37
+
38
+ if (spanMs < DAY) {
39
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
40
+ }
41
+ if (spanMs < YEAR) {
42
+ return `${date.getDate()} ${MONTHS[date.getMonth()] ?? ""}`;
43
+ }
44
+ if (spanMs < 4 * YEAR) {
45
+ return `${MONTHS[date.getMonth()] ?? ""} ${String(date.getFullYear()).slice(2)}`;
46
+ }
47
+ return String(date.getFullYear());
48
+ }
49
+
50
+ /** The formatter a scale kind gets when the caller supplies none. */
51
+ export function defaultTickFormat(kind: ScaleType, spanMs: number): (value: number) => string {
52
+ if (kind !== "time") return formatNumberTick;
53
+ return (value) => formatDateTick(value, spanMs);
54
+ }
55
+
56
+ function pad(value: number): string {
57
+ return value < 10 ? `0${value}` : String(value);
58
+ }
59
+
60
+ /**
61
+ * A categorical axis' labels, read at the datum index each tick names.
62
+ *
63
+ * The subtlety that makes this a function rather than an inline closure: a
64
+ * categorical tick's **value** is the datum's index, and that is not the same
65
+ * as the tick's own position in the tick list. Six months downsampled to four
66
+ * ticks leaves values `[0, 2, 3, 5]`; formatting by the tick's ordinal would
67
+ * print `Jan Feb Mar Apr` against bars standing at Jan, Mar, Apr and Jun — an
68
+ * axis that is wrong in a way nothing about it looks wrong.
69
+ */
70
+ export function categoricalTickFormat(raw: readonly unknown[]): (value: number) => string {
71
+ return (value) => {
72
+ const index = Math.round(value);
73
+ if (index < 0 || index >= raw.length) return "";
74
+ return String(raw[index] ?? "");
75
+ };
76
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { anchorX, anchorY, labelHeight } from "./label-anchor";
3
+
4
+ describe("anchorX", () => {
5
+ test("centres a label on the point it labels", () => {
6
+ expect(anchorX(100, 40, "middle")).toBe(80);
7
+ });
8
+
9
+ test("starts and ends against the point", () => {
10
+ expect(anchorX(100, 40, "start")).toBe(100);
11
+ expect(anchorX(100, 40, "end")).toBe(60);
12
+ });
13
+ });
14
+
15
+ describe("anchorY", () => {
16
+ // A 12pt line: 9 above the baseline, 3 below. The asymmetry is the point.
17
+ const metrics = { ascent: 9, descent: 3 };
18
+
19
+ test("puts the glyph box's top edge below the anchor, gap included", () => {
20
+ // The bug this replaced: a baseline a whole line-height down put the box's
21
+ // BOTTOM where its top belonged, and every descender — the tail of a `Q`,
22
+ // the hook of a `y` — hung past the reserved gutter and was sliced off by
23
+ // the canvas. `Jan` looked perfect; `Q1` was visibly cut.
24
+ const baseline = anchorY(100, metrics, "below", 6);
25
+ expect(baseline - metrics.ascent).toBe(106);
26
+ expect(baseline + metrics.descent).toBe(118);
27
+ });
28
+
29
+ test("keeps a below label inside the gutter reserved for it", () => {
30
+ const gap = 6;
31
+ const gutter = gap + labelHeight(metrics);
32
+ const bottom = anchorY(100, metrics, "below", gap) + metrics.descent;
33
+ expect(bottom).toBeLessThanOrEqual(100 + gutter);
34
+ });
35
+
36
+ test("puts the glyph box's bottom edge above the anchor", () => {
37
+ const baseline = anchorY(100, metrics, "above", 6);
38
+ expect(baseline + metrics.descent).toBe(94);
39
+ });
40
+
41
+ test("centres the glyph box on the anchor, not the baseline", () => {
42
+ // Anchoring the baseline puts every y-axis label below its own gridline.
43
+ const baseline = anchorY(100, metrics, "middle");
44
+ const top = baseline - metrics.ascent;
45
+ const bottom = baseline + metrics.descent;
46
+ expect((top + bottom) / 2).toBe(100);
47
+ });
48
+
49
+ test("a symmetric font still centres", () => {
50
+ const even = { ascent: 6, descent: 6 };
51
+ expect(anchorY(100, even, "middle")).toBe(100);
52
+ });
53
+ });
54
+
55
+ describe("labelHeight", () => {
56
+ test("is the whole glyph box, which is what a gutter reserves", () => {
57
+ expect(labelHeight({ ascent: 9, descent: 3 })).toBe(12);
58
+ });
59
+ });
@@ -0,0 +1,62 @@
1
+ /** Where a label sits relative to the point it labels, horizontally. */
2
+ export type LabelAlignment = "start" | "middle" | "end";
3
+
4
+ /** Where a label sits relative to the point it labels, vertically. */
5
+ export type LabelPosition = "above" | "below" | "middle";
6
+
7
+ /**
8
+ * Skia draws text from the left edge of the baseline, so a label centred under
9
+ * a tick needs the left edge moved half its own width. Every label placement in
10
+ * the package goes through here rather than each axis re-deriving it and one of
11
+ * them getting the sign wrong.
12
+ */
13
+ export function anchorX(x: number, width: number, alignment: LabelAlignment): number {
14
+ switch (alignment) {
15
+ case "start":
16
+ return x;
17
+ case "middle":
18
+ return x - width / 2;
19
+ case "end":
20
+ return x - width;
21
+ }
22
+ }
23
+
24
+ /** A font's vertical extents, both positive, measured from the baseline. */
25
+ export type LabelMetrics = {
26
+ /** How far the tallest glyph rises above the baseline. */
27
+ readonly ascent: number;
28
+ /** How far the deepest descender falls below it. */
29
+ readonly descent: number;
30
+ };
31
+
32
+ /** The full glyph box a label occupies — what a gutter has to reserve. */
33
+ export function labelHeight(metrics: LabelMetrics): number {
34
+ return metrics.ascent + metrics.descent;
35
+ }
36
+
37
+ /**
38
+ * The baseline y for a label placed against `y`.
39
+ *
40
+ * Skia positions text by its **baseline**, and a glyph box straddles that
41
+ * line: ascent above it, descent below. So every case here is stated in terms
42
+ * of where the box's edge should land, and the baseline falls out.
43
+ *
44
+ * That distinction is not pedantry. Placing a `below` label a whole line-height
45
+ * under the plot puts its baseline where the box's *bottom* should be, and the
46
+ * descenders — the tail of a `Q`, the hook of a `y` — hang past the gutter that
47
+ * was reserved for them and are sliced off by the edge of the canvas. A label
48
+ * of `Jan` looks perfect and a label of `Q1` is visibly cut.
49
+ */
50
+ export function anchorY(y: number, metrics: LabelMetrics, position: LabelPosition, gap = 0): number {
51
+ switch (position) {
52
+ // The box's bottom edge sits `gap` above `y`.
53
+ case "above":
54
+ return y - gap - metrics.descent;
55
+ // The box's top edge sits `gap` below `y`.
56
+ case "below":
57
+ return y + gap + metrics.ascent;
58
+ // The box is centred on `y`, which is what a label beside a gridline needs.
59
+ case "middle":
60
+ return y + (metrics.ascent - metrics.descent) / 2;
61
+ }
62
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { scaleValue } from "../scale/scale";
3
+ import type { ScaleDescriptor } from "../scale/scale.types";
4
+ import { buildTicks } from "./build-ticks";
5
+
6
+ const linear: ScaleDescriptor = { kind: "linear", domain: [0, 100], range: [0, 300] };
7
+
8
+ describe("buildTicks", () => {
9
+ test("positions every tick through the same scale the marks use", () => {
10
+ for (const tick of buildTicks(linear, { count: 5 })) {
11
+ expect(tick.position).toBe(scaleValue(linear, tick.value));
12
+ }
13
+ });
14
+
15
+ test("defaults to five-ish ticks", () => {
16
+ expect(buildTicks(linear).length).toBeGreaterThan(0);
17
+ });
18
+
19
+ test("uses explicit values instead of generating any, downsampled to count", () => {
20
+ const ticks = buildTicks(linear, { values: [0, 10, 20, 30, 40, 50], count: 3 });
21
+ expect(ticks).toHaveLength(3);
22
+ expect(ticks[0]?.value).toBe(0);
23
+ expect(ticks.at(-1)?.value).toBe(50);
24
+ });
25
+
26
+ test("generates time ticks for a time scale", () => {
27
+ const start = new Date(2026, 0, 1).getTime();
28
+ const time: ScaleDescriptor = { kind: "time", domain: [start, start + 7 * 86_400_000], range: [0, 300] };
29
+ const ticks = buildTicks(time, { count: 7 });
30
+ expect(ticks.length).toBeGreaterThan(0);
31
+ for (const tick of ticks) expect(new Date(tick.value).getHours()).toBe(0);
32
+ });
33
+
34
+ test("returns an empty axis rather than NaN positions for an unusable log domain", () => {
35
+ const broken: ScaleDescriptor = { kind: "log", domain: [0, 100], range: [0, 300], base: 10 };
36
+ expect(buildTicks(broken, { count: 5 })).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,36 @@
1
+ import { scaleValue } from "../scale/scale";
2
+ import type { ScaleDescriptor } from "../scale/scale.types";
3
+ import { linearTicks, logTicks } from "./linear-ticks";
4
+ import type { ChartTick } from "./tick.types";
5
+ import { DEFAULT_TICK_COUNT, downsampleTicks } from "./tick-count";
6
+ import { timeTicks } from "./time-ticks";
7
+
8
+ export type BuildTicksOptions = {
9
+ /** How many ticks to aim for. A suggestion — the generator rounds it to human values. */
10
+ readonly count?: number;
11
+ /** Explicit values, used instead of generating any. Downsampled to `count`. */
12
+ readonly values?: readonly number[];
13
+ };
14
+
15
+ /**
16
+ * Tick values for a scale, each already positioned on the canvas.
17
+ *
18
+ * Positions come from `scaleValue` rather than a second d3 call so an axis and
19
+ * its marks can never disagree about where a value sits.
20
+ */
21
+ export function buildTicks(scale: ScaleDescriptor, options: BuildTicksOptions = {}): ChartTick[] {
22
+ const count = options.count ?? DEFAULT_TICK_COUNT;
23
+ const values = options.values ? downsampleTicks(options.values, count) : generate(scale, count);
24
+ return values.map((value) => ({ value, position: scaleValue(scale, value) }));
25
+ }
26
+
27
+ function generate(scale: ScaleDescriptor, count: number): number[] {
28
+ switch (scale.kind) {
29
+ case "linear":
30
+ return linearTicks(scale.domain, count);
31
+ case "time":
32
+ return timeTicks(scale.domain, count);
33
+ case "log":
34
+ return logTicks(scale.domain, scale.base, count);
35
+ }
36
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { linearTicks, logTicks } from "./linear-ticks";
3
+
4
+ describe("linearTicks", () => {
5
+ test("returns round numbers, not evenly divided ones", () => {
6
+ expect(linearTicks([0, 97], 5)).toEqual([0, 20, 40, 60, 80]);
7
+ });
8
+
9
+ test("treats count as a suggestion and picks the rounder step", () => {
10
+ // Six twenties beats five twenty-fives: d3 optimises for how the numbers
11
+ // read, not for hitting the count. Asserting the count here would be
12
+ // asserting the wrong thing.
13
+ expect(linearTicks([0, 100], 5)).toEqual([0, 20, 40, 60, 80, 100]);
14
+ });
15
+
16
+ test("handles a negative domain", () => {
17
+ expect(linearTicks([-50, 50], 5)).toEqual([-40, -20, 0, 20, 40]);
18
+ });
19
+
20
+ test("returns the single value for a zero-width domain", () => {
21
+ expect(linearTicks([7, 7], 5)).toEqual([7]);
22
+ });
23
+
24
+ test("returns nothing for a non-positive count or a non-finite domain", () => {
25
+ expect(linearTicks([0, 100], 0)).toEqual([]);
26
+ expect(linearTicks([0, 100], -3)).toEqual([]);
27
+ expect(linearTicks([Number.NaN, 100], 5)).toEqual([]);
28
+ });
29
+ });
30
+
31
+ describe("logTicks", () => {
32
+ test("drops to decade powers when d3 would overshoot the count", () => {
33
+ // d3 returns 28 values here — every k·10^i. Unthinned that is a smear.
34
+ expect(logTicks([1, 1000], 10, 4)).toEqual([1, 10, 100, 1000]);
35
+ });
36
+
37
+ test("keeps d3's intermediate multiples when they already fit", () => {
38
+ expect(logTicks([1, 1_000_000], 10, 4)).toEqual([1, 100, 10_000, 1_000_000]);
39
+ });
40
+
41
+ test("thins the decades themselves when there are more than asked for", () => {
42
+ const ticks = logTicks([1, 1e9], 10, 4);
43
+ expect(ticks).toHaveLength(4);
44
+ expect(ticks[0]).toBe(1);
45
+ expect(ticks.at(-1)).toBe(1e9);
46
+ });
47
+
48
+ test("honours a non-decimal base", () => {
49
+ expect(logTicks([1, 1024], 2, 4)).toEqual([1, 8, 128, 1024]);
50
+ });
51
+
52
+ test("returns nothing for a domain reaching zero", () => {
53
+ expect(logTicks([0, 100], 10, 5)).toEqual([]);
54
+ expect(logTicks([-1, 100], 10, 5)).toEqual([]);
55
+ });
56
+ });
@@ -0,0 +1,67 @@
1
+ import { scaleLinear, scaleLog } from "d3-scale";
2
+ import type { DomainTuple } from "../scale/scale.types";
3
+ import { downsampleTicks } from "./tick-count";
4
+
5
+ /**
6
+ * Human tick values across a linear domain.
7
+ *
8
+ * `count` is a suggestion, not a promise — d3 returns whatever number of round
9
+ * values fits, which is the whole point. Asking for five over `[0, 97]` gets
10
+ * you `0 20 40 60 80` and not five ugly nineteenths.
11
+ *
12
+ * Routed through a throwaway `scaleLinear` rather than `d3-array`'s `ticks` so
13
+ * the package's declared dependencies stay `d3-scale` and `d3-shape`.
14
+ */
15
+ export function linearTicks(domain: DomainTuple, count: number): number[] {
16
+ if (count <= 0) return [];
17
+ if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1])) return [];
18
+ if (domain[0] === domain[1]) return [domain[0]];
19
+ return scaleLinear()
20
+ .domain([domain[0], domain[1]])
21
+ .ticks(Math.max(1, Math.round(count)));
22
+ }
23
+
24
+ /**
25
+ * Tick values across a log domain — decade powers when the span is wide,
26
+ * intermediate multiples when it is narrow.
27
+ *
28
+ * d3's log ticks treat `count` as a floor, not a ceiling: it thins only once
29
+ * there are more decades than ticks asked for, and otherwise returns every
30
+ * `k · base^i` for `k` in `1…base-1`. Three decades at a requested five ticks
31
+ * is twenty-eight values, which overprints itself into a grey smear. So when
32
+ * the generator overshoots we drop to the decade powers alone, and thin those
33
+ * only if there are still too many.
34
+ */
35
+ export function logTicks(domain: DomainTuple, base: number, count: number): number[] {
36
+ if (count <= 0) return [];
37
+ if (domain[0] <= 0 || domain[1] <= 0 || domain[0] === domain[1]) return [];
38
+
39
+ const wanted = Math.max(1, Math.round(count));
40
+ const generated = scaleLog().base(base).domain([domain[0], domain[1]]).ticks(wanted);
41
+ if (generated.length <= wanted) return generated;
42
+
43
+ const decades = decadePowers(domain, base);
44
+ if (decades.length === 0) return downsampleTicks(generated, wanted);
45
+ return decades.length <= wanted ? decades : downsampleTicks(decades, wanted);
46
+ }
47
+
48
+ /**
49
+ * Every whole power of `base` inside the domain, low to high.
50
+ *
51
+ * The epsilon is load-bearing. `Math.log(1000) / Math.log(10)` is
52
+ * 2.9999999999999996, so a bare `floor` drops the top decade and a chart
53
+ * domained `[1, 1000]` loses its 1000 tick — the one label the axis most
54
+ * needs. Nudging by a billionth of a decade costs nothing and fixes every
55
+ * exact power.
56
+ */
57
+ const DECADE_EPSILON = 1e-9;
58
+
59
+ function decadePowers(domain: DomainTuple, base: number): number[] {
60
+ const lo = Math.min(domain[0], domain[1]);
61
+ const hi = Math.max(domain[0], domain[1]);
62
+ const logBase = Math.log(base);
63
+ const first = Math.ceil(Math.log(lo) / logBase - DECADE_EPSILON);
64
+ const last = Math.floor(Math.log(hi) / logBase + DECADE_EPSILON);
65
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last < first) return [];
66
+ return Array.from({ length: last - first + 1 }, (_, index) => base ** (first + index));
67
+ }