@delacour/react-native-charts 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/package.json +61 -0
- package/src/animation/AGENTS.md +40 -0
- package/src/animation/animation.types.ts +16 -0
- package/src/animation/index.ts +2 -0
- package/src/animation/use-animated-path.ts +74 -0
- package/src/cartesian/AGENTS.md +110 -0
- package/src/cartesian/axes/chart-x-axis.tsx +52 -0
- package/src/cartesian/axes/chart-y-axis.tsx +56 -0
- package/src/cartesian/cartesian-chart.context.tsx +43 -0
- package/src/cartesian/cartesian-chart.tsx +132 -0
- package/src/cartesian/cartesian-chart.types.ts +181 -0
- package/src/cartesian/hooks/use-canvas-size.ts +37 -0
- package/src/cartesian/hooks/use-chart-model.ts +278 -0
- package/src/cartesian/index.ts +41 -0
- package/src/cartesian/marks/chart-area.tsx +83 -0
- package/src/cartesian/marks/chart-bar-group.tsx +132 -0
- package/src/cartesian/marks/chart-bar-stack.tsx +209 -0
- package/src/cartesian/marks/chart-bar.tsx +195 -0
- package/src/cartesian/marks/chart-candlestick.tsx +148 -0
- package/src/cartesian/marks/chart-cursor.tsx +168 -0
- package/src/cartesian/marks/chart-grid.tsx +46 -0
- package/src/cartesian/marks/chart-line.tsx +73 -0
- package/src/cartesian/marks/chart-scatter.tsx +71 -0
- package/src/core/AGENTS.md +77 -0
- package/src/core/animation/morph-strategy.test.ts +38 -0
- package/src/core/animation/morph-strategy.ts +53 -0
- package/src/core/animation/resample-points.test.ts +68 -0
- package/src/core/animation/resample-points.ts +64 -0
- package/src/core/chart.types.ts +62 -0
- package/src/core/curve/build-area.test.ts +48 -0
- package/src/core/curve/build-area.ts +58 -0
- package/src/core/curve/build-line.test.ts +64 -0
- package/src/core/curve/build-line.ts +53 -0
- package/src/core/curve/curves.test.ts +20 -0
- package/src/core/curve/curves.ts +48 -0
- package/src/core/geometry/band.test.ts +79 -0
- package/src/core/geometry/band.ts +111 -0
- package/src/core/geometry/category-domain.test.ts +33 -0
- package/src/core/geometry/category-domain.ts +18 -0
- package/src/core/geometry/chart-bounds.test.ts +36 -0
- package/src/core/geometry/chart-bounds.ts +47 -0
- package/src/core/geometry/chart-layout.test.ts +230 -0
- package/src/core/geometry/chart-layout.ts +162 -0
- package/src/core/geometry/domain.test.ts +87 -0
- package/src/core/geometry/domain.ts +94 -0
- package/src/core/geometry/grid-lines.test.ts +48 -0
- package/src/core/geometry/grid-lines.ts +43 -0
- package/src/core/geometry/stack.test.ts +151 -0
- package/src/core/geometry/stack.ts +109 -0
- package/src/core/geometry/step.test.ts +34 -0
- package/src/core/geometry/step.ts +21 -0
- package/src/core/geometry/transform-input-data.test.ts +105 -0
- package/src/core/geometry/transform-input-data.ts +88 -0
- package/src/core/geometry/x-values.test.ts +37 -0
- package/src/core/geometry/x-values.ts +36 -0
- package/src/core/index.ts +109 -0
- package/src/core/interaction/closest-index.test.ts +36 -0
- package/src/core/interaction/closest-index.ts +27 -0
- package/src/core/interaction/path-segments.test.ts +60 -0
- package/src/core/interaction/path-segments.ts +155 -0
- package/src/core/interaction/y-for-x.test.ts +135 -0
- package/src/core/interaction/y-for-x.ts +106 -0
- package/src/core/polar/build-slice-path.test.ts +173 -0
- package/src/core/polar/build-slice-path.ts +98 -0
- package/src/core/polar/label-position.test.ts +41 -0
- package/src/core/polar/label-position.ts +18 -0
- package/src/core/polar/pie-input.test.ts +36 -0
- package/src/core/polar/pie-input.ts +32 -0
- package/src/core/polar/polar-point.test.ts +55 -0
- package/src/core/polar/polar-point.ts +29 -0
- package/src/core/polar/polar.types.ts +47 -0
- package/src/core/polar/resolve-layout.test.ts +63 -0
- package/src/core/polar/resolve-layout.ts +55 -0
- package/src/core/polar/resolve-slices.test.ts +89 -0
- package/src/core/polar/resolve-slices.ts +58 -0
- package/src/core/polar/slice-index-at.test.ts +52 -0
- package/src/core/polar/slice-index-at.ts +42 -0
- package/src/core/polar/slice-opacity.test.ts +29 -0
- package/src/core/polar/slice-opacity.ts +12 -0
- package/src/core/scale/make-scale.test.ts +55 -0
- package/src/core/scale/make-scale.ts +78 -0
- package/src/core/scale/scale.test.ts +76 -0
- package/src/core/scale/scale.ts +65 -0
- package/src/core/scale/scale.types.ts +30 -0
- package/src/core/shape/build-bars.test.ts +203 -0
- package/src/core/shape/build-bars.ts +167 -0
- package/src/core/shape/build-candles.test.ts +156 -0
- package/src/core/shape/build-candles.ts +153 -0
- package/src/core/shape/build-scatter.test.ts +103 -0
- package/src/core/shape/build-scatter.ts +81 -0
- package/src/core/shape/rect-path.test.ts +61 -0
- package/src/core/shape/rect-path.ts +69 -0
- package/src/core/text/axis-gutters.test.ts +70 -0
- package/src/core/text/axis-gutters.ts +70 -0
- package/src/core/text/bar-label.test.ts +92 -0
- package/src/core/text/bar-label.ts +61 -0
- package/src/core/text/format-tick.test.ts +87 -0
- package/src/core/text/format-tick.ts +76 -0
- package/src/core/text/label-anchor.test.ts +59 -0
- package/src/core/text/label-anchor.ts +62 -0
- package/src/core/ticks/build-ticks.test.ts +38 -0
- package/src/core/ticks/build-ticks.ts +36 -0
- package/src/core/ticks/linear-ticks.test.ts +56 -0
- package/src/core/ticks/linear-ticks.ts +67 -0
- package/src/core/ticks/tick-count.test.ts +46 -0
- package/src/core/ticks/tick-count.ts +35 -0
- package/src/core/ticks/tick.types.ts +7 -0
- package/src/core/ticks/time-ticks.test.ts +61 -0
- package/src/core/ticks/time-ticks.ts +22 -0
- package/src/core/util/as-number.test.ts +45 -0
- package/src/core/util/as-number.ts +22 -0
- package/src/core/util/clamp.test.ts +21 -0
- package/src/core/util/clamp.ts +16 -0
- package/src/core/util/extent.test.ts +33 -0
- package/src/core/util/extent.ts +37 -0
- package/src/core/util/read-at.test.ts +14 -0
- package/src/core/util/read-at.ts +14 -0
- package/src/core/util/sided-number.test.ts +28 -0
- package/src/core/util/sided-number.ts +34 -0
- package/src/docs.test.ts +45 -0
- package/src/flat-worklet.test.ts +130 -0
- package/src/gesture/AGENTS.md +84 -0
- package/src/gesture/gesture-overlay.tsx +21 -0
- package/src/gesture/gesture.types.ts +143 -0
- package/src/gesture/index.ts +12 -0
- package/src/gesture/use-chart-scrub.ts +44 -0
- package/src/gesture/use-scrub-gesture.ts +162 -0
- package/src/index.ts +6 -0
- package/src/no-classname.test.ts +27 -0
- package/src/polar/AGENTS.md +55 -0
- package/src/polar/hooks/use-polar-model.ts +88 -0
- package/src/polar/index.ts +14 -0
- package/src/polar/marks/pie-inset.tsx +70 -0
- package/src/polar/marks/pie-label.tsx +71 -0
- package/src/polar/marks/pie-slice.tsx +82 -0
- package/src/polar/marks/pie-slices.tsx +60 -0
- package/src/polar/polar-chart.context.tsx +37 -0
- package/src/polar/polar-chart.tsx +91 -0
- package/src/polar/polar-chart.types.ts +82 -0
- package/src/purity.test.ts +75 -0
- package/src/skia/AGENTS.md +39 -0
- package/src/skia/build-path.ts +22 -0
- package/src/skia/font.ts +78 -0
- package/src/skia/index.ts +9 -0
- package/src/source-tree.test.ts +33 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scale kinds a chart axis can take.
|
|
3
|
+
*
|
|
4
|
+
* `band` and `point` are deliberately absent: a line over categorical x uses
|
|
5
|
+
* the datum's index against a linear scale, which is what `transformInputData`
|
|
6
|
+
* substitutes when a field is not numeric. Band arrives with bars, where a
|
|
7
|
+
* category genuinely needs a width rather than a position.
|
|
8
|
+
*/
|
|
9
|
+
export type ScaleType = "linear" | "time" | "log";
|
|
10
|
+
|
|
11
|
+
export type DomainTuple = readonly [number, number];
|
|
12
|
+
export type RangeTuple = readonly [number, number];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A scale as plain, serialisable data.
|
|
16
|
+
*
|
|
17
|
+
* d3's scales are closures, and a closure cannot be read on the UI thread — a
|
|
18
|
+
* shared value carries data, not functions. So every scale exists in two
|
|
19
|
+
* forms: the d3 object, which stays on the JS thread and does the nicing and
|
|
20
|
+
* the ticking, and this descriptor, which is what crosses into a worklet so
|
|
21
|
+
* the scrub gesture can invert a touch x with no bridge hop.
|
|
22
|
+
*
|
|
23
|
+
* Keep it numbers only. The moment a field here is a function or a class
|
|
24
|
+
* instance, `invertValue` stops working on the UI thread and the failure shows
|
|
25
|
+
* up as a scrub dot that never moves.
|
|
26
|
+
*/
|
|
27
|
+
export type ScaleDescriptor =
|
|
28
|
+
| { readonly kind: "linear"; readonly domain: DomainTuple; readonly range: RangeTuple }
|
|
29
|
+
| { readonly kind: "time"; readonly domain: DomainTuple; readonly range: RangeTuple }
|
|
30
|
+
| { readonly kind: "log"; readonly domain: DomainTuple; readonly range: RangeTuple; readonly base: number };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint, ChartSegment } from "../chart.types";
|
|
3
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
4
|
+
import { barRects, barsPathFromRects, buildBarsPath, resolveBaseline } from "./build-bars";
|
|
5
|
+
|
|
6
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
7
|
+
const seg = (x: number, y: number | null, y0: number | null): ChartSegment => ({
|
|
8
|
+
x,
|
|
9
|
+
y,
|
|
10
|
+
xValue: x,
|
|
11
|
+
yValue: y,
|
|
12
|
+
y0,
|
|
13
|
+
y0Value: y0,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function commands(path: string): string {
|
|
17
|
+
return (path.match(/[A-Za-z]/g) ?? []).join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const bounds = { left: 0, right: 100, top: 0, bottom: 100 };
|
|
21
|
+
|
|
22
|
+
describe("barRects", () => {
|
|
23
|
+
test("centres each bar on its x, from the baseline to its y", () => {
|
|
24
|
+
const [rect] = barRects([at(50, 30)], { bandwidth: 20, baseline: 100 });
|
|
25
|
+
expect(rect).toEqual({ left: 40, top: 30, right: 60, bottom: 100, negative: false, index: 0 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("an offset shifts every bar, which is how a group interleaves", () => {
|
|
29
|
+
const [rect] = barRects([at(50, 30)], { bandwidth: 20, baseline: 100, offset: -15 });
|
|
30
|
+
expect(rect?.left).toBe(25);
|
|
31
|
+
expect(rect?.right).toBe(45);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a bar below the baseline is flagged negative, with top still above bottom", () => {
|
|
35
|
+
const [rect] = barRects([at(50, 120)], { bandwidth: 20, baseline: 100 });
|
|
36
|
+
expect(rect).toMatchObject({ top: 100, bottom: 120, negative: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a gap is a zero-height bar at the baseline, never a dropped bar", () => {
|
|
40
|
+
// Dropping it would shift every later bar's index, and a stacked chart
|
|
41
|
+
// would pair the wrong segments.
|
|
42
|
+
const rects = barRects([at(10, 30), at(50, null), at(90, 60)], { bandwidth: 20, baseline: 100 });
|
|
43
|
+
expect(rects).toHaveLength(3);
|
|
44
|
+
expect(rects[1]).toMatchObject({ top: 100, bottom: 100, negative: false, index: 1 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a segment bases on its own y0 rather than the baseline", () => {
|
|
48
|
+
const [rect] = barRects([seg(50, 30, 60)], { bandwidth: 20, baseline: 100 });
|
|
49
|
+
expect(rect).toMatchObject({ top: 30, bottom: 60, negative: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a negative segment is one whose top is below its base", () => {
|
|
53
|
+
const [rect] = barRects([seg(50, 80, 60)], { bandwidth: 20, baseline: 100 });
|
|
54
|
+
expect(rect).toMatchObject({ top: 60, bottom: 80, negative: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a segment with a null base is a gap at the baseline", () => {
|
|
58
|
+
const [rect] = barRects([seg(50, null, null)], { bandwidth: 20, baseline: 100 });
|
|
59
|
+
expect(rect).toMatchObject({ top: 100, bottom: 100 });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildBarsPath", () => {
|
|
64
|
+
test("one rect's worth of verbs per bar", () => {
|
|
65
|
+
expect(commands(buildBarsPath([at(10, 30), at(50, 60)], { bandwidth: 20, baseline: 100 }))).toBe(
|
|
66
|
+
"MCLCLCLCZMCLCLCLCZ"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the verbs never depend on the values", () => {
|
|
71
|
+
// Positive, gap and negative all animate into one another.
|
|
72
|
+
const options = { bandwidth: 20, baseline: 100, roundedCorners: { topLeft: 4, topRight: 4 } };
|
|
73
|
+
const before = buildBarsPath([at(10, 50), at(50, 70), at(90, 20)], options);
|
|
74
|
+
const after = buildBarsPath([at(10, 90), at(50, null), at(90, 120)], options);
|
|
75
|
+
expect(commands(after)).toBe(commands(before));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rounds the value end of a positive bar", () => {
|
|
79
|
+
const path = buildBarsPath([at(50, 30)], { bandwidth: 20, baseline: 100, roundedCorners: { topLeft: 4 } });
|
|
80
|
+
// Starts on the left edge 4 below the top: the top-left corner is rounded.
|
|
81
|
+
expect(path.startsWith("M40,34")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a negative bar rounds the same corner at its value end, which is its canvas bottom", () => {
|
|
85
|
+
const path = buildBarsPath([at(50, 120)], { bandwidth: 20, baseline: 100, roundedCorners: { topLeft: 4 } });
|
|
86
|
+
// The rect's top-left is now square, so the path starts at the very corner.
|
|
87
|
+
expect(path.startsWith("M40,100")).toBe(true);
|
|
88
|
+
// ...and the bottom-left carries the radius.
|
|
89
|
+
expect(path).toContain("L44,120");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("is empty for no points", () => {
|
|
93
|
+
expect(buildBarsPath([], { bandwidth: 20, baseline: 100 })).toBe("");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("barsPathFromRects", () => {
|
|
98
|
+
test("takes per-rect corners, for a stack that rounds only its top segment", () => {
|
|
99
|
+
const rects = barRects([at(10, 30), at(50, 30)], { bandwidth: 20, baseline: 100 });
|
|
100
|
+
const path = barsPathFromRects(rects, [undefined, { topLeft: 4 }]);
|
|
101
|
+
expect(path.startsWith("M0,30")).toBe(true);
|
|
102
|
+
expect(path).toContain("M40,34");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("resolveBaseline", () => {
|
|
107
|
+
const linear = (domain: readonly [number, number]): ScaleDescriptor => ({
|
|
108
|
+
kind: "linear",
|
|
109
|
+
domain,
|
|
110
|
+
range: [100, 0],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("is the position of zero", () => {
|
|
114
|
+
expect(resolveBaseline(linear([-50, 50]), bounds)).toBe(50);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("clamps into the plot when zero is outside the domain", () => {
|
|
118
|
+
expect(resolveBaseline(linear([40, 90]), bounds)).toBe(100);
|
|
119
|
+
expect(resolveBaseline(linear([-90, -40]), bounds)).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a log scale bases at the plot bottom, where zero cannot be drawn", () => {
|
|
123
|
+
expect(resolveBaseline({ kind: "log", domain: [1, 1000], range: [100, 0], base: 10 }, bounds)).toBe(100);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("horizontal bars", () => {
|
|
128
|
+
// Category on y, value on x. A horizontal point is `{ x: value, y: category }`.
|
|
129
|
+
const h = (y: number, x: number | null): ChartPoint => ({ x: x ?? Number.NaN, y, xValue: y, yValue: x });
|
|
130
|
+
const hseg = (y: number, x: number | null, x0: number | null): ChartSegment => ({
|
|
131
|
+
x: x ?? Number.NaN,
|
|
132
|
+
y,
|
|
133
|
+
xValue: y,
|
|
134
|
+
yValue: x,
|
|
135
|
+
y0: x0,
|
|
136
|
+
y0Value: x0,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("spans from the baseline x to the value x, a bandwidth tall around y", () => {
|
|
140
|
+
const [rect] = barRects([h(50, 80)], { bandwidth: 20, baseline: 0, orientation: "horizontal" });
|
|
141
|
+
expect(rect).toEqual({ left: 0, top: 40, right: 80, bottom: 60, negative: false, index: 0 });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a bar left of the baseline is negative", () => {
|
|
145
|
+
const [rect] = barRects([h(50, 20)], { bandwidth: 20, baseline: 60, orientation: "horizontal" });
|
|
146
|
+
expect(rect).toMatchObject({ left: 20, right: 60, negative: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a gap is a zero-width bar at the baseline", () => {
|
|
150
|
+
const [rect] = barRects([h(50, null)], { bandwidth: 20, baseline: 30, orientation: "horizontal" });
|
|
151
|
+
expect(rect).toMatchObject({ left: 30, right: 30, top: 40, bottom: 60 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a segment bases on its y0, which lies along the value axis", () => {
|
|
155
|
+
const [rect] = barRects([hseg(50, 80, 30)], { bandwidth: 20, baseline: 0, orientation: "horizontal" });
|
|
156
|
+
expect(rect).toMatchObject({ left: 30, right: 80, negative: false });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an offset shifts the bar along y", () => {
|
|
160
|
+
const [rect] = barRects([h(50, 80)], { bandwidth: 20, baseline: 0, offset: 5, orientation: "horizontal" });
|
|
161
|
+
expect(rect).toMatchObject({ top: 45, bottom: 65 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the value-end corners land on the right of a positive bar", () => {
|
|
165
|
+
const path = buildBarsPath([h(50, 80)], {
|
|
166
|
+
bandwidth: 20,
|
|
167
|
+
baseline: 0,
|
|
168
|
+
orientation: "horizontal",
|
|
169
|
+
roundedCorners: { topLeft: 4, topRight: 4 },
|
|
170
|
+
});
|
|
171
|
+
// Left edge square: the path starts at the very top-left corner.
|
|
172
|
+
expect(path.startsWith("M0,40")).toBe(true);
|
|
173
|
+
// Right edge rounded at both ends.
|
|
174
|
+
expect(path).toContain("L76,40");
|
|
175
|
+
expect(path).toContain("80,44L80,56");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("and on the left of a negative bar", () => {
|
|
179
|
+
const path = buildBarsPath([h(50, 20)], {
|
|
180
|
+
bandwidth: 20,
|
|
181
|
+
baseline: 60,
|
|
182
|
+
orientation: "horizontal",
|
|
183
|
+
roundedCorners: { topLeft: 4, topRight: 4 },
|
|
184
|
+
});
|
|
185
|
+
expect(path.startsWith("M20,44")).toBe(true);
|
|
186
|
+
expect(path).toContain("L24,60");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("the verbs never depend on the values", () => {
|
|
190
|
+
const options = { bandwidth: 20, baseline: 0, orientation: "horizontal" as const };
|
|
191
|
+
const before = buildBarsPath([h(10, 50), h(50, 70)], options);
|
|
192
|
+
const after = buildBarsPath([h(10, null), h(50, -20)], options);
|
|
193
|
+
expect(commands(after)).toBe(commands(before));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("the baseline is the position of zero on the value scale, clamped to the plot's width", () => {
|
|
197
|
+
const scale: ScaleDescriptor = { kind: "linear", domain: [-50, 50], range: [0, 100] };
|
|
198
|
+
expect(resolveBaseline(scale, bounds, "horizontal")).toBe(50);
|
|
199
|
+
const pinned: ScaleDescriptor = { kind: "linear", domain: [40, 90], range: [0, 100] };
|
|
200
|
+
expect(resolveBaseline(pinned, bounds, "horizontal")).toBe(0);
|
|
201
|
+
expect(resolveBaseline({ kind: "log", domain: [1, 10], range: [0, 100], base: 10 }, bounds, "horizontal")).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ChartBounds, ChartOrientation, ChartPoint, ChartSegment } from "../chart.types";
|
|
2
|
+
import { scaleValue } from "../scale/scale";
|
|
3
|
+
import type { ScaleDescriptor } from "../scale/scale.types";
|
|
4
|
+
import { clamp } from "../util/clamp";
|
|
5
|
+
import { type CornerRadii, rectPath } from "./rect-path";
|
|
6
|
+
|
|
7
|
+
/** One bar, resolved to canvas edges. `top` is always above `bottom`. */
|
|
8
|
+
export type BarRect = {
|
|
9
|
+
readonly left: number;
|
|
10
|
+
readonly top: number;
|
|
11
|
+
readonly right: number;
|
|
12
|
+
readonly bottom: number;
|
|
13
|
+
/** Whether the bar hangs below its base — its value end is its canvas bottom. */
|
|
14
|
+
readonly negative: boolean;
|
|
15
|
+
/** The datum's index in the series. */
|
|
16
|
+
readonly index: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BarRectOptions = {
|
|
20
|
+
readonly bandwidth: number;
|
|
21
|
+
/** Canvas position along the value axis a bar stands on when its point has no base of its own. */
|
|
22
|
+
readonly baseline: number;
|
|
23
|
+
/** Canvas distance along the category axis added to every bar, for a group's interleaving. */
|
|
24
|
+
readonly offset?: number;
|
|
25
|
+
/** Bars grow up from a baseline y, or right from a baseline x. Defaults to `vertical`. */
|
|
26
|
+
readonly orientation?: ChartOrientation;
|
|
27
|
+
/**
|
|
28
|
+
* Corners named by their role, not their canvas position: `topLeft` and
|
|
29
|
+
* `topRight` are the value end, `bottomLeft` and `bottomRight` the base. A
|
|
30
|
+
* negative bar swaps them, so the rounded end is still the one that moves.
|
|
31
|
+
*/
|
|
32
|
+
readonly roundedCorners?: CornerRadii;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A point that may carry its own base. A `ChartSegment` does; a `ChartPoint`
|
|
37
|
+
* stands on the baseline.
|
|
38
|
+
*/
|
|
39
|
+
export type BarPoint = ChartPoint | ChartSegment;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Each point as a bar rect, centred on its x.
|
|
43
|
+
*
|
|
44
|
+
* A gap is a zero-height rect at the baseline rather than a dropped bar.
|
|
45
|
+
* Dropping it would shift every later bar's index, and a stacked chart would
|
|
46
|
+
* pair the wrong segments — and the path would lose nine verbs, which is a
|
|
47
|
+
* snap instead of a morph the next time that datum has a value.
|
|
48
|
+
*/
|
|
49
|
+
export function barRects(points: readonly BarPoint[], options: BarRectOptions): BarRect[] {
|
|
50
|
+
const { bandwidth, baseline, offset = 0, orientation = "vertical" } = options;
|
|
51
|
+
const horizontal = orientation === "horizontal";
|
|
52
|
+
const half = bandwidth / 2;
|
|
53
|
+
|
|
54
|
+
return points.map((point, index) => {
|
|
55
|
+
const centre = (horizontal ? (point.y ?? Number.NaN) : point.x) + offset;
|
|
56
|
+
const base = baseOf(point, baseline);
|
|
57
|
+
const measured = horizontal ? point.x : point.y;
|
|
58
|
+
const value = measured !== null && Number.isFinite(measured) && Number.isFinite(base) ? measured : base;
|
|
59
|
+
if (horizontal) {
|
|
60
|
+
return {
|
|
61
|
+
left: Math.min(value, base),
|
|
62
|
+
right: Math.max(value, base),
|
|
63
|
+
top: centre - half,
|
|
64
|
+
bottom: centre + half,
|
|
65
|
+
negative: value < base,
|
|
66
|
+
index,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
left: centre - half,
|
|
71
|
+
right: centre + half,
|
|
72
|
+
top: Math.min(value, base),
|
|
73
|
+
bottom: Math.max(value, base),
|
|
74
|
+
negative: value > base,
|
|
75
|
+
index,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Where a point stands: its own `y0` when it is a segment with one, else the baseline. */
|
|
81
|
+
function baseOf(point: BarPoint, baseline: number): number {
|
|
82
|
+
if ("y0" in point && point.y0 !== null && Number.isFinite(point.y0)) return point.y0;
|
|
83
|
+
return baseline;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rects to one path, with corners resolved per rect.
|
|
88
|
+
*
|
|
89
|
+
* `roundedCorners` may be one set for every bar or one per rect, for a stack
|
|
90
|
+
* that rounds only its outermost segment. Corners are stated in value/base
|
|
91
|
+
* terms and rotated here for a negative bar.
|
|
92
|
+
*/
|
|
93
|
+
export function barsPathFromRects(
|
|
94
|
+
rects: readonly BarRect[],
|
|
95
|
+
roundedCorners?: CornerRadii | readonly (CornerRadii | undefined)[],
|
|
96
|
+
orientation: ChartOrientation = "vertical"
|
|
97
|
+
): string {
|
|
98
|
+
let path = "";
|
|
99
|
+
for (let index = 0; index < rects.length; index += 1) {
|
|
100
|
+
const rect = rects[index] as BarRect;
|
|
101
|
+
const radii = Array.isArray(roundedCorners) ? roundedCorners[index] : (roundedCorners as CornerRadii | undefined);
|
|
102
|
+
path += rectPath(
|
|
103
|
+
rect.left,
|
|
104
|
+
rect.top,
|
|
105
|
+
rect.right,
|
|
106
|
+
rect.bottom,
|
|
107
|
+
orientCorners(radii ?? {}, rect.negative, orientation)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Value-end corners onto whichever canvas edge the value end is: the top of a
|
|
115
|
+
* positive vertical bar, the bottom of a negative one, the right of a positive
|
|
116
|
+
* horizontal bar, the left of a negative one.
|
|
117
|
+
*/
|
|
118
|
+
function orientCorners(radii: CornerRadii, negative: boolean, orientation: ChartOrientation): CornerRadii {
|
|
119
|
+
if (orientation === "horizontal") {
|
|
120
|
+
return negative
|
|
121
|
+
? {
|
|
122
|
+
topLeft: radii.topRight,
|
|
123
|
+
bottomLeft: radii.topLeft,
|
|
124
|
+
bottomRight: radii.bottomLeft,
|
|
125
|
+
topRight: radii.bottomRight,
|
|
126
|
+
}
|
|
127
|
+
: {
|
|
128
|
+
topRight: radii.topLeft,
|
|
129
|
+
bottomRight: radii.topRight,
|
|
130
|
+
bottomLeft: radii.bottomRight,
|
|
131
|
+
topLeft: radii.bottomLeft,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!negative) return radii;
|
|
135
|
+
return {
|
|
136
|
+
topLeft: radii.bottomLeft,
|
|
137
|
+
topRight: radii.bottomRight,
|
|
138
|
+
bottomRight: radii.topRight,
|
|
139
|
+
bottomLeft: radii.topLeft,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Points straight to one path of bars. */
|
|
144
|
+
export function buildBarsPath(points: readonly BarPoint[], options: BarRectOptions): string {
|
|
145
|
+
return barsPathFromRects(barRects(points, options), options.roundedCorners, options.orientation);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Where bars stand: the position of zero on the value scale, held inside the
|
|
150
|
+
* plot. A y on a vertical chart, an x on a horizontal one.
|
|
151
|
+
*
|
|
152
|
+
* Zero can lie outside a domain that was pinned or padded away from it; a bar
|
|
153
|
+
* based there would draw off the canvas. A log scale has no zero at all, so
|
|
154
|
+
* its bars stand on the plot's bottom — or left — edge.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveBaseline(
|
|
157
|
+
valueScale: ScaleDescriptor,
|
|
158
|
+
bounds: ChartBounds,
|
|
159
|
+
orientation: ChartOrientation = "vertical"
|
|
160
|
+
): number {
|
|
161
|
+
if (orientation === "horizontal") {
|
|
162
|
+
if (valueScale.kind === "log") return bounds.left;
|
|
163
|
+
return clamp(scaleValue(valueScale, 0), bounds.left, bounds.right);
|
|
164
|
+
}
|
|
165
|
+
if (valueScale.kind === "log") return bounds.bottom;
|
|
166
|
+
return clamp(scaleValue(valueScale, 0), bounds.top, bounds.bottom);
|
|
167
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChartPoint } from "../chart.types";
|
|
3
|
+
import { buildCandlePaths, candlePoints, candleSentiment, SENTIMENTS } from "./build-candles";
|
|
4
|
+
|
|
5
|
+
const at = (x: number, y: number | null): ChartPoint => ({ x, y, xValue: x, yValue: y });
|
|
6
|
+
|
|
7
|
+
function commands(path: string): string {
|
|
8
|
+
return (path.match(/[A-Za-z]/g) ?? []).join("");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Four series from rows of `[open, high, low, close]`, in canvas y (100 − value). */
|
|
12
|
+
function series(rows: readonly (readonly [number | null, number | null, number | null, number | null])[]) {
|
|
13
|
+
const pick = (column: 0 | 1 | 2 | 3): ChartPoint[] =>
|
|
14
|
+
rows.map((row, index) => {
|
|
15
|
+
const value = row[column];
|
|
16
|
+
return { x: index * 20 + 10, y: value === null ? null : 100 - value, xValue: index, yValue: value };
|
|
17
|
+
});
|
|
18
|
+
return candlePoints(pick(0), pick(1), pick(2), pick(3));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("candleSentiment", () => {
|
|
22
|
+
test("close above open is positive, below is negative", () => {
|
|
23
|
+
expect(candleSentiment(10, 12)).toBe("positive");
|
|
24
|
+
expect(candleSentiment(12, 10)).toBe("negative");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("an unchanged close is neutral, and so is anything unreadable", () => {
|
|
28
|
+
expect(candleSentiment(10, 10)).toBe("neutral");
|
|
29
|
+
expect(candleSentiment(Number.NaN, 10)).toBe("neutral");
|
|
30
|
+
expect(candleSentiment(null, 10)).toBe("neutral");
|
|
31
|
+
expect(candleSentiment(10, null)).toBe("neutral");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("candlePoints", () => {
|
|
36
|
+
test("zips the four series by index", () => {
|
|
37
|
+
const candles = series([
|
|
38
|
+
[10, 15, 5, 12],
|
|
39
|
+
[12, 14, 8, 9],
|
|
40
|
+
]);
|
|
41
|
+
expect(candles).toHaveLength(2);
|
|
42
|
+
expect(candles[0]).toMatchObject({ index: 0, x: 10, openValue: 10, closeValue: 12, sentiment: "positive" });
|
|
43
|
+
expect(candles[1]).toMatchObject({ index: 1, x: 30, sentiment: "negative" });
|
|
44
|
+
expect(candles[0]?.highY).toBe(85);
|
|
45
|
+
expect(candles[0]?.lowY).toBe(95);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a row with any missing value is a gap, never dropped", () => {
|
|
49
|
+
const candles = series([[10, null, 5, 12]]);
|
|
50
|
+
expect(candles).toHaveLength(1);
|
|
51
|
+
expect(candles[0]?.gap).toBe(true);
|
|
52
|
+
expect(candles[0]?.sentiment).toBe("neutral");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("takes the length of the shortest series", () => {
|
|
56
|
+
expect(candlePoints([at(0, 1), at(1, 1)], [at(0, 1)], [at(0, 1)], [at(0, 1)])).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("buildCandlePaths", () => {
|
|
61
|
+
const candles = series([
|
|
62
|
+
[10, 15, 5, 12],
|
|
63
|
+
[12, 14, 8, 9],
|
|
64
|
+
[9, 9, 9, 9],
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
test("every candle appears in every sentiment path, so a flip morphs", () => {
|
|
68
|
+
const paths = buildCandlePaths(candles, { bandwidth: 10 });
|
|
69
|
+
const bodies = SENTIMENTS.map((sentiment) => commands(paths[sentiment].bodies));
|
|
70
|
+
const wicks = SENTIMENTS.map((sentiment) => commands(paths[sentiment].wicks));
|
|
71
|
+
expect(bodies[0]).toBe("MCLCLCLCZ".repeat(3));
|
|
72
|
+
expect(bodies[1]).toBe(bodies[0]);
|
|
73
|
+
expect(bodies[2]).toBe(bodies[0]);
|
|
74
|
+
expect(wicks[0]).toBe("ML".repeat(3));
|
|
75
|
+
expect(wicks[1]).toBe(wicks[0]);
|
|
76
|
+
expect(wicks[2]).toBe(wicks[0]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a body spans open to close, half a bandwidth either side of x", () => {
|
|
80
|
+
const { bodies } = buildCandlePaths(candles, { bandwidth: 10 }).positive;
|
|
81
|
+
// First candle: open 10 → y 90, close 12 → y 88, x 10.
|
|
82
|
+
expect(bodies.startsWith("M5,88")).toBe(true);
|
|
83
|
+
expect(bodies).toContain("L15,88");
|
|
84
|
+
expect(bodies).toContain("L5,90");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a wick runs from the high to the low", () => {
|
|
88
|
+
const { wicks } = buildCandlePaths(candles, { bandwidth: 10 }).positive;
|
|
89
|
+
expect(wicks.startsWith("M10,85L10,95")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a candle of another sentiment is a degenerate rect and a zero-length wick at its own place", () => {
|
|
93
|
+
const { bodies, wicks } = buildCandlePaths(candles, { bandwidth: 10 }).negative;
|
|
94
|
+
// The first candle is positive, so in the negative path it collapses to
|
|
95
|
+
// its body's midpoint (y 89) — where it will grow from if it flips.
|
|
96
|
+
expect(bodies.startsWith("M10,89")).toBe(true);
|
|
97
|
+
expect(wicks.startsWith("M10,89L10,89")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a flat candle is neutral and is expanded to the minimum body height", () => {
|
|
101
|
+
const { bodies } = buildCandlePaths(candles, { bandwidth: 10, minBodyHeight: 2 }).neutral;
|
|
102
|
+
// Third candle: everything at 9 → y 91, expanded about 91 to 90..92.
|
|
103
|
+
expect(bodies).toContain("M45,90");
|
|
104
|
+
expect(bodies).toContain("L45,92");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a thin body is expanded to the minimum height about its midpoint", () => {
|
|
108
|
+
const thin = series([[10, 12, 8, 10.2]]);
|
|
109
|
+
const { bodies } = buildCandlePaths(thin, { bandwidth: 10, minBodyHeight: 4 }).positive;
|
|
110
|
+
// open 10 → 90, close 10.2 → 89.8; midpoint 89.9, expanded to 87.9..91.9.
|
|
111
|
+
expect(bodies).toContain("M5,87.9");
|
|
112
|
+
expect(bodies).toContain("L5,91.9");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a gap row is degenerate in all six paths", () => {
|
|
116
|
+
const withGap = series([
|
|
117
|
+
[10, 15, 5, 12],
|
|
118
|
+
[null, 14, 8, 9],
|
|
119
|
+
]);
|
|
120
|
+
const paths = buildCandlePaths(withGap, { bandwidth: 10, baseline: 100 });
|
|
121
|
+
for (const sentiment of SENTIMENTS) {
|
|
122
|
+
expect(commands(paths[sentiment].bodies)).toBe("MCLCLCLCZ".repeat(2));
|
|
123
|
+
expect(paths[sentiment].bodies).toContain("M30,100");
|
|
124
|
+
expect(paths[sentiment].wicks).toContain("M30,100L30,100");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("is empty in every path for no candles", () => {
|
|
129
|
+
const paths = buildCandlePaths([], { bandwidth: 10 });
|
|
130
|
+
for (const sentiment of SENTIMENTS) {
|
|
131
|
+
expect(paths[sentiment]).toEqual({ bodies: "", wicks: "" });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("horizontal candles", () => {
|
|
137
|
+
// Category on y, value on x.
|
|
138
|
+
const horizontal = (rows: readonly (readonly [number, number, number, number])[]) => {
|
|
139
|
+
const pick = (column: 0 | 1 | 2 | 3): ChartPoint[] =>
|
|
140
|
+
rows.map((row, index) => ({ x: row[column], y: index * 20 + 10, xValue: index, yValue: row[column] }));
|
|
141
|
+
return candlePoints(pick(0), pick(1), pick(2), pick(3), "horizontal");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
test("reads the category from y and the values from x", () => {
|
|
145
|
+
const [candle] = horizontal([[10, 15, 5, 12]]);
|
|
146
|
+
expect(candle).toMatchObject({ x: 10, openY: 10, highY: 15, lowY: 5, closeY: 12, sentiment: "positive" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("bodies span open to close on x, a bandwidth tall; wicks run low to high on x", () => {
|
|
150
|
+
const paths = buildCandlePaths(horizontal([[10, 15, 5, 12]]), { bandwidth: 10, orientation: "horizontal" });
|
|
151
|
+
expect(paths.positive.bodies.startsWith("M10,5")).toBe(true);
|
|
152
|
+
expect(paths.positive.bodies).toContain("L12,5");
|
|
153
|
+
expect(paths.positive.bodies).toContain("L10,15");
|
|
154
|
+
expect(paths.positive.wicks).toBe("M15,10L5,10");
|
|
155
|
+
});
|
|
156
|
+
});
|