@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.
- package/package.json +57 -0
- package/src/components/chart-area.tsx +124 -0
- package/src/components/chart-bars.tsx +105 -0
- package/src/components/chart-cursor.tsx +105 -0
- package/src/components/chart-data-table.tsx +55 -0
- package/src/components/chart-empty.tsx +22 -0
- package/src/components/chart-grid.tsx +81 -0
- package/src/components/chart-label-list.tsx +178 -0
- package/src/components/chart-legend.tsx +106 -0
- package/src/components/chart-line.tsx +100 -0
- package/src/components/chart-pie.tsx +130 -0
- package/src/components/chart-points.tsx +94 -0
- package/src/components/chart-polar-angle-axis.tsx +76 -0
- package/src/components/chart-polar-grid.tsx +64 -0
- package/src/components/chart-radar.tsx +103 -0
- package/src/components/chart-radial-bar.tsx +122 -0
- package/src/components/chart-reference-line.tsx +60 -0
- package/src/components/chart-root.tsx +188 -0
- package/src/components/chart-skeleton.tsx +45 -0
- package/src/components/chart-slice.tsx +59 -0
- package/src/components/chart-style.tsx +78 -0
- package/src/components/chart-tooltip.tsx +205 -0
- package/src/components/chart-x-axis.tsx +94 -0
- package/src/components/chart-y-axis.tsx +90 -0
- package/src/components/chart.tsx +146 -0
- package/src/context/chart-context.tsx +70 -0
- package/src/core/axis.ts +53 -0
- package/src/core/bars.ts +160 -0
- package/src/core/chart-model.ts +156 -0
- package/src/core/config.ts +68 -0
- package/src/core/format.ts +86 -0
- package/src/core/geometry.ts +271 -0
- package/src/core/polar.ts +138 -0
- package/src/core/scales.ts +167 -0
- package/src/core/series.ts +70 -0
- package/src/core/ticks.ts +118 -0
- package/src/core/types.ts +71 -0
- package/src/hooks/use-chart-dimensions.ts +50 -0
- package/src/hooks/use-chart-pointer.ts +152 -0
- 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
|
+
}
|