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