@voila.dev/ui-chart 1.1.9

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 (40) hide show
  1. package/package.json +57 -0
  2. package/src/components/chart-area.tsx +124 -0
  3. package/src/components/chart-bars.tsx +105 -0
  4. package/src/components/chart-cursor.tsx +105 -0
  5. package/src/components/chart-data-table.tsx +55 -0
  6. package/src/components/chart-empty.tsx +22 -0
  7. package/src/components/chart-grid.tsx +81 -0
  8. package/src/components/chart-label-list.tsx +178 -0
  9. package/src/components/chart-legend.tsx +106 -0
  10. package/src/components/chart-line.tsx +100 -0
  11. package/src/components/chart-pie.tsx +130 -0
  12. package/src/components/chart-points.tsx +94 -0
  13. package/src/components/chart-polar-angle-axis.tsx +76 -0
  14. package/src/components/chart-polar-grid.tsx +64 -0
  15. package/src/components/chart-radar.tsx +103 -0
  16. package/src/components/chart-radial-bar.tsx +122 -0
  17. package/src/components/chart-reference-line.tsx +60 -0
  18. package/src/components/chart-root.tsx +188 -0
  19. package/src/components/chart-skeleton.tsx +45 -0
  20. package/src/components/chart-slice.tsx +59 -0
  21. package/src/components/chart-style.tsx +78 -0
  22. package/src/components/chart-tooltip.tsx +205 -0
  23. package/src/components/chart-x-axis.tsx +94 -0
  24. package/src/components/chart-y-axis.tsx +90 -0
  25. package/src/components/chart.tsx +146 -0
  26. package/src/context/chart-context.tsx +70 -0
  27. package/src/core/axis.ts +53 -0
  28. package/src/core/bars.ts +160 -0
  29. package/src/core/chart-model.ts +156 -0
  30. package/src/core/config.ts +68 -0
  31. package/src/core/format.ts +86 -0
  32. package/src/core/geometry.ts +271 -0
  33. package/src/core/polar.ts +138 -0
  34. package/src/core/scales.ts +167 -0
  35. package/src/core/series.ts +70 -0
  36. package/src/core/ticks.ts +118 -0
  37. package/src/core/types.ts +71 -0
  38. package/src/hooks/use-chart-dimensions.ts +50 -0
  39. package/src/hooks/use-chart-pointer.ts +152 -0
  40. package/src/styles.css +10 -0
