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