@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
package/src/react/ChartFrame.tsx
CHANGED
|
@@ -3,44 +3,38 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every family routes through here, so validation, status resolution, budget
|
|
5
5
|
* checks, theming, layout, selection, accessibility, and failure containment
|
|
6
|
-
* are written once and behave identically across all five.
|
|
6
|
+
* are written once and behave identically across all five. The pieces live in
|
|
7
|
+
* their own hooks and components; this file is the order they run in.
|
|
7
8
|
*
|
|
8
9
|
* Layout note (FR-039): the frame imposes **no outer margin**. Insight text is
|
|
9
10
|
* out of scope for this package, so apps render their own text directly above
|
|
10
11
|
* or below a chart and must not have to fight a margin they cannot control.
|
|
11
12
|
*/
|
|
12
|
-
import React, {
|
|
13
|
+
import React, { useId, useMemo } from 'react';
|
|
13
14
|
import { Pressable, StyleSheet, Text, View, type GestureResponderEvent } from 'react-native';
|
|
14
15
|
import Svg from 'react-native-svg';
|
|
15
16
|
|
|
16
17
|
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
17
|
-
import type {
|
|
18
|
-
import { chartError } from '../contract/errors';
|
|
19
|
-
import { formatPositionDefault, formatValueDefault, safeFormat } from '../contract/format';
|
|
20
|
-
import type { PointInteractionEvent } from '../contract/interaction';
|
|
18
|
+
import type { DistributionValues } from '../contract/distribution';
|
|
21
19
|
import type { BaseChartProps } from '../contract/props';
|
|
22
|
-
import { drawsData,
|
|
23
|
-
import
|
|
20
|
+
import { drawsData, suppressesInteraction } from '../contract/state';
|
|
21
|
+
import { chartStrings } from '../contract/strings';
|
|
24
22
|
import { hitTest, hitTestByX } from '../geometry/hitTest';
|
|
25
23
|
import { containsPoint } from '../geometry/layout';
|
|
26
24
|
import type { GeometryInput, ResolvedGeometry } from '../geometry/types';
|
|
27
25
|
import { EMPTY_GEOMETRY } from '../geometry/types';
|
|
28
|
-
import {
|
|
29
|
-
import { validateDatasource } from '../validation/validateDatasource';
|
|
30
|
-
import { buildChartSummary, buildPointAnnouncement } from '../a11y/summary';
|
|
31
|
-
import { Grid, XAxis, YAxis } from '../render/Axis';
|
|
32
|
-
import {
|
|
33
|
-
GeometryLabels,
|
|
34
|
-
MarkerLines,
|
|
35
|
-
Marks,
|
|
36
|
-
NonValueTextures,
|
|
37
|
-
SelectionHighlight,
|
|
38
|
-
} from '../render/Marks';
|
|
26
|
+
import { SelectionHighlight } from '../render/Marks';
|
|
39
27
|
import { ChartBoundary } from './ChartBoundary';
|
|
40
|
-
import { ChartLegend, buildLegendEntries } from './ChartLegend';
|
|
28
|
+
import { ChartLegend, ColorScaleLegend, buildLegendEntries } from './ChartLegend';
|
|
29
|
+
import { ChartPlot, type ChartAxes } from './ChartPlot';
|
|
30
|
+
import { ChartFailureOverlay, ChartStateOverlay } from './ChartStateOverlay';
|
|
41
31
|
import { useChartTheme } from './ChartThemeProvider';
|
|
32
|
+
import { pointEventFor } from './pointEvent';
|
|
33
|
+
import { useChartA11y } from './useChartA11y';
|
|
34
|
+
import { useChartFormatters } from './useChartFormatters';
|
|
42
35
|
import { useChartLayout, useGeometryInput } from './useChartLayout';
|
|
43
36
|
import { useChartSelection, useSeriesVisibility } from './useChartSelection';
|
|
37
|
+
import { useChartStatus } from './useChartStatus';
|
|
44
38
|
|
|
45
39
|
export interface ChartFrameProps<M> extends BaseChartProps<M> {
|
|
46
40
|
readonly family: ChartFamily;
|
|
@@ -50,8 +44,11 @@ export interface ChartFrameProps<M> extends BaseChartProps<M> {
|
|
|
50
44
|
) => ResolvedGeometry;
|
|
51
45
|
/** Time-series selection tracks the x axis; other families use proximity. */
|
|
52
46
|
readonly hitTestMode?: 'nearest' | 'byX';
|
|
53
|
-
|
|
47
|
+
/** `full` by default: axis lines, grid and tick labels. */
|
|
48
|
+
readonly axes?: ChartAxes;
|
|
54
49
|
readonly defaultLegend?: boolean;
|
|
50
|
+
/** What a distribution's values mean, when the app says. */
|
|
51
|
+
readonly distributionValues?: DistributionValues;
|
|
55
52
|
}
|
|
56
53
|
|
|
57
54
|
const DEFAULT_HEIGHT = 220;
|
|
@@ -59,104 +56,28 @@ const DEFAULT_HEIGHT = 220;
|
|
|
59
56
|
export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
60
57
|
const family = props.family;
|
|
61
58
|
const theme = useChartTheme(props.theme);
|
|
59
|
+
const strings = useMemo(() => chartStrings.resolve(props.strings), [props.strings]);
|
|
62
60
|
const generatedId = useId();
|
|
63
61
|
const instanceKey = props.chartId ?? generatedId;
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
() => validateDatasource(props.datasource, props.family, props.chartId),
|
|
71
|
-
[props.datasource, props.family, props.chartId],
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
useMemo(
|
|
75
|
-
() => warnIfOverBudget(props.datasource, props.family, instanceKey),
|
|
76
|
-
[props.datasource, props.family, instanceKey],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const declaredError =
|
|
80
|
-
props.status === 'error'
|
|
81
|
-
? chartError('declared', 'declaredByApp', {
|
|
82
|
-
chartId: props.chartId,
|
|
83
|
-
family: props.family,
|
|
84
|
-
})
|
|
85
|
-
: undefined;
|
|
86
|
-
|
|
87
|
-
const error = validation.error ?? declaredError;
|
|
88
|
-
|
|
89
|
-
const status = resolveStatus({
|
|
63
|
+
const { validation, error, status } = useChartStatus<M>({
|
|
64
|
+
datasource: props.datasource,
|
|
65
|
+
family,
|
|
66
|
+
chartId: props.chartId,
|
|
67
|
+
instanceKey,
|
|
90
68
|
declared: props.status,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
error,
|
|
69
|
+
onFailure: props.onFailure,
|
|
70
|
+
distributionValues: props.distributionValues,
|
|
94
71
|
});
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
// render exception, so consumers have one place to watch.
|
|
98
|
-
//
|
|
99
|
-
// `onFailure` is held in a ref rather than listed as a dependency: an
|
|
100
|
-
// inline callback would otherwise re-fire the report on every render.
|
|
101
|
-
const reportedRef = useRef<string | undefined>(undefined);
|
|
102
|
-
const onFailureRef = useRef(props.onFailure);
|
|
103
|
-
onFailureRef.current = props.onFailure;
|
|
104
|
-
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (!validation.error) {
|
|
107
|
-
reportedRef.current = undefined;
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (reportedRef.current === validation.error.code) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
reportedRef.current = validation.error.code;
|
|
114
|
-
onFailureRef.current?.({
|
|
115
|
-
chartId: validation.error.chartId,
|
|
116
|
-
family: validation.error.family ?? family,
|
|
117
|
-
error: validation.error,
|
|
118
|
-
});
|
|
119
|
-
}, [validation.error, family]);
|
|
120
|
-
|
|
121
|
-
const formatValue = useMemo(() => {
|
|
122
|
-
const custom = props.formatters?.value;
|
|
123
|
-
return (v: ValueState): string =>
|
|
124
|
-
custom
|
|
125
|
-
? safeFormat(
|
|
126
|
-
() => custom(v, { slot: 'value', hint: props.datasource.format }),
|
|
127
|
-
() => formatValueDefault(v, { slot: 'value', hint: props.datasource.format }),
|
|
128
|
-
'value',
|
|
129
|
-
instanceKey,
|
|
130
|
-
)
|
|
131
|
-
: formatValueDefault(v, { slot: 'value', hint: props.datasource.format });
|
|
132
|
-
}, [props.formatters, props.datasource.format, instanceKey]);
|
|
133
|
-
|
|
134
|
-
const geometryFormatters = useMemo(
|
|
135
|
-
() => ({
|
|
136
|
-
formatValue: (n: number) => formatValue({ kind: 'known', value: n }),
|
|
137
|
-
formatCategory: (raw: string) => raw,
|
|
138
|
-
formatInstant: (at: number) => {
|
|
139
|
-
const custom = props.formatters?.axisLabel;
|
|
140
|
-
const position = { kind: 'instant' as const, at };
|
|
141
|
-
return custom
|
|
142
|
-
? safeFormat(
|
|
143
|
-
() => custom(position, { slot: 'axis' }),
|
|
144
|
-
() => formatPositionDefault(position),
|
|
145
|
-
'axis',
|
|
146
|
-
instanceKey,
|
|
147
|
-
)
|
|
148
|
-
: formatPositionDefault(position);
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
[formatValue, props.formatters, instanceKey],
|
|
152
|
-
);
|
|
73
|
+
const formatters = useChartFormatters(props.formatters, props.datasource.format, instanceKey);
|
|
153
74
|
|
|
154
75
|
const layout = useChartLayout(theme, { height: props.height });
|
|
155
76
|
const geometryInput = useGeometryInput(
|
|
156
77
|
props.datasource,
|
|
157
78
|
theme,
|
|
158
79
|
layout,
|
|
159
|
-
|
|
80
|
+
formatters.geometry,
|
|
160
81
|
family,
|
|
161
82
|
);
|
|
162
83
|
|
|
@@ -174,25 +95,18 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
174
95
|
|
|
175
96
|
// Runtime visibility has to reach the geometry, not just the legend —
|
|
176
97
|
// otherwise toggling a series greys out its swatch while the line keeps
|
|
177
|
-
// drawing (FR-049).
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
// builds a fresh Set every render and depending on it would invalidate the
|
|
181
|
-
// geometry memo constantly. With nothing hidden the original datasource is
|
|
182
|
-
// returned by identity, so the common case costs nothing.
|
|
183
|
-
const hiddenKey = Array.from(visibility.hidden)
|
|
184
|
-
.sort((a, b) => a.localeCompare(b))
|
|
185
|
-
.join(',');
|
|
98
|
+
// drawing (FR-049). With nothing hidden the original datasource is returned
|
|
99
|
+
// by identity, so the common case costs nothing.
|
|
100
|
+
const hidden = visibility.hidden;
|
|
186
101
|
const datasource = useMemo(() => {
|
|
187
|
-
if (
|
|
102
|
+
if (hidden.size === 0) {
|
|
188
103
|
return props.datasource;
|
|
189
104
|
}
|
|
190
|
-
const hidden = new Set(hiddenKey.split(','));
|
|
191
105
|
return {
|
|
192
106
|
...props.datasource,
|
|
193
107
|
series: props.datasource.series.map(s => (hidden.has(s.id) ? { ...s, hidden: true } : s)),
|
|
194
108
|
};
|
|
195
|
-
}, [props.datasource,
|
|
109
|
+
}, [props.datasource, hidden]);
|
|
196
110
|
|
|
197
111
|
// The single memoized artefact.
|
|
198
112
|
//
|
|
@@ -231,187 +145,62 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
231
145
|
props.hitTestMode === 'byX'
|
|
232
146
|
? hitTestByX(geometry.hitRegions, locationX)
|
|
233
147
|
: hitTest(geometry.hitRegions, locationX, locationY);
|
|
234
|
-
|
|
235
148
|
if (!hit) {
|
|
236
149
|
selection.select(null, null);
|
|
237
150
|
return;
|
|
238
151
|
}
|
|
239
152
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return;
|
|
153
|
+
const pointEvent = pointEventFor(props.datasource, hit.seriesId, hit.pointId);
|
|
154
|
+
if (pointEvent) {
|
|
155
|
+
selection.select({ seriesId: hit.seriesId, pointId: hit.pointId }, pointEvent);
|
|
244
156
|
}
|
|
245
|
-
|
|
246
|
-
const interactionEvent: PointInteractionEvent<M> = {
|
|
247
|
-
type: 'pointSelect',
|
|
248
|
-
seriesId: series.id,
|
|
249
|
-
pointId: point.id,
|
|
250
|
-
value: point.value,
|
|
251
|
-
position: point.position,
|
|
252
|
-
metadata: point.metadata,
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
selection.select({ seriesId: series.id, pointId: point.id }, interactionEvent);
|
|
256
157
|
};
|
|
257
158
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
* points, giving assistive-technology users the same point-by-point access
|
|
261
|
-
* that touch provides (FR-052, FR-072).
|
|
262
|
-
*/
|
|
263
|
-
const handleAccessibilityAction = (event: { nativeEvent: { actionName: string } }) => {
|
|
264
|
-
const regions = geometry.hitRegions.filter(r => r.selectable);
|
|
265
|
-
if (regions.length === 0) {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const step = event.nativeEvent.actionName === 'increment' ? 1 : -1;
|
|
270
|
-
const current = regions.findIndex(
|
|
271
|
-
r =>
|
|
272
|
-
r.seriesId === selection.selection?.seriesId && r.pointId === selection.selection?.pointId,
|
|
273
|
-
);
|
|
274
|
-
const next = regions[current < 0 ? 0 : (current + step + regions.length) % regions.length];
|
|
275
|
-
|
|
276
|
-
const series = props.datasource.series.find(s => s.id === next.seriesId);
|
|
277
|
-
const point = series?.points.find(p => p.id === next.pointId);
|
|
278
|
-
if (!series || !point) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
selection.select(
|
|
283
|
-
{ seriesId: series.id, pointId: point.id },
|
|
284
|
-
{
|
|
285
|
-
type: 'pointSelect',
|
|
286
|
-
seriesId: series.id,
|
|
287
|
-
pointId: point.id,
|
|
288
|
-
value: point.value,
|
|
289
|
-
position: point.position,
|
|
290
|
-
metadata: point.metadata,
|
|
291
|
-
},
|
|
292
|
-
);
|
|
293
|
-
};
|
|
159
|
+
const incomplete = geometry.incomplete || validation.totalIncomplete;
|
|
160
|
+
const interactive = props.accessibility?.interactive ?? Boolean(props.onSelectionChange);
|
|
294
161
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
return buildChartSummary({
|
|
300
|
-
datasource: props.datasource,
|
|
301
|
-
family: props.family,
|
|
302
|
-
status,
|
|
303
|
-
title: props.title,
|
|
304
|
-
formatValue,
|
|
305
|
-
incomplete: geometry.incomplete || validation.totalIncomplete,
|
|
306
|
-
});
|
|
307
|
-
}, [
|
|
308
|
-
props.accessibility,
|
|
309
|
-
props.datasource,
|
|
310
|
-
props.family,
|
|
311
|
-
props.title,
|
|
162
|
+
const a11y = useChartA11y<M>({
|
|
163
|
+
datasource: props.datasource,
|
|
164
|
+
family,
|
|
312
165
|
status,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const series = props.datasource.series.find(s => s.id === selection.resolved!.seriesId);
|
|
323
|
-
const point = series?.points.find(p => p.id === selection.resolved!.pointId);
|
|
324
|
-
return series && point ? { series, point } : undefined;
|
|
325
|
-
}, [selection.resolved, props.datasource.series]);
|
|
326
|
-
|
|
327
|
-
const interactive = props.accessibility?.interactive ?? Boolean(props.onSelectionChange);
|
|
166
|
+
title: props.title,
|
|
167
|
+
accessibility: props.accessibility,
|
|
168
|
+
interactive,
|
|
169
|
+
incomplete,
|
|
170
|
+
hitRegions: geometry.hitRegions,
|
|
171
|
+
selection,
|
|
172
|
+
formatters,
|
|
173
|
+
strings,
|
|
174
|
+
});
|
|
328
175
|
|
|
329
176
|
const legendEnabled =
|
|
330
177
|
props.legend === undefined ? (props.defaultLegend ?? false) : props.legend !== false;
|
|
331
|
-
|
|
178
|
+
const legendPosition = typeof props.legend === 'object' ? props.legend.position : 'bottom';
|
|
179
|
+
const legendLabel = formatters.legendLabel;
|
|
332
180
|
const legendEntries = useMemo(
|
|
333
181
|
() =>
|
|
334
182
|
legendEnabled
|
|
335
|
-
? buildLegendEntries(
|
|
183
|
+
? buildLegendEntries(
|
|
184
|
+
props.datasource,
|
|
185
|
+
theme,
|
|
186
|
+
hidden,
|
|
187
|
+
visibility.enabled,
|
|
188
|
+
family,
|
|
189
|
+
legendLabel,
|
|
190
|
+
)
|
|
336
191
|
: [],
|
|
337
|
-
[legendEnabled, props.datasource, theme,
|
|
192
|
+
[legendEnabled, props.datasource, theme, hidden, visibility.enabled, family, legendLabel],
|
|
338
193
|
);
|
|
194
|
+
const legend = legendEnabled ? (
|
|
195
|
+
<ChartLegend
|
|
196
|
+
entries={legendEntries}
|
|
197
|
+
theme={theme}
|
|
198
|
+
onToggle={visibility.enabled ? visibility.toggle : undefined}
|
|
199
|
+
strings={strings}
|
|
200
|
+
/>
|
|
201
|
+
) : null;
|
|
339
202
|
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
const renderStateOverlay = (currentError?: ChartError) => {
|
|
343
|
-
const supplied = props.stateContent?.[status];
|
|
344
|
-
if (supplied !== undefined) {
|
|
345
|
-
return <View style={styles.overlay}>{supplied}</View>;
|
|
346
|
-
}
|
|
347
|
-
// Minimal built-in wording, deliberately terse. Apps are expected to
|
|
348
|
-
// supply their own copy — the package must not impose product voice.
|
|
349
|
-
const fallback = builtInStateText(status, currentError);
|
|
350
|
-
if (!fallback) {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
return (
|
|
354
|
-
<View style={styles.overlay}>
|
|
355
|
-
<Text
|
|
356
|
-
style={{
|
|
357
|
-
color: theme.muted,
|
|
358
|
-
fontSize: theme.typography.valueLabel.fontSize,
|
|
359
|
-
}}
|
|
360
|
-
>
|
|
361
|
-
{fallback}
|
|
362
|
-
</Text>
|
|
363
|
-
</View>
|
|
364
|
-
);
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
const body = (currentError?: ChartError) => (
|
|
368
|
-
<View
|
|
369
|
-
style={[styles.plotArea, { height: props.height ?? DEFAULT_HEIGHT }]}
|
|
370
|
-
onLayout={layout.onLayout}
|
|
371
|
-
>
|
|
372
|
-
{layout.measured && showData ? (
|
|
373
|
-
<Pressable
|
|
374
|
-
onPress={handlePress}
|
|
375
|
-
disabled={!props.onSelectionChange}
|
|
376
|
-
style={StyleSheet.absoluteFill}
|
|
377
|
-
accessible={false}
|
|
378
|
-
importantForAccessibility="no-hide-descendants"
|
|
379
|
-
>
|
|
380
|
-
<Svg width={layout.size.width} height={layout.size.height}>
|
|
381
|
-
<NonValueTextures theme={theme} />
|
|
382
|
-
{props.showAxes !== false && (
|
|
383
|
-
<>
|
|
384
|
-
<Grid plot={geometry.plot} yTicks={geometry.yTicks} theme={theme} />
|
|
385
|
-
<YAxis
|
|
386
|
-
plot={geometry.plot}
|
|
387
|
-
ticks={geometry.yTicks}
|
|
388
|
-
theme={theme}
|
|
389
|
-
fontSize={layout.fontSize}
|
|
390
|
-
/>
|
|
391
|
-
<XAxis
|
|
392
|
-
plot={geometry.plot}
|
|
393
|
-
ticks={geometry.xTicks}
|
|
394
|
-
theme={theme}
|
|
395
|
-
fontSize={layout.fontSize}
|
|
396
|
-
/>
|
|
397
|
-
</>
|
|
398
|
-
)}
|
|
399
|
-
<Marks paths={geometry.paths} />
|
|
400
|
-
<MarkerLines markers={geometry.markers} theme={theme} />
|
|
401
|
-
<GeometryLabels labels={geometry.labels} theme={theme} fontSize={layout.fontSize} />
|
|
402
|
-
{selection.resolved && (
|
|
403
|
-
<SelectionHighlight
|
|
404
|
-
x={selection.resolved.x}
|
|
405
|
-
y={selection.resolved.y}
|
|
406
|
-
color={theme.onSurface}
|
|
407
|
-
/>
|
|
408
|
-
)}
|
|
409
|
-
</Svg>
|
|
410
|
-
</Pressable>
|
|
411
|
-
) : null}
|
|
412
|
-
{!showData || currentError ? renderStateOverlay(currentError) : null}
|
|
413
|
-
</View>
|
|
414
|
-
);
|
|
203
|
+
const plotHeight = useMemo(() => ({ height: props.height ?? DEFAULT_HEIGHT }), [props.height]);
|
|
415
204
|
|
|
416
205
|
const content = (
|
|
417
206
|
<>
|
|
@@ -430,62 +219,73 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
430
219
|
</Text>
|
|
431
220
|
) : null}
|
|
432
221
|
|
|
433
|
-
{
|
|
434
|
-
<ChartLegend
|
|
435
|
-
entries={legendEntries}
|
|
436
|
-
theme={theme}
|
|
437
|
-
onToggle={visibility.enabled ? visibility.toggle : undefined}
|
|
438
|
-
/>
|
|
439
|
-
) : null}
|
|
222
|
+
{legendPosition === 'top' ? legend : null}
|
|
440
223
|
|
|
441
224
|
<View
|
|
442
225
|
accessible
|
|
443
226
|
accessibilityRole={interactive ? 'adjustable' : 'image'}
|
|
444
|
-
accessibilityLabel={summary}
|
|
445
|
-
accessibilityValue={
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
text: buildPointAnnouncement(
|
|
449
|
-
selectedPoint.series.label,
|
|
450
|
-
formatPositionDefault(selectedPoint.point.position),
|
|
451
|
-
selectedPoint.point.value,
|
|
452
|
-
formatValue,
|
|
453
|
-
),
|
|
454
|
-
}
|
|
455
|
-
: undefined
|
|
456
|
-
}
|
|
457
|
-
accessibilityActions={
|
|
458
|
-
interactive
|
|
459
|
-
? [
|
|
460
|
-
{ name: 'increment', label: 'Next data point' },
|
|
461
|
-
{ name: 'decrement', label: 'Previous data point' },
|
|
462
|
-
]
|
|
463
|
-
: undefined
|
|
464
|
-
}
|
|
465
|
-
onAccessibilityAction={interactive ? handleAccessibilityAction : undefined}
|
|
227
|
+
accessibilityLabel={a11y.summary}
|
|
228
|
+
accessibilityValue={a11y.value}
|
|
229
|
+
accessibilityActions={a11y.actions}
|
|
230
|
+
onAccessibilityAction={a11y.onAction}
|
|
466
231
|
>
|
|
467
|
-
{
|
|
232
|
+
<View style={[styles.plotArea, plotHeight]} onLayout={layout.onLayout}>
|
|
233
|
+
{layout.measured && showData ? (
|
|
234
|
+
<Pressable
|
|
235
|
+
onPress={handlePress}
|
|
236
|
+
disabled={!props.onSelectionChange}
|
|
237
|
+
style={StyleSheet.absoluteFill}
|
|
238
|
+
accessible={false}
|
|
239
|
+
importantForAccessibility="no-hide-descendants"
|
|
240
|
+
>
|
|
241
|
+
<Svg width={layout.size.width} height={layout.size.height}>
|
|
242
|
+
<ChartPlot
|
|
243
|
+
geometry={geometry}
|
|
244
|
+
theme={theme}
|
|
245
|
+
fontSize={layout.fontSize}
|
|
246
|
+
axes={props.axes ?? 'full'}
|
|
247
|
+
/>
|
|
248
|
+
{selection.resolved && (
|
|
249
|
+
<SelectionHighlight
|
|
250
|
+
x={selection.resolved.x}
|
|
251
|
+
y={selection.resolved.y}
|
|
252
|
+
color={theme.onSurface}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
</Svg>
|
|
256
|
+
</Pressable>
|
|
257
|
+
) : null}
|
|
258
|
+
{!showData || error ? (
|
|
259
|
+
<ChartStateOverlay
|
|
260
|
+
status={status}
|
|
261
|
+
supplied={props.stateContent?.[status]}
|
|
262
|
+
strings={strings}
|
|
263
|
+
theme={theme}
|
|
264
|
+
error={error}
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</View>
|
|
468
268
|
</View>
|
|
469
269
|
|
|
470
|
-
{
|
|
471
|
-
|
|
472
|
-
|
|
270
|
+
{legendPosition === 'bottom' ? legend : null}
|
|
271
|
+
|
|
272
|
+
{geometry.colorScale && props.legend !== false ? (
|
|
273
|
+
<ColorScaleLegend
|
|
274
|
+
scale={geometry.colorScale}
|
|
473
275
|
theme={theme}
|
|
474
|
-
|
|
276
|
+
formatValue={formatters.geometry.formatValue}
|
|
277
|
+
strings={strings}
|
|
475
278
|
/>
|
|
476
279
|
) : null}
|
|
477
280
|
|
|
478
|
-
{
|
|
281
|
+
{incomplete ? (
|
|
479
282
|
<Text
|
|
480
283
|
style={[
|
|
481
284
|
styles.note,
|
|
482
|
-
{
|
|
483
|
-
color: theme.muted,
|
|
484
|
-
fontSize: theme.typography.axisLabel.fontSize,
|
|
485
|
-
},
|
|
285
|
+
{ color: theme.muted, fontSize: theme.typography.axisLabel.fontSize },
|
|
486
286
|
]}
|
|
487
287
|
>
|
|
488
|
-
|
|
288
|
+
{strings['chart.note.incomplete']}
|
|
489
289
|
</Text>
|
|
490
290
|
) : null}
|
|
491
291
|
|
|
@@ -511,21 +311,14 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
511
311
|
<View
|
|
512
312
|
accessible
|
|
513
313
|
accessibilityRole="image"
|
|
514
|
-
accessibilityLabel={summary}
|
|
515
|
-
style={[styles.plotArea,
|
|
314
|
+
accessibilityLabel={a11y.summary}
|
|
315
|
+
style={[styles.plotArea, plotHeight]}
|
|
516
316
|
>
|
|
517
|
-
<
|
|
518
|
-
{props.stateContent?.error
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
fontSize: theme.typography.valueLabel.fontSize,
|
|
523
|
-
}}
|
|
524
|
-
>
|
|
525
|
-
{containedError.message}
|
|
526
|
-
</Text>
|
|
527
|
-
)}
|
|
528
|
-
</View>
|
|
317
|
+
<ChartFailureOverlay
|
|
318
|
+
supplied={props.stateContent?.error}
|
|
319
|
+
theme={theme}
|
|
320
|
+
error={containedError}
|
|
321
|
+
/>
|
|
529
322
|
</View>
|
|
530
323
|
)}
|
|
531
324
|
>
|
|
@@ -535,24 +328,6 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
535
328
|
);
|
|
536
329
|
}
|
|
537
330
|
|
|
538
|
-
function builtInStateText(
|
|
539
|
-
status: ReturnType<typeof resolveStatus>,
|
|
540
|
-
error?: ChartError,
|
|
541
|
-
): string | undefined {
|
|
542
|
-
switch (status) {
|
|
543
|
-
case 'loading':
|
|
544
|
-
return 'Loading…';
|
|
545
|
-
case 'empty':
|
|
546
|
-
return 'No data yet';
|
|
547
|
-
case 'error':
|
|
548
|
-
return error?.message ?? 'Unable to show this chart';
|
|
549
|
-
case 'noPermission':
|
|
550
|
-
return 'Not available';
|
|
551
|
-
default:
|
|
552
|
-
return undefined;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
331
|
const styles = StyleSheet.create({
|
|
557
332
|
// No outer margin: app content must be able to sit flush against a chart.
|
|
558
333
|
container: {
|
|
@@ -566,15 +341,6 @@ const styles = StyleSheet.create({
|
|
|
566
341
|
width: '100%',
|
|
567
342
|
position: 'relative',
|
|
568
343
|
},
|
|
569
|
-
overlay: {
|
|
570
|
-
position: 'absolute',
|
|
571
|
-
top: 0,
|
|
572
|
-
left: 0,
|
|
573
|
-
right: 0,
|
|
574
|
-
bottom: 0,
|
|
575
|
-
alignItems: 'center',
|
|
576
|
-
justifyContent: 'center',
|
|
577
|
-
},
|
|
578
344
|
// Colour and type size are applied inline from ChartTheme, so they follow
|
|
579
345
|
// the consuming app's tokens and the system text scale (UX-001).
|
|
580
346
|
note: {
|