@@ -0,0 +1,271 @@
1
+ import { polarToCartesian } from "#/core/polar.ts";
2
+ import type {
3
+ ChartCornerRadius,
4
+ ChartCurve,
5
+ ChartPoint,
6
+ } from "#/core/types.ts";
7
+
8
+ /**
9
+ * SVG path builders. Everything is rounded to `PATH_PRECISION` decimals so the
10
+ * emitted `d` attributes stay short, stable across runs and pleasant to assert
11
+ * on in tests.
12
+ */
13
+
14
+ const PATH_PRECISION = 2;
15
+ /** Fritsch-Carlson clamps the tangent when the slope ratio exceeds this. */
16
+ const MONOTONE_SLOPE_LIMIT = 9;
17
+
18
+ function round(value: number): number {
19
+ const factor = 10 ** PATH_PRECISION;
20
+ return Math.round(value * factor) / factor;
21
+ }
22
+
23
+ function point(x: number, y: number): string {
24
+ return `${round(x)},${round(y)}`;
25
+ }
26
+
27
+ /**
28
+ * Per-point tangents for a monotone cubic spline (Fritsch-Carlson): the curve
29
+ * bends through the data without the overshoot a plain cubic gives you, so a
30
+ * series that never goes negative never dips below the axis.
31
+ */
32
+ function monotoneTangents(points: ReadonlyArray<ChartPoint>): number[] {
33
+ const count = points.length;
34
+ const secants: number[] = [];
35
+ for (let index = 0; index < count - 1; index += 1) {
36
+ const run = points[index + 1].x - points[index].x;
37
+ secants.push(run === 0 ? 0 : (points[index + 1].y - points[index].y) / run);
38
+ }
39
+
40
+ const tangents: number[] = [secants[0] ?? 0];
41
+ for (let index = 1; index < count - 1; index += 1) {
42
+ tangents.push((secants[index - 1] + secants[index]) / 2);
43
+ }
44
+ tangents.push(secants[count - 2] ?? 0);
45
+
46
+ for (let index = 0; index < secants.length; index += 1) {
47
+ const secant = secants[index];
48
+ if (secant === 0) {
49
+ tangents[index] = 0;
50
+ tangents[index + 1] = 0;
51
+ continue;
52
+ }
53
+ const before = tangents[index] / secant;
54
+ const after = tangents[index + 1] / secant;
55
+ const magnitude = before * before + after * after;
56
+ if (magnitude > MONOTONE_SLOPE_LIMIT) {
57
+ const scale = 3 / Math.sqrt(magnitude);
58
+ tangents[index] = scale * before * secant;
59
+ tangents[index + 1] = scale * after * secant;
60
+ }
61
+ }
62
+ return tangents;
63
+ }
64
+
65
+ function monotoneSegments(points: ReadonlyArray<ChartPoint>): string {
66
+ const tangents = monotoneTangents(points);
67
+ const segments: string[] = [];
68
+ for (let index = 0; index < points.length - 1; index += 1) {
69
+ const from = points[index];
70
+ const to = points[index + 1];
71
+ const third = (to.x - from.x) / 3;
72
+ segments.push(
73
+ `C${point(from.x + third, from.y + tangents[index] * third)} ${point(
74
+ to.x - third,
75
+ to.y - tangents[index + 1] * third,
76
+ )} ${point(to.x, to.y)}`,
77
+ );
78
+ }
79
+ return segments.join("");
80
+ }
81
+
82
+ /** Midpoint step: hold the value, jump halfway between the two categories. */
83
+ function stepSegments(points: ReadonlyArray<ChartPoint>): string {
84
+ const segments: string[] = [];
85
+ for (let index = 0; index < points.length - 1; index += 1) {
86
+ const from = points[index];
87
+ const to = points[index + 1];
88
+ const middle = (from.x + to.x) / 2;
89
+ segments.push(
90
+ `L${point(middle, from.y)}L${point(middle, to.y)}L${point(to.x, to.y)}`,
91
+ );
92
+ }
93
+ return segments.join("");
94
+ }
95
+
96
+ function linearSegments(points: ReadonlyArray<ChartPoint>): string {
97
+ return points
98
+ .slice(1)
99
+ .map((current) => `L${point(current.x, current.y)}`)
100
+ .join("");
101
+ }
102
+
103
+ const segmentBuilders: Record<
104
+ ChartCurve,
105
+ (points: ReadonlyArray<ChartPoint>) => string
106
+ > = {
107
+ linear: linearSegments,
108
+ monotone: monotoneSegments,
109
+ step: stepSegments,
110
+ };
111
+
112
+ /**
113
+ * An open path through `points`. Returns `""` for fewer than two points, which
114
+ * renders as nothing rather than as a stray dot.
115
+ */
116
+ export function linePath(
117
+ points: ReadonlyArray<ChartPoint>,
118
+ curve: ChartCurve = "linear",
119
+ ): string {
120
+ if (points.length < 2) {
121
+ return "";
122
+ }
123
+ return `M${point(points[0].x, points[0].y)}${segmentBuilders[curve](points)}`;
124
+ }
125
+
126
+ /**
127
+ * A closed band between `points` and a flat `baseline` y. Built from the same
128
+ * curve as `linePath`, so an area and its outline never disagree.
129
+ */
130
+ export function areaPath(
131
+ points: ReadonlyArray<ChartPoint>,
132
+ baseline: number,
133
+ curve: ChartCurve = "linear",
134
+ ): string {
135
+ const top = linePath(points, curve);
136
+ if (top === "") {
137
+ return "";
138
+ }
139
+ const last = points[points.length - 1];
140
+ const first = points[0];
141
+ return `${top}L${point(last.x, baseline)}L${point(first.x, baseline)}Z`;
142
+ }
143
+
144
+ /** A closed polygon — the radar mark's outline. */
145
+ export function polygonPath(points: ReadonlyArray<ChartPoint>): string {
146
+ if (points.length === 0) {
147
+ return "";
148
+ }
149
+ const [head, ...rest] = points;
150
+ const tail = rest
151
+ .map((current) => `L${point(current.x, current.y)}`)
152
+ .join("");
153
+ return `M${point(head.x, head.y)}${tail}Z`;
154
+ }
155
+
156
+ function cornerRadii(
157
+ radius: ChartCornerRadius,
158
+ width: number,
159
+ height: number,
160
+ ): readonly [number, number, number, number] {
161
+ const raw: readonly [number, number, number, number] =
162
+ typeof radius === "number" ? [radius, radius, radius, radius] : radius;
163
+ const limit = Math.min(Math.abs(width), Math.abs(height)) / 2;
164
+ return [
165
+ Math.max(0, Math.min(raw[0], limit)),
166
+ Math.max(0, Math.min(raw[1], limit)),
167
+ Math.max(0, Math.min(raw[2], limit)),
168
+ Math.max(0, Math.min(raw[3], limit)),
169
+ ];
170
+ }
171
+
172
+ export interface RoundedBarOptions {
173
+ readonly x: number;
174
+ readonly y: number;
175
+ readonly width: number;
176
+ readonly height: number;
177
+ /** One radius, or `[topLeft, topRight, bottomRight, bottomLeft]`. */
178
+ readonly radius?: ChartCornerRadius;
179
+ }
180
+
181
+ /**
182
+ * A rectangle with independently rounded corners — stacked bars round only the
183
+ * outer end of the stack, so the inner joins stay square.
184
+ */
185
+ export function roundedBarPath(options: RoundedBarOptions): string {
186
+ const { x, y, width, height, radius = 0 } = options;
187
+ if (width <= 0 || height <= 0) {
188
+ return "";
189
+ }
190
+ const [topLeft, topRight, bottomRight, bottomLeft] = cornerRadii(
191
+ radius,
192
+ width,
193
+ height,
194
+ );
195
+ const right = x + width;
196
+ const bottom = y + height;
197
+ return [
198
+ `M${point(x + topLeft, y)}`,
199
+ `H${round(right - topRight)}`,
200
+ topRight > 0
201
+ ? `A${round(topRight)},${round(topRight)} 0 0 1 ${point(right, y + topRight)}`
202
+ : "",
203
+ `V${round(bottom - bottomRight)}`,
204
+ bottomRight > 0
205
+ ? `A${round(bottomRight)},${round(bottomRight)} 0 0 1 ${point(right - bottomRight, bottom)}`
206
+ : "",
207
+ `H${round(x + bottomLeft)}`,
208
+ bottomLeft > 0
209
+ ? `A${round(bottomLeft)},${round(bottomLeft)} 0 0 1 ${point(x, bottom - bottomLeft)}`
210
+ : "",
211
+ `V${round(y + topLeft)}`,
212
+ topLeft > 0
213
+ ? `A${round(topLeft)},${round(topLeft)} 0 0 1 ${point(x + topLeft, y)}`
214
+ : "",
215
+ "Z",
216
+ ].join("");
217
+ }
218
+
219
+ export interface ArcOptions {
220
+ readonly cx: number;
221
+ readonly cy: number;
222
+ readonly innerRadius: number;
223
+ readonly outerRadius: number;
224
+ /** Degrees clockwise from twelve o'clock. */
225
+ readonly startAngle: number;
226
+ readonly endAngle: number;
227
+ }
228
+
229
+ /**
230
+ * A pie wedge (`innerRadius` 0) or a donut segment. A sweep of 360 degrees or
231
+ * more is drawn as two half arcs, because a single arc back to its own start
232
+ * point renders as nothing at all.
233
+ */
234
+ export function arcPath(options: ArcOptions): string {
235
+ const { cx, cy, innerRadius, outerRadius, startAngle, endAngle } = options;
236
+ const sweep = endAngle - startAngle;
237
+ if (sweep <= 0 || outerRadius <= 0) {
238
+ return "";
239
+ }
240
+ if (sweep >= 360) {
241
+ const half = startAngle + 180;
242
+ return `${arcPath({ ...options, endAngle: half })}${arcPath({
243
+ ...options,
244
+ startAngle: half,
245
+ endAngle: startAngle + 359.99,
246
+ })}`;
247
+ }
248
+
249
+ const largeArc = sweep > 180 ? 1 : 0;
250
+ const outerStart = polarToCartesian(cx, cy, outerRadius, startAngle);
251
+ const outerEnd = polarToCartesian(cx, cy, outerRadius, endAngle);
252
+
253
+ if (innerRadius <= 0) {
254
+ return [
255
+ `M${point(cx, cy)}`,
256
+ `L${point(outerStart.x, outerStart.y)}`,
257
+ `A${round(outerRadius)},${round(outerRadius)} 0 ${largeArc} 1 ${point(outerEnd.x, outerEnd.y)}`,
258
+ "Z",
259
+ ].join("");
260
+ }
261
+
262
+ const innerEnd = polarToCartesian(cx, cy, innerRadius, endAngle);
263
+ const innerStart = polarToCartesian(cx, cy, innerRadius, startAngle);
264
+ return [
265
+ `M${point(outerStart.x, outerStart.y)}`,
266
+ `A${round(outerRadius)},${round(outerRadius)} 0 ${largeArc} 1 ${point(outerEnd.x, outerEnd.y)}`,
267
+ `L${point(innerEnd.x, innerEnd.y)}`,
268
+ `A${round(innerRadius)},${round(innerRadius)} 0 ${largeArc} 0 ${point(innerStart.x, innerStart.y)}`,
269
+ "Z",
270
+ ].join("");
271
+ }
@@ -0,0 +1,138 @@
1
+ import type { ChartPoint } from "#/core/types.ts";
2
+
3
+ /**
4
+ * Polar helpers. Angles are **degrees, clockwise, zero at twelve o'clock** —
5
+ * the way a pie chart is described out loud — rather than the mathematical
6
+ * convention, so nothing in the components has to apply a mental offset.
7
+ */
8
+
9
+ const FULL_TURN = 360;
10
+ const DEGREES_TO_RADIANS = Math.PI / 180;
11
+
12
+ export function polarToCartesian(
13
+ cx: number,
14
+ cy: number,
15
+ radius: number,
16
+ angle: number,
17
+ ): ChartPoint {
18
+ const radians = angle * DEGREES_TO_RADIANS;
19
+ return {
20
+ x: cx + radius * Math.sin(radians),
21
+ y: cy - radius * Math.cos(radians),
22
+ };
23
+ }
24
+
25
+ export interface PolarFrame {
26
+ readonly cx: number;
27
+ readonly cy: number;
28
+ readonly radius: number;
29
+ }
30
+
31
+ /**
32
+ * Where a round chart sits inside the root: centred in the plotting area, and
33
+ * never wider than it. That is what makes the root's `margin` mean something
34
+ * on a round chart — reserve space at the bottom for a legend and the circle
35
+ * shrinks to leave it, rather than being drawn over it.
36
+ */
37
+ export function polarFrame(options: {
38
+ readonly innerWidth: number;
39
+ readonly innerHeight: number;
40
+ /** Pixels kept clear inside the plotting area, around the outer edge. */
41
+ readonly inset?: number;
42
+ }): PolarFrame {
43
+ const { innerWidth, innerHeight, inset = 0 } = options;
44
+ return {
45
+ cx: innerWidth / 2,
46
+ cy: innerHeight / 2,
47
+ radius: Math.max(0, Math.min(innerWidth, innerHeight) / 2 - inset),
48
+ };
49
+ }
50
+
51
+ export interface ChartSliceAngle {
52
+ readonly index: number;
53
+ readonly value: number;
54
+ /** Share of the total, in `[0, 1]`. Zero when every value is zero. */
55
+ readonly fraction: number;
56
+ readonly startAngle: number;
57
+ readonly endAngle: number;
58
+ }
59
+
60
+ export interface SliceAngleOptions {
61
+ readonly startAngle?: number;
62
+ readonly endAngle?: number;
63
+ /** Degrees of blank left between two neighbouring slices. */
64
+ readonly padAngle?: number;
65
+ }
66
+
67
+ /**
68
+ * Splits an angular span between values, proportionally. Negative values are
69
+ * treated as zero: a pie of signed numbers has no honest reading, and silently
70
+ * flipping a slice inside-out would be worse than showing nothing.
71
+ */
72
+ export function sliceAngles(
73
+ values: ReadonlyArray<number>,
74
+ options: SliceAngleOptions = {},
75
+ ): ReadonlyArray<ChartSliceAngle> {
76
+ const { startAngle = 0, endAngle = FULL_TURN, padAngle = 0 } = options;
77
+ const positives = values.map((value) =>
78
+ Number.isFinite(value) && value > 0 ? value : 0,
79
+ );
80
+ const total = positives.reduce((sum, value) => sum + value, 0);
81
+ const span = endAngle - startAngle;
82
+ const available = Math.max(0, span - padAngle * positives.length);
83
+
84
+ let cursor = startAngle;
85
+ return positives.map((value, index) => {
86
+ const fraction = total === 0 ? 0 : value / total;
87
+ const sweep = available * fraction;
88
+ const slice: ChartSliceAngle = {
89
+ index,
90
+ value: values[index],
91
+ fraction,
92
+ startAngle: cursor + padAngle / 2,
93
+ endAngle: cursor + padAngle / 2 + sweep,
94
+ };
95
+ cursor += sweep + padAngle;
96
+ return slice;
97
+ });
98
+ }
99
+
100
+ /** The angle of the `index`-th of `count` axes, spread over a full turn. */
101
+ export function axisAngle(
102
+ index: number,
103
+ count: number,
104
+ startAngle = 0,
105
+ ): number {
106
+ return count === 0 ? startAngle : startAngle + (FULL_TURN * index) / count;
107
+ }
108
+
109
+ /**
110
+ * The vertices of one radar ring: `count` equally spaced points at `radius`.
111
+ * Also used for the polar grid, whose rings are polygons rather than circles so
112
+ * they line up with the data they frame.
113
+ */
114
+ export function ringPoints(
115
+ cx: number,
116
+ cy: number,
117
+ radius: number,
118
+ count: number,
119
+ startAngle = 0,
120
+ ): ReadonlyArray<ChartPoint> {
121
+ return Array.from({ length: count }, (_unused, index) =>
122
+ polarToCartesian(cx, cy, radius, axisAngle(index, count, startAngle)),
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Radii for `count` evenly spaced grid rings inside `radius`, outermost first.
128
+ * The centre is never emitted — a ring of radius zero is a dot, not a ring.
129
+ */
130
+ export function ringRadii(
131
+ radius: number,
132
+ count: number,
133
+ ): ReadonlyArray<number> {
134
+ return Array.from(
135
+ { length: Math.max(0, count) },
136
+ (_unused, index) => (radius * (count - index)) / count,
137
+ );
138
+ }
@@ -0,0 +1,167 @@
1
+ import { niceTicks } from "#/core/ticks.ts";
2
+ import type {
3
+ ChartDiscreteScale,
4
+ ChartInterval,
5
+ ChartLinearScale,
6
+ } from "#/core/types.ts";
7
+
8
+ /**
9
+ * The three scales a cartesian chart needs, written out rather than pulled in.
10
+ * Each one is a plain object of closures: no state, no mutation, safe to build
11
+ * during render and cheap enough to build on every render.
12
+ */
13
+
14
+ const DEFAULT_BAND_PADDING_INNER = 0.2;
15
+ const DEFAULT_BAND_PADDING_OUTER = 0.1;
16
+
17
+ function clamp(value: number, low: number, high: number): number {
18
+ return Math.min(Math.max(value, low), high);
19
+ }
20
+
21
+ export interface LinearScaleOptions {
22
+ readonly domain: ChartInterval;
23
+ readonly range: ChartInterval;
24
+ }
25
+
26
+ /**
27
+ * Maps a numeric domain onto a pixel range, linearly. A zero-width domain
28
+ * collapses to the middle of the range, so a flat series draws a centred line
29
+ * instead of dividing by zero.
30
+ */
31
+ export function linearScale(options: LinearScaleOptions): ChartLinearScale {
32
+ const [domainStart, domainEnd] = options.domain;
33
+ const [rangeStart, rangeEnd] = options.range;
34
+ const domainSpan = domainEnd - domainStart;
35
+ const rangeSpan = rangeEnd - rangeStart;
36
+
37
+ const scale = (value: number): number =>
38
+ domainSpan === 0
39
+ ? rangeStart + rangeSpan / 2
40
+ : rangeStart + ((value - domainStart) / domainSpan) * rangeSpan;
41
+
42
+ const invert = (pixel: number): number =>
43
+ rangeSpan === 0
44
+ ? domainStart
45
+ : domainStart + ((pixel - rangeStart) / rangeSpan) * domainSpan;
46
+
47
+ return {
48
+ kind: "linear",
49
+ domain: options.domain,
50
+ range: options.range,
51
+ bandwidth: 0,
52
+ scale,
53
+ invert,
54
+ ticks: (count) =>
55
+ niceTicks(
56
+ Math.min(domainStart, domainEnd),
57
+ Math.max(domainStart, domainEnd),
58
+ count,
59
+ ),
60
+ };
61
+ }
62
+
63
+ export interface BandScaleOptions {
64
+ readonly domain: ReadonlyArray<string>;
65
+ readonly range: ChartInterval;
66
+ /** Share of a step left empty between two bands. 0 = touching bars. */
67
+ readonly paddingInner?: number;
68
+ /** Share of a step left empty before the first and after the last band. */
69
+ readonly paddingOuter?: number;
70
+ }
71
+
72
+ /**
73
+ * Maps categories onto equal slots of `bandwidth` pixels — the scale bars sit
74
+ * on. Categories are looked up by value, so duplicate category labels collapse
75
+ * onto the first slot (which is what a duplicated x value means anyway).
76
+ */
77
+ export function bandScale(options: BandScaleOptions): ChartDiscreteScale {
78
+ const {
79
+ domain,
80
+ range,
81
+ paddingInner = DEFAULT_BAND_PADDING_INNER,
82
+ paddingOuter = DEFAULT_BAND_PADDING_OUTER,
83
+ } = options;
84
+ const [rangeStart, rangeEnd] = range;
85
+ const count = domain.length;
86
+ const step =
87
+ (rangeEnd - rangeStart) /
88
+ Math.max(1, count - paddingInner + paddingOuter * 2);
89
+ const bandwidth = step * (1 - paddingInner);
90
+ const origin = rangeStart + step * paddingOuter;
91
+ const indexByValue = new Map<string, number>();
92
+ for (const [index, value] of domain.entries()) {
93
+ if (!indexByValue.has(value)) {
94
+ indexByValue.set(value, index);
95
+ }
96
+ }
97
+
98
+ const scale = (value: string): number =>
99
+ origin + step * (indexByValue.get(value) ?? 0);
100
+
101
+ return {
102
+ kind: "band",
103
+ domain,
104
+ range,
105
+ bandwidth,
106
+ step,
107
+ scale,
108
+ center: (value) => scale(value) + bandwidth / 2,
109
+ invert: (pixel) =>
110
+ count === 0
111
+ ? 0
112
+ : clamp(Math.floor((pixel - origin) / step), 0, count - 1),
113
+ ticks: () => domain,
114
+ };
115
+ }
116
+
117
+ export interface PointScaleOptions {
118
+ readonly domain: ReadonlyArray<string>;
119
+ readonly range: ChartInterval;
120
+ /** Share of a step left empty at each end. 0 pins the ends to the edges. */
121
+ readonly padding?: number;
122
+ }
123
+
124
+ /**
125
+ * Maps categories onto evenly spaced positions with no width — the scale lines
126
+ * and areas sit on, where the first and last points touch the plot edges.
127
+ */
128
+ export function pointScale(options: PointScaleOptions): ChartDiscreteScale {
129
+ const { domain, range, padding = 0 } = options;
130
+ const [rangeStart, rangeEnd] = range;
131
+ const count = domain.length;
132
+ const step = (rangeEnd - rangeStart) / Math.max(1, count - 1 + padding * 2);
133
+ const origin = rangeStart + step * padding;
134
+ const indexByValue = new Map<string, number>();
135
+ for (const [index, value] of domain.entries()) {
136
+ if (!indexByValue.has(value)) {
137
+ indexByValue.set(value, index);
138
+ }
139
+ }
140
+
141
+ const scale = (value: string): number =>
142
+ count <= 1
143
+ ? rangeStart + (rangeEnd - rangeStart) / 2
144
+ : origin + step * (indexByValue.get(value) ?? 0);
145
+
146
+ return {
147
+ kind: "point",
148
+ domain,
149
+ range,
150
+ bandwidth: 0,
151
+ step,
152
+ scale,
153
+ center: scale,
154
+ invert: (pixel) =>
155
+ count === 0
156
+ ? 0
157
+ : clamp(Math.round((pixel - origin) / step), 0, count - 1),
158
+ ticks: () => domain,
159
+ };
160
+ }
161
+
162
+ /** Type guard so marks can branch on "is this axis continuous?". */
163
+ export function isLinearScale(
164
+ scale: ChartLinearScale | ChartDiscreteScale,
165
+ ): scale is ChartLinearScale {
166
+ return scale.kind === "linear";
167
+ }
@@ -0,0 +1,70 @@
1
+ import { readNumber } from "#/core/chart-model.ts";
2
+ import type {
3
+ ChartDatum,
4
+ ChartDiscreteScale,
5
+ ChartLinearScale,
6
+ ChartOrientation,
7
+ ChartPoint,
8
+ } from "#/core/types.ts";
9
+
10
+ /**
11
+ * Point geometry shared by the line, area and scatter marks: one place that
12
+ * knows a category runs along x when the chart is upright and along y when it
13
+ * is on its side.
14
+ */
15
+
16
+ export interface SeriesPointsOptions {
17
+ readonly data: ReadonlyArray<ChartDatum>;
18
+ readonly categories: ReadonlyArray<string>;
19
+ readonly categoryScale: ChartDiscreteScale;
20
+ readonly valueScale: ChartLinearScale;
21
+ readonly orientation: ChartOrientation;
22
+ readonly key: string;
23
+ /**
24
+ * Value each point sits on top of, per datum — the running total under a
25
+ * stacked series. Defaults to zero, i.e. straight off the baseline.
26
+ */
27
+ readonly base?: ReadonlyArray<number>;
28
+ }
29
+
30
+ export function seriesPoints(
31
+ options: SeriesPointsOptions,
32
+ ): ReadonlyArray<ChartPoint> {
33
+ const {
34
+ data,
35
+ categories,
36
+ categoryScale,
37
+ valueScale,
38
+ orientation,
39
+ key,
40
+ base,
41
+ } = options;
42
+
43
+ return data.map((datum, index) => {
44
+ const along = categoryScale.center(categories[index] ?? String(index));
45
+ const value = readNumber(datum, key) + (base?.[index] ?? 0);
46
+ const across = valueScale.scale(value);
47
+ return orientation === "vertical"
48
+ ? { x: along, y: across }
49
+ : { x: across, y: along };
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Running totals under each series of a stack, in draw order. `stackTops[key]`
55
+ * is what the next series up should sit on.
56
+ */
57
+ export function stackBases(
58
+ data: ReadonlyArray<ChartDatum>,
59
+ keys: ReadonlyArray<string>,
60
+ ): ReadonlyMap<string, ReadonlyArray<number>> {
61
+ const bases = new Map<string, ReadonlyArray<number>>();
62
+ const running = data.map(() => 0);
63
+ for (const key of keys) {
64
+ bases.set(key, [...running]);
65
+ for (const [index, datum] of data.entries()) {
66
+ running[index] += readNumber(datum, key);
67
+ }
68
+ }
69
+ return bases;
70
+ }