@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.
@@ -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, { useEffect, useId, useMemo, useRef } from '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 { ChartError } from '../contract/errors';
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, resolveStatus, suppressesInteraction } from '../contract/state';
23
- import type { ValueState } from '../contract/value';
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 { releaseBudgetWarning, warnIfOverBudget } from '../validation/budgets';
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
- readonly showAxes?: boolean;
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
- // Warn once per chart instance, dev only; release the key on unmount so a
66
- // remount warns again.
67
- useEffect(() => () => releaseBudgetWarning(instanceKey), [instanceKey]);
68
-
69
- const validation = useMemo(
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
- isEmpty: validation.isEmpty,
92
- isPartial: validation.isPartial,
93
- error,
69
+ onFailure: props.onFailure,
70
+ distributionValues: props.distributionValues,
94
71
  });
95
72
 
96
- // Report a validation failure through the same channel as a contained
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
- geometryFormatters,
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
- // Keyed on a sorted string rather than the Set, because `useSeriesVisibility`
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 (hiddenKey === '') {
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, hiddenKey]);
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 series = props.datasource.series.find(s => s.id === hit.seriesId);
241
- const point = series?.points.find(p => p.id === hit.pointId);
242
- if (!series || !point) {
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
- * Screen-reader traversal: increment and decrement walk the selectable
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 summary = useMemo(() => {
296
- if (props.accessibility?.label) {
297
- return props.accessibility.label;
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
- formatValue,
314
- geometry.incomplete,
315
- validation.totalIncomplete,
316
- ]);
317
-
318
- const selectedPoint = useMemo(() => {
319
- if (!selection.resolved) {
320
- return undefined;
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(props.datasource, theme, visibility.hidden, visibility.enabled, family)
183
+ ? buildLegendEntries(
184
+ props.datasource,
185
+ theme,
186
+ hidden,
187
+ visibility.enabled,
188
+ family,
189
+ legendLabel,
190
+ )
336
191
  : [],
337
- [legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled, family],
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 legendPosition = typeof props.legend === 'object' ? props.legend.position : 'bottom';
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
- {legendEnabled && legendPosition === 'top' ? (
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
- interactive && selectedPoint
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
- {body(error)}
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
- {legendEnabled && legendPosition === 'bottom' ? (
471
- <ChartLegend
472
- entries={legendEntries}
270
+ {legendPosition === 'bottom' ? legend : null}
271
+
272
+ {geometry.colorScale && props.legend !== false ? (
273
+ <ColorScaleLegend
274
+ scale={geometry.colorScale}
473
275
  theme={theme}
474
- onToggle={visibility.enabled ? visibility.toggle : undefined}
276
+ formatValue={formatters.geometry.formatValue}
277
+ strings={strings}
475
278
  />
476
279
  ) : null}
477
280
 
478
- {geometry.incomplete || validation.totalIncomplete ? (
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
- Some values are unavailable, so totals are incomplete.
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, { height: props.height ?? DEFAULT_HEIGHT }]}
314
+ accessibilityLabel={a11y.summary}
315
+ style={[styles.plotArea, plotHeight]}
516
316
  >
517
- <View style={styles.overlay}>
518
- {props.stateContent?.error ?? (
519
- <Text
520
- style={{
521
- color: theme.muted,
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: {