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