@chipmobilesdk/rn-chart 0.2.1 → 0.4.0
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/CHANGELOG.md +56 -0
- package/README.md +54 -2
- package/package.json +5 -2
- package/skills/sdk-rn-chart/SKILL.md +49 -14
- package/src/a11y/summary.ts +85 -46
- package/src/contract/distribution.ts +44 -0
- package/src/contract/errors.ts +6 -0
- package/src/contract/format.ts +6 -0
- package/src/contract/interaction.ts +2 -2
- package/src/contract/props.ts +7 -0
- package/src/contract/strings.ts +50 -0
- package/src/geometry/bar.ts +27 -16
- package/src/geometry/distribution.ts +11 -6
- package/src/geometry/heatmap.ts +34 -17
- package/src/geometry/hitTest.ts +35 -0
- package/src/geometry/progress.ts +0 -1
- package/src/geometry/ticks.ts +0 -13
- package/src/geometry/timeSeries.ts +7 -2
- package/src/geometry/types.ts +11 -0
- package/src/index.ts +8 -1
- package/src/react/ChartBoundary.tsx +2 -2
- package/src/react/ChartFrame.tsx +128 -362
- package/src/react/ChartLegend.tsx +65 -3
- package/src/react/ChartPlot.tsx +57 -0
- package/src/react/ChartStateOverlay.tsx +94 -0
- package/src/react/charts.tsx +27 -12
- package/src/react/pointEvent.ts +40 -0
- package/src/react/useChartA11y.ts +113 -0
- package/src/react/useChartFormatters.ts +105 -0
- package/src/react/useChartLayout.ts +5 -2
- package/src/react/useChartSelection.ts +77 -40
- package/src/react/useChartStatus.ts +85 -0
- package/src/render/Axis.tsx +26 -22
- package/src/render/Marks.tsx +2 -9
- package/src/validation/budgets.ts +16 -4
- package/src/validation/validateDatasource.ts +38 -14
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* carries a dash pattern as well as a colour, so series stay distinguishable
|
|
7
7
|
* without colour perception.
|
|
8
8
|
*/
|
|
9
|
+
import { formatMessage } from '@chipmobilesdk/rn-core';
|
|
9
10
|
import React from 'react';
|
|
10
11
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
11
12
|
|
|
12
13
|
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
14
|
+
import type { SeriesRef } from '../contract/format';
|
|
15
|
+
import { chartStrings, type ChartStrings } from '../contract/strings';
|
|
13
16
|
import {
|
|
14
17
|
resolveSegmentColors,
|
|
15
18
|
resolveSeriesColor,
|
|
@@ -17,6 +20,7 @@ import {
|
|
|
17
20
|
type ChartTheme,
|
|
18
21
|
type LegendEntry,
|
|
19
22
|
} from '../contract/theme';
|
|
23
|
+
import type { ColorScale } from '../geometry/types';
|
|
20
24
|
|
|
21
25
|
/** Minimum tap target, per platform accessibility guidance (UX-003). */
|
|
22
26
|
const MIN_TAP_TARGET = 44;
|
|
@@ -32,13 +36,15 @@ export function buildLegendEntries<M>(
|
|
|
32
36
|
hidden: ReadonlySet<string>,
|
|
33
37
|
toggleable: boolean,
|
|
34
38
|
family?: ChartFamily,
|
|
39
|
+
/** The app's `legendLabel` formatter, when it has one. */
|
|
40
|
+
formatLabel: (series: SeriesRef) => string = series => series.label,
|
|
35
41
|
): LegendEntry[] {
|
|
36
42
|
if (family === 'distribution') {
|
|
37
43
|
const colors = resolveSegmentColors(datasource, theme);
|
|
38
44
|
return datasource.series.flatMap(series =>
|
|
39
45
|
series.points.map(point => {
|
|
40
46
|
const single = series.points.length === 1;
|
|
41
|
-
let label = series.label;
|
|
47
|
+
let label = formatLabel({ id: series.id, label: series.label });
|
|
42
48
|
if (!single) {
|
|
43
49
|
label = point.position.kind === 'category' ? point.position.category : point.id;
|
|
44
50
|
}
|
|
@@ -57,7 +63,7 @@ export function buildLegendEntries<M>(
|
|
|
57
63
|
}
|
|
58
64
|
return datasource.series.map((series, index) => ({
|
|
59
65
|
seriesId: series.id,
|
|
60
|
-
label: series.label,
|
|
66
|
+
label: formatLabel({ id: series.id, label: series.label }),
|
|
61
67
|
color: resolveSeriesColor(theme, index, series.color),
|
|
62
68
|
pattern: series.pattern ?? 'solid',
|
|
63
69
|
visible: !hidden.has(series.id),
|
|
@@ -117,10 +123,12 @@ export function ChartLegend({
|
|
|
117
123
|
entries,
|
|
118
124
|
theme,
|
|
119
125
|
onToggle,
|
|
126
|
+
strings = chartStrings.defaults,
|
|
120
127
|
}: {
|
|
121
128
|
readonly entries: readonly LegendEntry[];
|
|
122
129
|
readonly theme: ChartTheme;
|
|
123
130
|
readonly onToggle?: (seriesId: string) => void;
|
|
131
|
+
readonly strings?: ChartStrings;
|
|
124
132
|
}) {
|
|
125
133
|
if (entries.length === 0) {
|
|
126
134
|
return null;
|
|
@@ -162,7 +170,9 @@ export function ChartLegend({
|
|
|
162
170
|
accessibilityRole="switch"
|
|
163
171
|
accessibilityState={{ checked: entry.visible }}
|
|
164
172
|
accessibilityLabel={entry.label}
|
|
165
|
-
accessibilityHint={
|
|
173
|
+
accessibilityHint={
|
|
174
|
+
entry.visible ? strings['chart.legend.hide'] : strings['chart.legend.show']
|
|
175
|
+
}
|
|
166
176
|
style={[styles.entry, styles.tappable]}
|
|
167
177
|
>
|
|
168
178
|
{content}
|
|
@@ -173,7 +183,59 @@ export function ChartLegend({
|
|
|
173
183
|
);
|
|
174
184
|
}
|
|
175
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The key to a colour ramp: its lowest and highest value around the colours
|
|
188
|
+
* between them. Without it a heatmap's shades meant nothing (CHT-15).
|
|
189
|
+
*/
|
|
190
|
+
export function ColorScaleLegend({
|
|
191
|
+
scale,
|
|
192
|
+
theme,
|
|
193
|
+
formatValue,
|
|
194
|
+
strings = chartStrings.defaults,
|
|
195
|
+
}: {
|
|
196
|
+
readonly scale: ColorScale;
|
|
197
|
+
readonly theme: ChartTheme;
|
|
198
|
+
readonly formatValue: (value: number) => string;
|
|
199
|
+
readonly strings?: ChartStrings;
|
|
200
|
+
}) {
|
|
201
|
+
const min = formatValue(scale.min);
|
|
202
|
+
const max = formatValue(scale.max);
|
|
203
|
+
const text = {
|
|
204
|
+
color: theme.typography.legendLabel.color,
|
|
205
|
+
fontSize: theme.typography.legendLabel.fontSize,
|
|
206
|
+
};
|
|
207
|
+
return (
|
|
208
|
+
<View
|
|
209
|
+
style={styles.scale}
|
|
210
|
+
accessible
|
|
211
|
+
accessibilityRole="text"
|
|
212
|
+
accessibilityLabel={formatMessage(strings['chart.legend.scale'], { min, max })}
|
|
213
|
+
>
|
|
214
|
+
<Text style={text}>{min}</Text>
|
|
215
|
+
<View style={styles.scaleRamp}>
|
|
216
|
+
{scale.colors.map((color, index) => (
|
|
217
|
+
<View key={`${index}-${color}`} style={[styles.scaleStep, { backgroundColor: color }]} />
|
|
218
|
+
))}
|
|
219
|
+
</View>
|
|
220
|
+
<Text style={text}>{max}</Text>
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
176
225
|
const styles = StyleSheet.create({
|
|
226
|
+
scale: {
|
|
227
|
+
flexDirection: 'row',
|
|
228
|
+
alignItems: 'center',
|
|
229
|
+
marginTop: 4,
|
|
230
|
+
},
|
|
231
|
+
scaleRamp: {
|
|
232
|
+
flexDirection: 'row',
|
|
233
|
+
marginHorizontal: 8,
|
|
234
|
+
},
|
|
235
|
+
scaleStep: {
|
|
236
|
+
width: 16,
|
|
237
|
+
height: 8,
|
|
238
|
+
},
|
|
177
239
|
container: {
|
|
178
240
|
flexDirection: 'row',
|
|
179
241
|
flexWrap: 'wrap',
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the chart draws from its geometry.
|
|
3
|
+
*
|
|
4
|
+
* Memoized on the geometry: selecting a point re-renders the frame and moves
|
|
5
|
+
* the highlight, and this subtree — the long path strings — is skipped
|
|
6
|
+
* (CHT-08). It used to be reconciled again on every tap.
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import type { ChartTheme } from '../contract/theme';
|
|
11
|
+
import type { ResolvedGeometry } from '../geometry/types';
|
|
12
|
+
import { Grid, XAxis, YAxis } from '../render/Axis';
|
|
13
|
+
import { GeometryLabels, MarkerLines, Marks, NonValueTextures } from '../render/Marks';
|
|
14
|
+
|
|
15
|
+
/** `labels` names rows and columns without drawing axis lines or a grid. */
|
|
16
|
+
export type ChartAxes = 'full' | 'labels' | 'none';
|
|
17
|
+
|
|
18
|
+
export const ChartPlot = React.memo(function ChartPlot({
|
|
19
|
+
geometry,
|
|
20
|
+
theme,
|
|
21
|
+
fontSize,
|
|
22
|
+
axes,
|
|
23
|
+
}: {
|
|
24
|
+
readonly geometry: ResolvedGeometry;
|
|
25
|
+
readonly theme: ChartTheme;
|
|
26
|
+
readonly fontSize: number;
|
|
27
|
+
readonly axes: ChartAxes;
|
|
28
|
+
}) {
|
|
29
|
+
const lines = axes === 'full';
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<NonValueTextures theme={theme} />
|
|
33
|
+
{axes !== 'none' && (
|
|
34
|
+
<>
|
|
35
|
+
{lines && <Grid plot={geometry.plot} yTicks={geometry.yTicks} theme={theme} />}
|
|
36
|
+
<YAxis
|
|
37
|
+
plot={geometry.plot}
|
|
38
|
+
ticks={geometry.yTicks}
|
|
39
|
+
theme={theme}
|
|
40
|
+
fontSize={fontSize}
|
|
41
|
+
line={lines}
|
|
42
|
+
/>
|
|
43
|
+
<XAxis
|
|
44
|
+
plot={geometry.plot}
|
|
45
|
+
ticks={geometry.xTicks}
|
|
46
|
+
theme={theme}
|
|
47
|
+
fontSize={fontSize}
|
|
48
|
+
line={lines}
|
|
49
|
+
/>
|
|
50
|
+
</>
|
|
51
|
+
)}
|
|
52
|
+
<Marks paths={geometry.paths} />
|
|
53
|
+
<MarkerLines markers={geometry.markers} theme={theme} />
|
|
54
|
+
<GeometryLabels labels={geometry.labels} theme={theme} fontSize={fontSize} />
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a chart shows in place of data: the app's own content for the state,
|
|
3
|
+
* or a terse built-in line. Apps are expected to supply their own copy — the
|
|
4
|
+
* package must not impose product voice.
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
8
|
+
|
|
9
|
+
import type { ChartError } from '../contract/errors';
|
|
10
|
+
import type { ChartStatus } from '../contract/state';
|
|
11
|
+
import type { ChartStrings } from '../contract/strings';
|
|
12
|
+
import type { ChartTheme } from '../contract/theme';
|
|
13
|
+
|
|
14
|
+
function builtInStateText(
|
|
15
|
+
status: ChartStatus,
|
|
16
|
+
strings: ChartStrings,
|
|
17
|
+
error?: ChartError,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
switch (status) {
|
|
20
|
+
case 'loading':
|
|
21
|
+
return strings['chart.state.loading'];
|
|
22
|
+
case 'empty':
|
|
23
|
+
return strings['chart.state.empty'];
|
|
24
|
+
case 'error':
|
|
25
|
+
return error?.message ?? strings['chart.state.error'];
|
|
26
|
+
case 'noPermission':
|
|
27
|
+
return strings['chart.state.noPermission'];
|
|
28
|
+
default:
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChartStateOverlay({
|
|
34
|
+
status,
|
|
35
|
+
supplied,
|
|
36
|
+
strings,
|
|
37
|
+
theme,
|
|
38
|
+
error,
|
|
39
|
+
}: {
|
|
40
|
+
readonly status: ChartStatus;
|
|
41
|
+
/** The app's content for this state, when it gave any. */
|
|
42
|
+
readonly supplied?: React.ReactNode;
|
|
43
|
+
readonly strings: ChartStrings;
|
|
44
|
+
readonly theme: ChartTheme;
|
|
45
|
+
readonly error?: ChartError;
|
|
46
|
+
}) {
|
|
47
|
+
if (supplied !== undefined) {
|
|
48
|
+
return <View style={styles.overlay}>{supplied}</View>;
|
|
49
|
+
}
|
|
50
|
+
const fallback = builtInStateText(status, strings, error);
|
|
51
|
+
if (!fallback) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.overlay}>
|
|
56
|
+
<Text style={{ color: theme.muted, fontSize: theme.typography.valueLabel.fontSize }}>
|
|
57
|
+
{fallback}
|
|
58
|
+
</Text>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The message a contained render failure shows where the plot was. */
|
|
64
|
+
export function ChartFailureOverlay({
|
|
65
|
+
supplied,
|
|
66
|
+
theme,
|
|
67
|
+
error,
|
|
68
|
+
}: {
|
|
69
|
+
readonly supplied?: React.ReactNode;
|
|
70
|
+
readonly theme: ChartTheme;
|
|
71
|
+
readonly error: ChartError;
|
|
72
|
+
}) {
|
|
73
|
+
return (
|
|
74
|
+
<View style={styles.overlay}>
|
|
75
|
+
{supplied ?? (
|
|
76
|
+
<Text style={{ color: theme.muted, fontSize: theme.typography.valueLabel.fontSize }}>
|
|
77
|
+
{error.message}
|
|
78
|
+
</Text>
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const styles = StyleSheet.create({
|
|
85
|
+
overlay: {
|
|
86
|
+
position: 'absolute',
|
|
87
|
+
top: 0,
|
|
88
|
+
left: 0,
|
|
89
|
+
right: 0,
|
|
90
|
+
bottom: 0,
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
justifyContent: 'center',
|
|
93
|
+
},
|
|
94
|
+
});
|
package/src/react/charts.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import React, { useCallback } from 'react';
|
|
12
12
|
|
|
13
13
|
import type { ChartDatasource } from '../contract/datasource';
|
|
14
|
+
import type { DistributionValues } from '../contract/distribution';
|
|
14
15
|
import type { BaseChartProps } from '../contract/props';
|
|
15
16
|
import { buildBarGeometry, type BarGrouping } from '../geometry/bar';
|
|
16
17
|
import { buildDistributionGeometry, type DistributionShape } from '../geometry/distribution';
|
|
@@ -20,15 +21,12 @@ import { buildTimeSeriesGeometry } from '../geometry/timeSeries';
|
|
|
20
21
|
import type { GeometryInput } from '../geometry/types';
|
|
21
22
|
import { ChartFrame } from './ChartFrame';
|
|
22
23
|
|
|
23
|
-
export
|
|
24
|
-
readonly orientation?: 'horizontal' | 'vertical';
|
|
25
|
-
}
|
|
24
|
+
export type ProgressChartProps<M = unknown> = BaseChartProps<M>;
|
|
26
25
|
|
|
27
26
|
function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
|
|
28
27
|
const build = useCallback(
|
|
29
|
-
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
30
|
-
|
|
31
|
-
[props.orientation],
|
|
28
|
+
(ds: ChartDatasource<M>, input: GeometryInput) => buildProgressGeometry(ds, input),
|
|
29
|
+
[],
|
|
32
30
|
);
|
|
33
31
|
|
|
34
32
|
return (
|
|
@@ -36,7 +34,7 @@ function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
|
|
|
36
34
|
{...props}
|
|
37
35
|
family="progress"
|
|
38
36
|
buildGeometry={build}
|
|
39
|
-
|
|
37
|
+
axes="none"
|
|
40
38
|
defaultLegend={false}
|
|
41
39
|
/>
|
|
42
40
|
);
|
|
@@ -45,6 +43,13 @@ function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
|
|
|
45
43
|
export interface DistributionChartProps<M = unknown> extends BaseChartProps<M> {
|
|
46
44
|
readonly shape?: DistributionShape;
|
|
47
45
|
readonly innerRadiusRatio?: number;
|
|
46
|
+
/**
|
|
47
|
+
* What the values mean. `proportions`: shares of a whole in 0..1, drawn short
|
|
48
|
+
* and marked incomplete when they do not reach 1. `absolute`: counts or
|
|
49
|
+
* amounts, normalised by their total. Inferred from the data when omitted;
|
|
50
|
+
* say it when small counts (1 and 1) could be read as shares.
|
|
51
|
+
*/
|
|
52
|
+
readonly valuesAre?: DistributionValues;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
|
|
@@ -53,8 +58,9 @@ function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
|
|
|
53
58
|
buildDistributionGeometry(ds, input, {
|
|
54
59
|
shape: props.shape,
|
|
55
60
|
innerRadiusRatio: props.innerRadiusRatio,
|
|
61
|
+
valuesAre: props.valuesAre,
|
|
56
62
|
}),
|
|
57
|
-
[props.shape, props.innerRadiusRatio],
|
|
63
|
+
[props.shape, props.innerRadiusRatio, props.valuesAre],
|
|
58
64
|
);
|
|
59
65
|
|
|
60
66
|
return (
|
|
@@ -62,7 +68,8 @@ function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
|
|
|
62
68
|
{...props}
|
|
63
69
|
family="distribution"
|
|
64
70
|
buildGeometry={build}
|
|
65
|
-
|
|
71
|
+
distributionValues={props.valuesAre}
|
|
72
|
+
axes="none"
|
|
66
73
|
defaultLegend
|
|
67
74
|
/>
|
|
68
75
|
);
|
|
@@ -74,10 +81,17 @@ export interface TimeSeriesChartProps<M = unknown> extends BaseChartProps<M> {
|
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
function TimeSeriesChartImpl<M>(props: TimeSeriesChartProps<M>) {
|
|
84
|
+
// By value: `range={{ from, to }}` written inline is a new object each render.
|
|
85
|
+
const from = props.range?.from;
|
|
86
|
+
const to = props.range?.to;
|
|
77
87
|
const build = useCallback(
|
|
78
88
|
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
79
|
-
buildTimeSeriesGeometry(
|
|
80
|
-
|
|
89
|
+
buildTimeSeriesGeometry(
|
|
90
|
+
ds,
|
|
91
|
+
input,
|
|
92
|
+
from === undefined || to === undefined ? {} : { range: { from, to } },
|
|
93
|
+
),
|
|
94
|
+
[from, to],
|
|
81
95
|
);
|
|
82
96
|
|
|
83
97
|
return (
|
|
@@ -122,7 +136,8 @@ function HeatmapChartImpl<M>(props: HeatmapChartProps<M>) {
|
|
|
122
136
|
{...props}
|
|
123
137
|
family="heatmap"
|
|
124
138
|
buildGeometry={build}
|
|
125
|
-
|
|
139
|
+
// Rows and columns are named; there is no axis line or grid to draw.
|
|
140
|
+
axes="labels"
|
|
126
141
|
defaultLegend={false}
|
|
127
142
|
/>
|
|
128
143
|
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event a selected point is reported with. One place builds it, for a tap
|
|
3
|
+
* and for a screen-reader step alike.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChartDatasource, DataPoint, Series } from '../contract/datasource';
|
|
6
|
+
import type { PointInteractionEvent } from '../contract/interaction';
|
|
7
|
+
|
|
8
|
+
export interface ResolvedPoint<M> {
|
|
9
|
+
readonly series: Series<M>;
|
|
10
|
+
readonly point: DataPoint<M>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findPoint<M>(
|
|
14
|
+
datasource: ChartDatasource<M>,
|
|
15
|
+
seriesId: string,
|
|
16
|
+
pointId: string | undefined,
|
|
17
|
+
): ResolvedPoint<M> | undefined {
|
|
18
|
+
const series = datasource.series.find(s => s.id === seriesId);
|
|
19
|
+
const point = series?.points.find(p => p.id === pointId);
|
|
20
|
+
return series && point ? { series, point } : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pointEventFor<M>(
|
|
24
|
+
datasource: ChartDatasource<M>,
|
|
25
|
+
seriesId: string,
|
|
26
|
+
pointId: string,
|
|
27
|
+
): PointInteractionEvent<M> | undefined {
|
|
28
|
+
const found = findPoint(datasource, seriesId, pointId);
|
|
29
|
+
if (!found) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
type: 'pointSelect',
|
|
34
|
+
seriesId: found.series.id,
|
|
35
|
+
pointId: found.point.id,
|
|
36
|
+
value: found.point.value,
|
|
37
|
+
position: found.point.position,
|
|
38
|
+
metadata: found.point.metadata,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a screen reader gets: the composed summary, the announcement for the
|
|
3
|
+
* selected point, and increment/decrement to walk the points (FR-052, FR-067,
|
|
4
|
+
* FR-072).
|
|
5
|
+
*/
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
import { buildChartSummary, buildPointAnnouncement } from '../a11y/summary';
|
|
9
|
+
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
10
|
+
import type { ChartAccessibility } from '../contract/props';
|
|
11
|
+
import type { ChartStatus } from '../contract/state';
|
|
12
|
+
import type { ChartStrings } from '../contract/strings';
|
|
13
|
+
import type { HitRegion } from '../geometry/hitTest';
|
|
14
|
+
import { findPoint, pointEventFor } from './pointEvent';
|
|
15
|
+
import type { ResolvedFormatters } from './useChartFormatters';
|
|
16
|
+
import type { ChartSelectionApi } from './useChartSelection';
|
|
17
|
+
|
|
18
|
+
export interface UseChartA11yArgs<M> {
|
|
19
|
+
readonly datasource: ChartDatasource<M>;
|
|
20
|
+
readonly family: ChartFamily;
|
|
21
|
+
readonly status: ChartStatus;
|
|
22
|
+
readonly title?: string;
|
|
23
|
+
readonly accessibility?: ChartAccessibility<M>;
|
|
24
|
+
readonly interactive: boolean;
|
|
25
|
+
readonly incomplete: boolean;
|
|
26
|
+
readonly hitRegions: readonly HitRegion[];
|
|
27
|
+
readonly selection: ChartSelectionApi<M>;
|
|
28
|
+
readonly formatters: ResolvedFormatters;
|
|
29
|
+
readonly strings: ChartStrings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChartA11y {
|
|
33
|
+
readonly summary: string;
|
|
34
|
+
readonly value: { readonly text: string } | undefined;
|
|
35
|
+
readonly actions: readonly { readonly name: string; readonly label: string }[] | undefined;
|
|
36
|
+
readonly onAction: ((event: { nativeEvent: { actionName: string } }) => void) | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useChartA11y<M>(args: UseChartA11yArgs<M>): ChartA11y {
|
|
40
|
+
const { datasource, family, status, title, incomplete, formatters, strings, selection } = args;
|
|
41
|
+
const label = args.accessibility?.label;
|
|
42
|
+
const summaryFormatter = args.accessibility?.summaryFormatter;
|
|
43
|
+
|
|
44
|
+
const summary = useMemo(() => {
|
|
45
|
+
if (label) {
|
|
46
|
+
return label;
|
|
47
|
+
}
|
|
48
|
+
// The app's own composer, when given.
|
|
49
|
+
if (summaryFormatter) {
|
|
50
|
+
return summaryFormatter(datasource, { slot: 'summary', hint: datasource.format });
|
|
51
|
+
}
|
|
52
|
+
return buildChartSummary({
|
|
53
|
+
datasource,
|
|
54
|
+
family,
|
|
55
|
+
status,
|
|
56
|
+
title,
|
|
57
|
+
formatValue: formatters.formatSummaryValue,
|
|
58
|
+
formatPosition: formatters.formatPosition,
|
|
59
|
+
incomplete,
|
|
60
|
+
strings,
|
|
61
|
+
});
|
|
62
|
+
}, [label, summaryFormatter, datasource, family, status, title, formatters, incomplete, strings]);
|
|
63
|
+
|
|
64
|
+
if (!args.interactive) {
|
|
65
|
+
return { summary, value: undefined, actions: undefined, onAction: undefined };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const selected = selection.resolved
|
|
69
|
+
? findPoint(datasource, selection.resolved.seriesId, selection.resolved.pointId)
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
summary,
|
|
74
|
+
value: selected
|
|
75
|
+
? {
|
|
76
|
+
text: buildPointAnnouncement(
|
|
77
|
+
selected.series.label,
|
|
78
|
+
// The app's own wording for a position: a raw epoch number is
|
|
79
|
+
// not something to read aloud (CHT-07).
|
|
80
|
+
formatters.formatPosition(selected.point.position),
|
|
81
|
+
selected.point.value,
|
|
82
|
+
formatters.formatSummaryValue,
|
|
83
|
+
strings,
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
: undefined,
|
|
87
|
+
actions: [
|
|
88
|
+
{ name: 'increment', label: strings['chart.action.next'] },
|
|
89
|
+
{ name: 'decrement', label: strings['chart.action.previous'] },
|
|
90
|
+
],
|
|
91
|
+
onAction: event => {
|
|
92
|
+
const regions = args.hitRegions.filter(r => r.selectable);
|
|
93
|
+
if (regions.length === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const step = event.nativeEvent.actionName === 'increment' ? 1 : -1;
|
|
97
|
+
const current = regions.findIndex(
|
|
98
|
+
r =>
|
|
99
|
+
r.seriesId === selection.selection?.seriesId &&
|
|
100
|
+
r.pointId === selection.selection?.pointId,
|
|
101
|
+
);
|
|
102
|
+
const next = regions[current < 0 ? 0 : (current + step + regions.length) % regions.length];
|
|
103
|
+
const pointEvent = pointEventFor(datasource, next.seriesId, next.pointId);
|
|
104
|
+
if (!pointEvent) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Stepping moves the selection; it never clears it.
|
|
108
|
+
selection.select({ seriesId: next.seriesId, pointId: next.pointId }, pointEvent, {
|
|
109
|
+
toggle: false,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's formatters, resolved once into the functions the chart calls.
|
|
3
|
+
*
|
|
4
|
+
* The memo depends on each formatter **function**, not on the `formatters`
|
|
5
|
+
* object that carries them. `formatters={{ value: formatMoney }}` written
|
|
6
|
+
* inline is a new object every render; depending on it rebuilt every scale and
|
|
7
|
+
* path each time the parent rendered (CHT-08, PERF-11). A formatter function
|
|
8
|
+
* that is itself created inline still counts as a change — it may format
|
|
9
|
+
* differently — so keep those stable (module scope, or `useCallback`).
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
import type { Position } from '../contract/datasource';
|
|
14
|
+
import {
|
|
15
|
+
formatPositionDefault,
|
|
16
|
+
formatValueDefault,
|
|
17
|
+
safeFormat,
|
|
18
|
+
type ChartFormatters,
|
|
19
|
+
type FormatHint,
|
|
20
|
+
type SeriesRef,
|
|
21
|
+
} from '../contract/format';
|
|
22
|
+
import type { ValueState } from '../contract/value';
|
|
23
|
+
|
|
24
|
+
export interface GeometryFormatters {
|
|
25
|
+
readonly formatValue: (value: number) => string;
|
|
26
|
+
readonly formatCategory: (raw: string) => string;
|
|
27
|
+
readonly formatInstant: (at: number) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedFormatters {
|
|
31
|
+
/** A value as the chart draws it. */
|
|
32
|
+
readonly formatValue: (value: ValueState) => string;
|
|
33
|
+
/** A value as the chart speaks it; the app's `summary` formatter when given. */
|
|
34
|
+
readonly formatSummaryValue: (value: ValueState) => string;
|
|
35
|
+
/** A position as the chart speaks it; the app's `axisLabel` formatter when given. */
|
|
36
|
+
readonly formatPosition: (position: Position) => string;
|
|
37
|
+
/** Present only when the app supplies `legendLabel`. */
|
|
38
|
+
readonly legendLabel?: (series: SeriesRef) => string;
|
|
39
|
+
readonly geometry: GeometryFormatters;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useChartFormatters(
|
|
43
|
+
formatters: ChartFormatters | undefined,
|
|
44
|
+
hint: FormatHint | undefined,
|
|
45
|
+
instanceKey: string,
|
|
46
|
+
): ResolvedFormatters {
|
|
47
|
+
const value = formatters?.value;
|
|
48
|
+
const axisLabel = formatters?.axisLabel;
|
|
49
|
+
const legend = formatters?.legendLabel;
|
|
50
|
+
const summary = formatters?.summary;
|
|
51
|
+
|
|
52
|
+
return useMemo(() => {
|
|
53
|
+
const formatValue = (v: ValueState): string =>
|
|
54
|
+
value
|
|
55
|
+
? safeFormat(
|
|
56
|
+
() => value(v, { slot: 'value', hint }),
|
|
57
|
+
() => formatValueDefault(v, { slot: 'value', hint }),
|
|
58
|
+
'value',
|
|
59
|
+
instanceKey,
|
|
60
|
+
)
|
|
61
|
+
: formatValueDefault(v, { slot: 'value', hint });
|
|
62
|
+
|
|
63
|
+
const formatSummaryValue = (v: ValueState): string =>
|
|
64
|
+
summary
|
|
65
|
+
? safeFormat(
|
|
66
|
+
() => summary(v, { slot: 'summary', hint }),
|
|
67
|
+
() => formatValue(v),
|
|
68
|
+
'summary',
|
|
69
|
+
instanceKey,
|
|
70
|
+
)
|
|
71
|
+
: formatValue(v);
|
|
72
|
+
|
|
73
|
+
const formatPosition = (position: Position): string =>
|
|
74
|
+
axisLabel
|
|
75
|
+
? safeFormat(
|
|
76
|
+
() => axisLabel(position, { slot: 'axis' }),
|
|
77
|
+
() => formatPositionDefault(position),
|
|
78
|
+
'axis',
|
|
79
|
+
instanceKey,
|
|
80
|
+
)
|
|
81
|
+
: formatPositionDefault(position);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
formatValue,
|
|
85
|
+
formatSummaryValue,
|
|
86
|
+
formatPosition,
|
|
87
|
+
...(legend
|
|
88
|
+
? {
|
|
89
|
+
legendLabel: (series: SeriesRef) =>
|
|
90
|
+
safeFormat(
|
|
91
|
+
() => legend(series, { slot: 'legend' }),
|
|
92
|
+
() => series.label,
|
|
93
|
+
'legend',
|
|
94
|
+
instanceKey,
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
98
|
+
geometry: {
|
|
99
|
+
formatValue: (n: number) => formatValue({ kind: 'known', value: n }),
|
|
100
|
+
formatCategory: (raw: string) => raw,
|
|
101
|
+
formatInstant: (at: number) => formatPosition({ kind: 'instant', at }),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}, [value, axisLabel, legend, summary, hint, instanceKey]);
|
|
105
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* app models the correct pattern.
|
|
12
12
|
*/
|
|
13
13
|
import { useCallback, useMemo, useState } from 'react';
|
|
14
|
-
import {
|
|
14
|
+
import { useWindowDimensions, type LayoutChangeEvent } from 'react-native';
|
|
15
15
|
|
|
16
16
|
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
17
17
|
import { resolveSegmentColors, resolveSeriesColor, type ChartTheme } from '../contract/theme';
|
|
@@ -53,7 +53,9 @@ export function useChartLayout(
|
|
|
53
53
|
);
|
|
54
54
|
}, []);
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Reactive: a change of the system text size re-renders the chart. A value
|
|
57
|
+
// read once from PixelRatio kept the old size until something else rendered.
|
|
58
|
+
const { fontScale } = useWindowDimensions();
|
|
57
59
|
const fontSize = effectiveFontSize(theme.typography.axisLabel.fontSize, fontScale);
|
|
58
60
|
|
|
59
61
|
const measured = isMeasurable(size);
|
|
@@ -116,6 +118,7 @@ export function useGeometryInput<M>(
|
|
|
116
118
|
family === 'distribution' ? resolveSegmentColors(datasource, theme) : undefined,
|
|
117
119
|
nonValueTextures: theme.nonValue,
|
|
118
120
|
heatmapRamp: theme.heatmapRamp,
|
|
121
|
+
radius: theme.radius,
|
|
119
122
|
formatValue: formatters.formatValue,
|
|
120
123
|
formatCategory: formatters.formatCategory,
|
|
121
124
|
formatInstant: formatters.formatInstant,
|