@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round tick values for a continuous axis. The step is always 1, 2, 5 or 10
|
|
3
|
+
* times a power of ten, which is what makes an axis readable: 0/25/50/75/100
|
|
4
|
+
* rather than 0/23.4/46.8.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Steps the eye reads as round, in units of the current power of ten. */
|
|
8
|
+
const NICE_FACTORS = [
|
|
9
|
+
{ threshold: Math.SQRT2, factor: 1 },
|
|
10
|
+
{ threshold: Math.sqrt(10), factor: 2 },
|
|
11
|
+
{ threshold: Math.sqrt(50), factor: 5 },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TICK_COUNT = 5;
|
|
15
|
+
/** Beyond this many decimals the rounding helper stops fighting IEEE noise. */
|
|
16
|
+
const MAX_TICK_PRECISION = 12;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drops the floating-point dust `i * step` leaves behind (0.30000000000000004)
|
|
20
|
+
* by rounding to the number of decimals the step itself carries.
|
|
21
|
+
*/
|
|
22
|
+
function roundToStep(value: number, step: number): number {
|
|
23
|
+
if (step === 0 || !Number.isFinite(step)) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
const decimals = Math.max(0, -Math.floor(Math.log10(Math.abs(step))));
|
|
27
|
+
return Number(value.toFixed(Math.min(decimals, MAX_TICK_PRECISION)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The round step closest to covering `[min, max]` in about `count` intervals.
|
|
32
|
+
* Returns `0` for a degenerate span, which callers read as "no ticks".
|
|
33
|
+
*/
|
|
34
|
+
export function tickStep(
|
|
35
|
+
min: number,
|
|
36
|
+
max: number,
|
|
37
|
+
count: number = DEFAULT_TICK_COUNT,
|
|
38
|
+
): number {
|
|
39
|
+
const span = max - min;
|
|
40
|
+
if (!Number.isFinite(span) || span <= 0 || count <= 0) {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const rough = span / count;
|
|
44
|
+
const power = Math.floor(Math.log10(rough));
|
|
45
|
+
const error = rough / 10 ** power;
|
|
46
|
+
const match = NICE_FACTORS.find((candidate) => error < candidate.threshold);
|
|
47
|
+
return (match?.factor ?? 10) * 10 ** power;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Round values strictly inside `[min, max]`. An axis that has been through
|
|
52
|
+
* `niceDomain` first gets ticks on both bounds; a raw domain does not, on
|
|
53
|
+
* purpose — a tick outside the data would be a lie.
|
|
54
|
+
*/
|
|
55
|
+
export function niceTicks(
|
|
56
|
+
min: number,
|
|
57
|
+
max: number,
|
|
58
|
+
count: number = DEFAULT_TICK_COUNT,
|
|
59
|
+
): ReadonlyArray<number> {
|
|
60
|
+
if (min === max) {
|
|
61
|
+
return [min];
|
|
62
|
+
}
|
|
63
|
+
const step = tickStep(min, max, count);
|
|
64
|
+
if (step === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const first = Math.ceil(min / step);
|
|
68
|
+
const last = Math.floor(max / step);
|
|
69
|
+
const ticks: number[] = [];
|
|
70
|
+
for (let index = first; index <= last; index += 1) {
|
|
71
|
+
ticks.push(roundToStep(index * step, step));
|
|
72
|
+
}
|
|
73
|
+
return ticks;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Widens `[min, max]` outwards to the nearest round step, so the axis starts
|
|
78
|
+
* and ends on a tick instead of mid-air.
|
|
79
|
+
*/
|
|
80
|
+
export function niceDomain(
|
|
81
|
+
min: number,
|
|
82
|
+
max: number,
|
|
83
|
+
count: number = DEFAULT_TICK_COUNT,
|
|
84
|
+
): readonly [number, number] {
|
|
85
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
86
|
+
return [0, 1];
|
|
87
|
+
}
|
|
88
|
+
if (min === max) {
|
|
89
|
+
return min === 0 ? [0, 1] : [Math.min(0, min), Math.max(0, max)];
|
|
90
|
+
}
|
|
91
|
+
const step = tickStep(min, max, count);
|
|
92
|
+
if (step === 0) {
|
|
93
|
+
return [min, max];
|
|
94
|
+
}
|
|
95
|
+
return [
|
|
96
|
+
roundToStep(Math.floor(min / step) * step, step),
|
|
97
|
+
roundToStep(Math.ceil(max / step) * step, step),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The `[min, max]` of a set of values, always including zero so bars are
|
|
103
|
+
* measured from a real baseline rather than from an arbitrary floor.
|
|
104
|
+
*/
|
|
105
|
+
export function extentFromZero(
|
|
106
|
+
values: ReadonlyArray<number>,
|
|
107
|
+
): readonly [number, number] {
|
|
108
|
+
let min = 0;
|
|
109
|
+
let max = 0;
|
|
110
|
+
for (const value of values) {
|
|
111
|
+
if (!Number.isFinite(value)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
min = Math.min(min, value);
|
|
115
|
+
max = Math.max(max, value);
|
|
116
|
+
}
|
|
117
|
+
return [min, max];
|
|
118
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary every other core module and component speaks. Deliberately
|
|
3
|
+
* plain: no class instances, no branded types, nothing that would make a scale
|
|
4
|
+
* awkward to build in a test or to serialize in a snapshot.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A row of chart data. Marks read named fields off it by key. */
|
|
8
|
+
export type ChartDatum = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
/** A `[start, end]` pair. Not sorted: a range may run backwards (y axes do). */
|
|
11
|
+
export type ChartInterval = readonly [number, number];
|
|
12
|
+
|
|
13
|
+
/** Inner padding between the SVG edge and the plotting area. */
|
|
14
|
+
export interface ChartMargin {
|
|
15
|
+
readonly top: number;
|
|
16
|
+
readonly right: number;
|
|
17
|
+
readonly bottom: number;
|
|
18
|
+
readonly left: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A continuous scale: numbers in, pixels out, and back again. `ticks` returns
|
|
23
|
+
* round values inside the domain, never the raw domain bounds.
|
|
24
|
+
*/
|
|
25
|
+
export interface ChartLinearScale {
|
|
26
|
+
readonly kind: "linear";
|
|
27
|
+
readonly domain: ChartInterval;
|
|
28
|
+
readonly range: ChartInterval;
|
|
29
|
+
readonly scale: (value: number) => number;
|
|
30
|
+
readonly invert: (pixel: number) => number;
|
|
31
|
+
readonly ticks: (count?: number) => ReadonlyArray<number>;
|
|
32
|
+
/** Continuous scales have no band, but marks ask uniformly. */
|
|
33
|
+
readonly bandwidth: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A discrete scale: one category in, the pixel offset of its slot out.
|
|
38
|
+
* `bandwidth` is the slot width for band scales and `0` for point scales.
|
|
39
|
+
*/
|
|
40
|
+
export interface ChartDiscreteScale {
|
|
41
|
+
readonly kind: "band" | "point";
|
|
42
|
+
readonly domain: ReadonlyArray<string>;
|
|
43
|
+
readonly range: ChartInterval;
|
|
44
|
+
readonly scale: (value: string) => number;
|
|
45
|
+
/** Centre of a category's slot — where a point or tick belongs. */
|
|
46
|
+
readonly center: (value: string) => number;
|
|
47
|
+
/** Nearest category index for a pixel offset. Used by pointer scrubbing. */
|
|
48
|
+
readonly invert: (pixel: number) => number;
|
|
49
|
+
readonly ticks: () => ReadonlyArray<string>;
|
|
50
|
+
readonly bandwidth: number;
|
|
51
|
+
readonly step: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ChartScale = ChartLinearScale | ChartDiscreteScale;
|
|
55
|
+
|
|
56
|
+
/** Whether the category axis runs along the bottom (vertical bars) or the side. */
|
|
57
|
+
export type ChartOrientation = "vertical" | "horizontal";
|
|
58
|
+
|
|
59
|
+
/** How a line or area joins its points. */
|
|
60
|
+
export type ChartCurve = "linear" | "monotone" | "step";
|
|
61
|
+
|
|
62
|
+
/** A point in plotting-area pixels. */
|
|
63
|
+
export interface ChartPoint {
|
|
64
|
+
readonly x: number;
|
|
65
|
+
readonly y: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Per-corner radii for a rounded bar, clockwise from the top-left. */
|
|
69
|
+
export type ChartCornerRadius =
|
|
70
|
+
| number
|
|
71
|
+
| readonly [number, number, number, number];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Measures the element the returned `ref` is attached to. Charts are drawn in
|
|
5
|
+
* pixels, so every scale needs a real width and height before it can place
|
|
6
|
+
* anything.
|
|
7
|
+
*
|
|
8
|
+
* Server-safe: nothing is read from the document during render, and the first
|
|
9
|
+
* paint uses `initial` so a chart rendered on the server still has sensible
|
|
10
|
+
* geometry. The real size arrives on the first observer callback.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_DIMENSION = { width: 320, height: 200 } as const;
|
|
14
|
+
|
|
15
|
+
export interface ChartDimension {
|
|
16
|
+
readonly width: number;
|
|
17
|
+
readonly height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChartDimensionsResult extends ChartDimension {
|
|
21
|
+
readonly ref: (node: HTMLDivElement | null) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useChartDimensions(
|
|
25
|
+
initial: ChartDimension = DEFAULT_DIMENSION,
|
|
26
|
+
): ChartDimensionsResult {
|
|
27
|
+
const [node, setNode] = React.useState<HTMLDivElement | null>(null);
|
|
28
|
+
const [dimension, setDimension] = React.useState<ChartDimension>(initial);
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
if (node === null || typeof ResizeObserver === "undefined") {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const observer = new ResizeObserver((entries) => {
|
|
35
|
+
const box = entries[0]?.contentRect;
|
|
36
|
+
if (box === undefined || box.width === 0 || box.height === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setDimension((current) =>
|
|
40
|
+
current.width === box.width && current.height === box.height
|
|
41
|
+
? current
|
|
42
|
+
: { width: box.width, height: box.height },
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
observer.observe(node);
|
|
46
|
+
return () => observer.disconnect();
|
|
47
|
+
}, [node]);
|
|
48
|
+
|
|
49
|
+
return { ref: setNode, width: dimension.width, height: dimension.height };
|
|
50
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ChartDiscreteScale,
|
|
5
|
+
ChartMargin,
|
|
6
|
+
ChartOrientation,
|
|
7
|
+
} from "#/core/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turns pointer, touch and keyboard input over the plotting area into a single
|
|
11
|
+
* "which datum is the reader looking at" index. One state, three input methods:
|
|
12
|
+
* scrubbing with a finger, hovering with a mouse and arrowing with a keyboard
|
|
13
|
+
* all land on the same active datum, so the tooltip and cursor never have to
|
|
14
|
+
* care which one it was.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where the reader is, in plotting-area pixels. */
|
|
18
|
+
export interface ChartActive {
|
|
19
|
+
readonly index: number;
|
|
20
|
+
readonly x: number;
|
|
21
|
+
readonly y: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChartPointerOptions {
|
|
25
|
+
readonly enabled: boolean;
|
|
26
|
+
readonly count: number;
|
|
27
|
+
readonly width: number;
|
|
28
|
+
readonly height: number;
|
|
29
|
+
readonly margin: ChartMargin;
|
|
30
|
+
readonly orientation: ChartOrientation;
|
|
31
|
+
readonly categoryScale: ChartDiscreteScale;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChartPointerHandlers {
|
|
35
|
+
readonly onPointerMove: React.PointerEventHandler<SVGSVGElement>;
|
|
36
|
+
readonly onPointerDown: React.PointerEventHandler<SVGSVGElement>;
|
|
37
|
+
readonly onPointerLeave: React.PointerEventHandler<SVGSVGElement>;
|
|
38
|
+
readonly onKeyDown: React.KeyboardEventHandler<SVGSVGElement>;
|
|
39
|
+
readonly onBlur: React.FocusEventHandler<SVGSVGElement>;
|
|
40
|
+
readonly tabIndex: number | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChartPointerResult {
|
|
44
|
+
readonly active: ChartActive | null;
|
|
45
|
+
readonly setActive: React.Dispatch<React.SetStateAction<ChartActive | null>>;
|
|
46
|
+
readonly handlers: ChartPointerHandlers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keyboard steps, keyed by the axis the categories run along. */
|
|
50
|
+
const KEY_STEPS: Record<ChartOrientation, Record<string, number>> = {
|
|
51
|
+
vertical: { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 },
|
|
52
|
+
horizontal: { ArrowDown: 1, ArrowUp: -1, ArrowRight: 1, ArrowLeft: -1 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Converts a pointer event into plotting-area coordinates, correcting for any
|
|
57
|
+
* scaling between the SVG's user units and its rendered box.
|
|
58
|
+
*/
|
|
59
|
+
function plotPoint(
|
|
60
|
+
event: React.PointerEvent<SVGSVGElement>,
|
|
61
|
+
options: ChartPointerOptions,
|
|
62
|
+
): { readonly x: number; readonly y: number } {
|
|
63
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
64
|
+
const scaleX = box.width === 0 ? 1 : options.width / box.width;
|
|
65
|
+
const scaleY = box.height === 0 ? 1 : options.height / box.height;
|
|
66
|
+
return {
|
|
67
|
+
x: (event.clientX - box.left) * scaleX - options.margin.left,
|
|
68
|
+
y: (event.clientY - box.top) * scaleY - options.margin.top,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function activeFromPoint(
|
|
73
|
+
point: { readonly x: number; readonly y: number },
|
|
74
|
+
options: ChartPointerOptions,
|
|
75
|
+
): ChartActive {
|
|
76
|
+
const along = options.orientation === "vertical" ? point.x : point.y;
|
|
77
|
+
return { index: options.categoryScale.invert(along), ...point };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Places the active marker on a datum's own slot, for keyboard navigation. */
|
|
81
|
+
function activeFromIndex(
|
|
82
|
+
index: number,
|
|
83
|
+
options: ChartPointerOptions,
|
|
84
|
+
): ChartActive {
|
|
85
|
+
const category = options.categoryScale.domain[index] ?? "";
|
|
86
|
+
const center = options.categoryScale.center(category);
|
|
87
|
+
return options.orientation === "vertical"
|
|
88
|
+
? { index, x: center, y: 0 }
|
|
89
|
+
: { index, x: 0, y: center };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useChartPointer(
|
|
93
|
+
options: ChartPointerOptions,
|
|
94
|
+
): ChartPointerResult {
|
|
95
|
+
const [active, setActive] = React.useState<ChartActive | null>(null);
|
|
96
|
+
const { enabled, count, orientation } = options;
|
|
97
|
+
|
|
98
|
+
// Built fresh every render rather than memoized: the handlers close over
|
|
99
|
+
// scales that are themselves rebuilt whenever the geometry changes, and they
|
|
100
|
+
// only ever land on a DOM element, where a stable identity buys nothing.
|
|
101
|
+
const track = (event: React.PointerEvent<SVGSVGElement>) => {
|
|
102
|
+
if (!enabled || count === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setActive(activeFromPoint(plotPoint(event, options), options));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const stepFrom = (delta: number): number => {
|
|
109
|
+
const from = active?.index ?? null;
|
|
110
|
+
if (from === null) {
|
|
111
|
+
return delta > 0 ? 0 : count - 1;
|
|
112
|
+
}
|
|
113
|
+
return Math.min(Math.max(from + delta, 0), count - 1);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const targetIndexFor = (key: string): number | null => {
|
|
117
|
+
if (key === "Home") {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
if (key === "End") {
|
|
121
|
+
return count - 1;
|
|
122
|
+
}
|
|
123
|
+
const delta = KEY_STEPS[orientation][key];
|
|
124
|
+
return delta === undefined ? null : stepFrom(delta);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handlers: ChartPointerHandlers = {
|
|
128
|
+
tabIndex: enabled && count > 0 ? 0 : undefined,
|
|
129
|
+
onPointerMove: track,
|
|
130
|
+
// Touch fires no hover: the first contact has to select outright.
|
|
131
|
+
onPointerDown: track,
|
|
132
|
+
onPointerLeave: () => setActive(null),
|
|
133
|
+
onBlur: () => setActive(null),
|
|
134
|
+
onKeyDown: (event) => {
|
|
135
|
+
if (!enabled || count === 0) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (event.key === "Escape") {
|
|
139
|
+
setActive(null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const target = targetIndexFor(event.key);
|
|
143
|
+
if (target === null) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
setActive(activeFromIndex(target, options));
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { active, setActive, handlers };
|
|
152
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind must scan this package or none of its classes are generated — the
|
|
3
|
+
* components ship as .tsx source, not as compiled CSS.
|
|
4
|
+
*
|
|
5
|
+
* `@source` resolves relative to the file that declares it, so this works
|
|
6
|
+
* wherever the package ends up: node_modules in an app, a symlink in a
|
|
7
|
+
* workspace. Consumers import this file and add nothing of their own.
|
|
8
|
+
*/
|
|
9
|
+
@source "./";
|
|
10
|
+
@source not "./**/*.test.*";
|