@chipmobilesdk/rn-chart 0.2.0 → 0.2.1

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