@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
package/src/core/axis.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isLinearScale } from "#/core/scales.ts";
|
|
2
|
+
import type { ChartScale } from "#/core/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where an axis puts its ticks. Works off either kind of scale so `XAxis` and
|
|
6
|
+
* `YAxis` are the same component twice, once per direction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface AxisTick {
|
|
10
|
+
/** The domain value, before formatting. */
|
|
11
|
+
readonly value: number | string;
|
|
12
|
+
/** Pixel offset along the axis. */
|
|
13
|
+
readonly offset: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AxisTickOptions {
|
|
17
|
+
/** Requested tick count. Continuous scales only; ignored by categories. */
|
|
18
|
+
readonly count?: number;
|
|
19
|
+
/** Minimum pixels between two rendered labels. Extra ticks are dropped. */
|
|
20
|
+
readonly minTickGap?: number;
|
|
21
|
+
/** Length of the axis in pixels, used to apply `minTickGap`. */
|
|
22
|
+
readonly available?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TICK_COUNT = 5;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keeps every `stride`-th tick. The first tick is always kept, and dropping is
|
|
29
|
+
* uniform, so labels stay evenly spaced instead of bunching at one end.
|
|
30
|
+
*/
|
|
31
|
+
function thin(ticks: ReadonlyArray<AxisTick>, stride: number): AxisTick[] {
|
|
32
|
+
return ticks.filter((_tick, index) => index % stride === 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function axisTicks(
|
|
36
|
+
scale: ChartScale,
|
|
37
|
+
options: AxisTickOptions = {},
|
|
38
|
+
): ReadonlyArray<AxisTick> {
|
|
39
|
+
const { count = DEFAULT_TICK_COUNT, minTickGap = 0, available = 0 } = options;
|
|
40
|
+
|
|
41
|
+
const ticks: AxisTick[] = isLinearScale(scale)
|
|
42
|
+
? scale.ticks(count).map((value) => ({ value, offset: scale.scale(value) }))
|
|
43
|
+
: scale.domain.map((value) => ({ value, offset: scale.center(value) }));
|
|
44
|
+
|
|
45
|
+
if (minTickGap <= 0 || available <= 0 || ticks.length < 2) {
|
|
46
|
+
return ticks;
|
|
47
|
+
}
|
|
48
|
+
const stride = Math.max(
|
|
49
|
+
1,
|
|
50
|
+
Math.ceil((ticks.length * minTickGap) / available),
|
|
51
|
+
);
|
|
52
|
+
return stride === 1 ? ticks : thin(ticks, stride);
|
|
53
|
+
}
|
package/src/core/bars.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readNumber } from "#/core/chart-model.ts";
|
|
2
|
+
import type {
|
|
3
|
+
ChartDatum,
|
|
4
|
+
ChartDiscreteScale,
|
|
5
|
+
ChartLinearScale,
|
|
6
|
+
ChartOrientation,
|
|
7
|
+
} from "#/core/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bar geometry, worked out away from React so it can be asserted on directly.
|
|
11
|
+
* Covers the four combinations that matter: grouped or stacked, upright or on
|
|
12
|
+
* its side, with values that may go either side of the baseline.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface BarRect {
|
|
16
|
+
readonly key: string;
|
|
17
|
+
/** Index of the datum, i.e. of the category slot. */
|
|
18
|
+
readonly index: number;
|
|
19
|
+
readonly value: number;
|
|
20
|
+
readonly x: number;
|
|
21
|
+
readonly y: number;
|
|
22
|
+
readonly width: number;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
readonly radius: readonly [number, number, number, number];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BarLayoutOptions {
|
|
28
|
+
readonly data: ReadonlyArray<ChartDatum>;
|
|
29
|
+
readonly keys: ReadonlyArray<string>;
|
|
30
|
+
readonly categories: ReadonlyArray<string>;
|
|
31
|
+
readonly categoryScale: ChartDiscreteScale;
|
|
32
|
+
readonly valueScale: ChartLinearScale;
|
|
33
|
+
readonly orientation: ChartOrientation;
|
|
34
|
+
readonly stacked?: boolean;
|
|
35
|
+
readonly radius?: number;
|
|
36
|
+
/** Pixels left between two bars of the same group. */
|
|
37
|
+
readonly gap?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_GAP = 2;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Corner radii for the end of a bar that points away from the baseline. The
|
|
44
|
+
* other end stays square so it meets the axis, or the next stack segment,
|
|
45
|
+
* cleanly.
|
|
46
|
+
*/
|
|
47
|
+
function endRadius(
|
|
48
|
+
radius: number,
|
|
49
|
+
orientation: ChartOrientation,
|
|
50
|
+
isPositive: boolean,
|
|
51
|
+
): readonly [number, number, number, number] {
|
|
52
|
+
if (orientation === "vertical") {
|
|
53
|
+
return isPositive ? [radius, radius, 0, 0] : [0, 0, radius, radius];
|
|
54
|
+
}
|
|
55
|
+
return isPositive ? [0, radius, radius, 0] : [radius, 0, 0, radius];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The last key on each side of the baseline — the only segments that round. */
|
|
59
|
+
function outerKeys(
|
|
60
|
+
datum: ChartDatum,
|
|
61
|
+
keys: ReadonlyArray<string>,
|
|
62
|
+
): { readonly positive: string | null; readonly negative: string | null } {
|
|
63
|
+
let positive: string | null = null;
|
|
64
|
+
let negative: string | null = null;
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const value = readNumber(datum, key);
|
|
67
|
+
if (value > 0) {
|
|
68
|
+
positive = key;
|
|
69
|
+
} else if (value < 0) {
|
|
70
|
+
negative = key;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { positive, negative };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rectFor(
|
|
77
|
+
options: BarLayoutOptions,
|
|
78
|
+
placement: {
|
|
79
|
+
readonly key: string;
|
|
80
|
+
readonly index: number;
|
|
81
|
+
readonly value: number;
|
|
82
|
+
readonly slotStart: number;
|
|
83
|
+
readonly slotSize: number;
|
|
84
|
+
readonly from: number;
|
|
85
|
+
readonly to: number;
|
|
86
|
+
readonly radius: readonly [number, number, number, number];
|
|
87
|
+
},
|
|
88
|
+
): BarRect {
|
|
89
|
+
const { key, index, value, slotStart, slotSize, from, to, radius } =
|
|
90
|
+
placement;
|
|
91
|
+
const low = Math.min(from, to);
|
|
92
|
+
const span = Math.abs(from - to);
|
|
93
|
+
const common = { key, index, value, radius };
|
|
94
|
+
return options.orientation === "vertical"
|
|
95
|
+
? { ...common, x: slotStart, y: low, width: slotSize, height: span }
|
|
96
|
+
: { ...common, x: low, y: slotStart, width: span, height: slotSize };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function barRects(options: BarLayoutOptions): ReadonlyArray<BarRect> {
|
|
100
|
+
const {
|
|
101
|
+
data,
|
|
102
|
+
keys,
|
|
103
|
+
categories,
|
|
104
|
+
categoryScale,
|
|
105
|
+
valueScale,
|
|
106
|
+
orientation,
|
|
107
|
+
stacked = false,
|
|
108
|
+
radius = 0,
|
|
109
|
+
gap = DEFAULT_GAP,
|
|
110
|
+
} = options;
|
|
111
|
+
|
|
112
|
+
const slotWidth = categoryScale.bandwidth;
|
|
113
|
+
const lanes = stacked ? 1 : Math.max(1, keys.length);
|
|
114
|
+
const lane = slotWidth / lanes;
|
|
115
|
+
const barSize = stacked ? slotWidth : Math.max(0, lane - gap);
|
|
116
|
+
const baseline = valueScale.scale(0);
|
|
117
|
+
|
|
118
|
+
return data.flatMap((datum, index) => {
|
|
119
|
+
const slotOrigin = categoryScale.scale(categories[index] ?? String(index));
|
|
120
|
+
const outer = outerKeys(datum, keys);
|
|
121
|
+
let positiveTop = 0;
|
|
122
|
+
let negativeTop = 0;
|
|
123
|
+
|
|
124
|
+
return keys.map((key, laneIndex) => {
|
|
125
|
+
const value = readNumber(datum, key);
|
|
126
|
+
const isPositive = value >= 0;
|
|
127
|
+
|
|
128
|
+
let from = baseline;
|
|
129
|
+
let to = valueScale.scale(value);
|
|
130
|
+
if (stacked) {
|
|
131
|
+
const base = isPositive ? positiveTop : negativeTop;
|
|
132
|
+
const top = base + value;
|
|
133
|
+
from = valueScale.scale(base);
|
|
134
|
+
to = valueScale.scale(top);
|
|
135
|
+
if (isPositive) {
|
|
136
|
+
positiveTop = top;
|
|
137
|
+
} else {
|
|
138
|
+
negativeTop = top;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rounds =
|
|
143
|
+
!stacked || key === (isPositive ? outer.positive : outer.negative);
|
|
144
|
+
|
|
145
|
+
return rectFor(options, {
|
|
146
|
+
key,
|
|
147
|
+
index,
|
|
148
|
+
value,
|
|
149
|
+
slotStart:
|
|
150
|
+
slotOrigin + (stacked ? 0 : laneIndex * lane + (lane - barSize) / 2),
|
|
151
|
+
slotSize: barSize,
|
|
152
|
+
from,
|
|
153
|
+
to,
|
|
154
|
+
radius: rounds
|
|
155
|
+
? endRadius(radius, orientation, isPositive)
|
|
156
|
+
: [0, 0, 0, 0],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { bandScale, linearScale, pointScale } from "#/core/scales.ts";
|
|
2
|
+
import { extentFromZero, niceDomain } from "#/core/ticks.ts";
|
|
3
|
+
import type {
|
|
4
|
+
ChartDatum,
|
|
5
|
+
ChartDiscreteScale,
|
|
6
|
+
ChartLinearScale,
|
|
7
|
+
ChartOrientation,
|
|
8
|
+
ChartScale,
|
|
9
|
+
} from "#/core/types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Turns raw rows plus a pair of axis declarations into the two scales every
|
|
13
|
+
* cartesian mark reads. Pure and synchronous: the components only ever call it
|
|
14
|
+
* inside a `useMemo`, and the tests call it directly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ChartCategorySpec {
|
|
18
|
+
/** Field on each datum holding the category label. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
/** `band` leaves room for bars; `point` pins lines to the plot edges. */
|
|
21
|
+
readonly type?: "band" | "point";
|
|
22
|
+
readonly paddingInner?: number;
|
|
23
|
+
readonly paddingOuter?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChartValueSpec {
|
|
27
|
+
/** Fields plotted against the value axis, in draw order. */
|
|
28
|
+
readonly keys: ReadonlyArray<string>;
|
|
29
|
+
/** Overrides the computed domain — use it to pin an axis to `[0, 100]`. */
|
|
30
|
+
readonly domain?: readonly [number, number];
|
|
31
|
+
/** Sum the keys per datum instead of taking their maximum. */
|
|
32
|
+
readonly stacked?: boolean;
|
|
33
|
+
/** Round the domain out to whole ticks. On by default. */
|
|
34
|
+
readonly nice?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ChartModelOptions {
|
|
38
|
+
readonly data: ReadonlyArray<ChartDatum>;
|
|
39
|
+
readonly category?: ChartCategorySpec;
|
|
40
|
+
readonly value?: ChartValueSpec;
|
|
41
|
+
readonly orientation: ChartOrientation;
|
|
42
|
+
readonly innerWidth: number;
|
|
43
|
+
readonly innerHeight: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ChartModel {
|
|
47
|
+
readonly categories: ReadonlyArray<string>;
|
|
48
|
+
readonly valueKeys: ReadonlyArray<string>;
|
|
49
|
+
readonly categoryScale: ChartDiscreteScale;
|
|
50
|
+
readonly valueScale: ChartLinearScale;
|
|
51
|
+
readonly xScale: ChartScale;
|
|
52
|
+
readonly yScale: ChartScale;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reads a numeric field, treating anything unparseable as zero. */
|
|
56
|
+
export function readNumber(datum: ChartDatum, key: string): number {
|
|
57
|
+
const value = datum[key];
|
|
58
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
const parsed = Number(value);
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function categoryLabels(
|
|
69
|
+
data: ReadonlyArray<ChartDatum>,
|
|
70
|
+
spec: ChartCategorySpec | undefined,
|
|
71
|
+
): ReadonlyArray<string> {
|
|
72
|
+
return data.map((datum, index) => {
|
|
73
|
+
const raw = spec === undefined ? undefined : datum[spec.key];
|
|
74
|
+
return raw === undefined || raw === null ? String(index) : String(raw);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The extent the value axis has to cover. Stacked series are summed per datum
|
|
80
|
+
* (positives and negatives apart, so a stack that crosses zero still fits);
|
|
81
|
+
* grouped series are compared one by one.
|
|
82
|
+
*/
|
|
83
|
+
function valueExtent(
|
|
84
|
+
data: ReadonlyArray<ChartDatum>,
|
|
85
|
+
spec: ChartValueSpec,
|
|
86
|
+
): readonly [number, number] {
|
|
87
|
+
if (!spec.stacked) {
|
|
88
|
+
return extentFromZero(
|
|
89
|
+
data.flatMap((datum) => spec.keys.map((key) => readNumber(datum, key))),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const totals = data.flatMap((datum) => {
|
|
93
|
+
let positive = 0;
|
|
94
|
+
let negative = 0;
|
|
95
|
+
for (const key of spec.keys) {
|
|
96
|
+
const value = readNumber(datum, key);
|
|
97
|
+
if (value >= 0) {
|
|
98
|
+
positive += value;
|
|
99
|
+
} else {
|
|
100
|
+
negative += value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [positive, negative];
|
|
104
|
+
});
|
|
105
|
+
return extentFromZero(totals);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildCategoryScale(
|
|
109
|
+
categories: ReadonlyArray<string>,
|
|
110
|
+
spec: ChartCategorySpec | undefined,
|
|
111
|
+
range: readonly [number, number],
|
|
112
|
+
): ChartDiscreteScale {
|
|
113
|
+
if (spec?.type === "point") {
|
|
114
|
+
return pointScale({ domain: categories, range });
|
|
115
|
+
}
|
|
116
|
+
return bandScale({
|
|
117
|
+
domain: categories,
|
|
118
|
+
range,
|
|
119
|
+
paddingInner: spec?.paddingInner,
|
|
120
|
+
paddingOuter: spec?.paddingOuter,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildChartModel(options: ChartModelOptions): ChartModel {
|
|
125
|
+
const { data, category, value, orientation, innerWidth, innerHeight } =
|
|
126
|
+
options;
|
|
127
|
+
const isVertical = orientation === "vertical";
|
|
128
|
+
const categories = categoryLabels(data, category);
|
|
129
|
+
const valueSpec: ChartValueSpec = value ?? { keys: [] };
|
|
130
|
+
|
|
131
|
+
const rawExtent = valueSpec.domain ?? valueExtent(data, valueSpec);
|
|
132
|
+
const domain =
|
|
133
|
+
valueSpec.domain !== undefined || valueSpec.nice === false
|
|
134
|
+
? rawExtent
|
|
135
|
+
: niceDomain(rawExtent[0], rawExtent[1]);
|
|
136
|
+
|
|
137
|
+
const categoryScale = buildCategoryScale(
|
|
138
|
+
categories,
|
|
139
|
+
category,
|
|
140
|
+
isVertical ? [0, innerWidth] : [0, innerHeight],
|
|
141
|
+
);
|
|
142
|
+
const valueScale = linearScale({
|
|
143
|
+
domain,
|
|
144
|
+
// A value axis grows upwards, so its pixel range runs backwards.
|
|
145
|
+
range: isVertical ? [innerHeight, 0] : [0, innerWidth],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
categories,
|
|
150
|
+
valueKeys: valueSpec.keys,
|
|
151
|
+
categoryScale,
|
|
152
|
+
valueScale,
|
|
153
|
+
xScale: isVertical ? categoryScale : valueScale,
|
|
154
|
+
yScale: isVertical ? valueScale : categoryScale,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The per-series presentation map every chart is given, plus the two lookups
|
|
5
|
+
* every mark needs from it: what colour to paint a key, and what to call it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Light and dark values for one series colour. */
|
|
9
|
+
export type ChartTheme = Record<"light" | "dark", string>;
|
|
10
|
+
|
|
11
|
+
export type ChartConfigItem = {
|
|
12
|
+
label?: React.ReactNode;
|
|
13
|
+
icon?: React.ComponentType;
|
|
14
|
+
} & ({ color?: string; theme?: never } | { color?: never; theme: ChartTheme });
|
|
15
|
+
|
|
16
|
+
export type ChartConfig = Record<string, ChartConfigItem>;
|
|
17
|
+
|
|
18
|
+
/** How many `--chart-N` tokens the design system defines. */
|
|
19
|
+
export const CHART_PALETTE_SIZE = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The colour a series is drawn in. A configured series uses the `--color-<key>`
|
|
23
|
+
* variable the root injects (so light and dark both work without a re-render);
|
|
24
|
+
* anything else falls back to the shared palette, cycling by draw order rather
|
|
25
|
+
* than picking one arbitrary colour for every unnamed series.
|
|
26
|
+
*/
|
|
27
|
+
export function seriesColor(
|
|
28
|
+
config: ChartConfig,
|
|
29
|
+
key: string,
|
|
30
|
+
index = 0,
|
|
31
|
+
): string {
|
|
32
|
+
const item = config[key];
|
|
33
|
+
if (
|
|
34
|
+
item !== undefined &&
|
|
35
|
+
(item.color !== undefined || item.theme !== undefined)
|
|
36
|
+
) {
|
|
37
|
+
return `var(--color-${key})`;
|
|
38
|
+
}
|
|
39
|
+
return `var(--chart-${(index % CHART_PALETTE_SIZE) + 1})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The human name of a series, falling back to its raw key. */
|
|
43
|
+
export function seriesLabel(config: ChartConfig, key: string): React.ReactNode {
|
|
44
|
+
return config[key]?.label ?? key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which config entry describes a row. Charts coloured per row (a pie, a status
|
|
49
|
+
* breakdown) name their entries after a field on the datum — `nameKey` says
|
|
50
|
+
* which one — while ordinary series charts are keyed by the series itself.
|
|
51
|
+
*/
|
|
52
|
+
export function configKeyFor(
|
|
53
|
+
datum: Record<string, unknown> | undefined,
|
|
54
|
+
fallbackKey: string,
|
|
55
|
+
nameKey?: string,
|
|
56
|
+
): string {
|
|
57
|
+
if (nameKey === undefined || datum === undefined) {
|
|
58
|
+
return fallbackKey;
|
|
59
|
+
}
|
|
60
|
+
const named = datum[nameKey];
|
|
61
|
+
return named === undefined || named === null ? fallbackKey : String(named);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The same, flattened to a string for `aria-label` and the data table. */
|
|
65
|
+
export function seriesLabelText(config: ChartConfig, key: string): string {
|
|
66
|
+
const label = config[key]?.label;
|
|
67
|
+
return typeof label === "string" ? label : key;
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number formatting for axes, labels and tooltips. The whole app writes its
|
|
3
|
+
* numbers in French, so that is the default here too — pass an explicit locale
|
|
4
|
+
* when a chart lives on a page that does not.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LOCALE = "fr-FR";
|
|
8
|
+
/** Above this magnitude an axis tick reads better as "12 k" than as "12 000". */
|
|
9
|
+
const COMPACT_THRESHOLD = 10_000;
|
|
10
|
+
|
|
11
|
+
const formatterCache = new Map<string, Intl.NumberFormat>();
|
|
12
|
+
|
|
13
|
+
function formatterFor(
|
|
14
|
+
locale: string,
|
|
15
|
+
options: Intl.NumberFormatOptions,
|
|
16
|
+
): Intl.NumberFormat {
|
|
17
|
+
const cacheKey = `${locale}:${JSON.stringify(options)}`;
|
|
18
|
+
const cached = formatterCache.get(cacheKey);
|
|
19
|
+
if (cached !== undefined) {
|
|
20
|
+
return cached;
|
|
21
|
+
}
|
|
22
|
+
const created = new Intl.NumberFormat(locale, options);
|
|
23
|
+
formatterCache.set(cacheKey, created);
|
|
24
|
+
return created;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FormatNumberOptions {
|
|
28
|
+
readonly locale?: string;
|
|
29
|
+
readonly maximumFractionDigits?: number;
|
|
30
|
+
readonly minimumFractionDigits?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatNumber(
|
|
34
|
+
value: number,
|
|
35
|
+
options: FormatNumberOptions = {},
|
|
36
|
+
): string {
|
|
37
|
+
const { locale = DEFAULT_LOCALE, ...digits } = options;
|
|
38
|
+
return formatterFor(locale, digits).format(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "12 k", "3,4 M" — for axis ticks, where width is the scarce resource. */
|
|
42
|
+
export function formatCompactNumber(
|
|
43
|
+
value: number,
|
|
44
|
+
locale: string = DEFAULT_LOCALE,
|
|
45
|
+
): string {
|
|
46
|
+
return formatterFor(locale, {
|
|
47
|
+
notation: "compact",
|
|
48
|
+
maximumFractionDigits: 1,
|
|
49
|
+
}).format(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatPercentage(
|
|
53
|
+
fraction: number,
|
|
54
|
+
locale: string = DEFAULT_LOCALE,
|
|
55
|
+
): string {
|
|
56
|
+
return formatterFor(locale, {
|
|
57
|
+
style: "percent",
|
|
58
|
+
maximumFractionDigits: 1,
|
|
59
|
+
}).format(fraction);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a value axis uses when the caller has no opinion: plain digits until the
|
|
64
|
+
* numbers get long, compact notation after that, and at most one decimal so a
|
|
65
|
+
* tick step of 0.5 still reads correctly.
|
|
66
|
+
*/
|
|
67
|
+
export function formatTickValue(
|
|
68
|
+
value: number,
|
|
69
|
+
locale: string = DEFAULT_LOCALE,
|
|
70
|
+
): string {
|
|
71
|
+
if (Math.abs(value) >= COMPACT_THRESHOLD) {
|
|
72
|
+
return formatCompactNumber(value, locale);
|
|
73
|
+
}
|
|
74
|
+
return formatNumber(value, { locale, maximumFractionDigits: 2 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Renders an unknown datum field as a label without throwing on `null`. */
|
|
78
|
+
export function formatLabel(value: unknown): string {
|
|
79
|
+
if (value === null || value === undefined) {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === "number") {
|
|
83
|
+
return formatTickValue(value);
|
|
84
|
+
}
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|