@chipmobilesdk/rn-chart 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +164 -115
  2. package/README.md +391 -381
  3. package/package.json +49 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +190 -177
  6. package/src/contract/datasource.ts +126 -136
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +168 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +64 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/strings.ts +48 -0
  14. package/src/contract/theme.ts +169 -177
  15. package/src/contract/value.ts +121 -125
  16. package/src/geometry/bar.ts +271 -273
  17. package/src/geometry/distribution.ts +197 -203
  18. package/src/geometry/heatmap.ts +189 -195
  19. package/src/geometry/hitTest.ts +117 -120
  20. package/src/geometry/layout.ts +162 -173
  21. package/src/geometry/markers.ts +55 -57
  22. package/src/geometry/path.ts +193 -199
  23. package/src/geometry/progress.ts +223 -230
  24. package/src/geometry/scale.ts +140 -146
  25. package/src/geometry/ticks.ts +145 -153
  26. package/src/geometry/timeSeries.ts +168 -168
  27. package/src/geometry/types.ts +94 -94
  28. package/src/index.ts +162 -176
  29. package/src/react/ChartBoundary.tsx +96 -96
  30. package/src/react/ChartFrame.tsx +596 -622
  31. package/src/react/ChartLegend.tsx +216 -215
  32. package/src/react/ChartThemeProvider.tsx +44 -48
  33. package/src/react/charts.tsx +154 -161
  34. package/src/react/useChartLayout.ts +124 -124
  35. package/src/react/useChartSelection.ts +152 -170
  36. package/src/render/Axis.tsx +120 -122
  37. package/src/render/Marks.tsx +173 -205
  38. package/src/validation/budgets.ts +113 -110
  39. package/src/validation/validateDatasource.ts +174 -174
@@ -1,622 +1,596 @@
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 { 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';
40
+ import { ChartBoundary } from './ChartBoundary';
41
+ import { ChartLegend, buildLegendEntries } from './ChartLegend';
42
+ import { useChartTheme } from './ChartThemeProvider';
43
+ import { useChartLayout, useGeometryInput } from './useChartLayout';
44
+ import { useChartSelection, useSeriesVisibility } from './useChartSelection';
45
+
46
+ export interface ChartFrameProps<M> extends BaseChartProps<M> {
47
+ readonly family: ChartFamily;
48
+ readonly buildGeometry: (
49
+ datasource: ChartDatasource<M>,
50
+ input: GeometryInput,
51
+ ) => ResolvedGeometry;
52
+ /** Time-series selection tracks the x axis; other families use proximity. */
53
+ readonly hitTestMode?: 'nearest' | 'byX';
54
+ readonly showAxes?: boolean;
55
+ readonly defaultLegend?: boolean;
56
+ }
57
+
58
+ const DEFAULT_HEIGHT = 220;
59
+
60
+ export function ChartFrame<M>(props: ChartFrameProps<M>) {
61
+ const family = props.family;
62
+ const theme = useChartTheme(props.theme);
63
+ const strings = useMemo(() => chartStrings.resolve(props.strings), [props.strings]);
64
+ const generatedId = useId();
65
+ const instanceKey = props.chartId ?? generatedId;
66
+
67
+ // Warn once per chart instance, dev only; release the key on unmount so a
68
+ // remount warns again.
69
+ useEffect(() => () => releaseBudgetWarning(instanceKey), [instanceKey]);
70
+
71
+ const validation = useMemo(
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({
92
+ declared: props.status,
93
+ isEmpty: validation.isEmpty,
94
+ isPartial: validation.isPartial,
95
+ error,
96
+ });
97
+
98
+ // Report a validation failure through the same channel as a contained
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
+ );
155
+
156
+ const layout = useChartLayout(theme, { height: props.height });
157
+ const geometryInput = useGeometryInput(
158
+ props.datasource,
159
+ theme,
160
+ layout,
161
+ geometryFormatters,
162
+ family,
163
+ );
164
+
165
+ const showData = drawsData(status);
166
+
167
+ const visibility = useSeriesVisibility<M>({
168
+ controlled: props.hiddenSeriesIds,
169
+ onVisibilityChange: props.onVisibilityChange,
170
+ enabled: props.seriesToggleEnabled === true,
171
+ initiallyHidden: useMemo(
172
+ () => props.datasource.series.filter(s => s.hidden).map(s => s.id),
173
+ [props.datasource.series],
174
+ ),
175
+ });
176
+
177
+ // Runtime visibility has to reach the geometry, not just the legend —
178
+ // otherwise toggling a series greys out its swatch while the line keeps
179
+ // drawing (FR-049).
180
+ //
181
+ // Keyed on a sorted string rather than the Set, because `useSeriesVisibility`
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(',');
188
+ const datasource = useMemo(() => {
189
+ if (hiddenKey === '') {
190
+ return props.datasource;
191
+ }
192
+ const hidden = new Set(hiddenKey.split(','));
193
+ return {
194
+ ...props.datasource,
195
+ series: props.datasource.series.map(s => (hidden.has(s.id) ? { ...s, hidden: true } : s)),
196
+ };
197
+ }, [props.datasource, hiddenKey]);
198
+
199
+ // The single memoized artefact.
200
+ //
201
+ // The dependency list is narrow on purpose. Depending on the whole `props`
202
+ // object would invalidate the memo whenever any callback identity changed —
203
+ // and inline arrow callbacks are the norm in app code, so that would defeat
204
+ // FR-073 in exactly the case it exists for. Selection is absent for the same
205
+ // reason: highlighting repaints the highlight, it does not rebuild scales.
206
+ const buildGeometry = props.buildGeometry;
207
+ const geometry = useMemo(() => {
208
+ if (!layout.measured || !showData) {
209
+ return EMPTY_GEOMETRY;
210
+ }
211
+ return buildGeometry(datasource, geometryInput);
212
+ }, [layout.measured, showData, buildGeometry, datasource, geometryInput]);
213
+
214
+ const selection = useChartSelection<M>({
215
+ controlled: props.selection,
216
+ onSelectionChange: props.onSelectionChange,
217
+ onSelectionUnresolvable: props.onSelectionUnresolvable,
218
+ hitRegions: geometry.hitRegions,
219
+ interactionSuppressed: suppressesInteraction(status),
220
+ geometryReady: layout.measured && showData && geometry.hitRegions.length > 0,
221
+ });
222
+
223
+ const handlePress = (event: GestureResponderEvent) => {
224
+ if (suppressesInteraction(status) || !props.onSelectionChange) {
225
+ return;
226
+ }
227
+ const { locationX, locationY } = event.nativeEvent;
228
+ if (!containsPoint(geometry.plot, locationX, locationY)) {
229
+ return;
230
+ }
231
+
232
+ const hit =
233
+ props.hitTestMode === 'byX'
234
+ ? hitTestByX(geometry.hitRegions, locationX)
235
+ : hitTest(geometry.hitRegions, locationX, locationY);
236
+
237
+ if (!hit) {
238
+ selection.select(null, null);
239
+ return;
240
+ }
241
+
242
+ const series = props.datasource.series.find(s => s.id === hit.seriesId);
243
+ const point = series?.points.find(p => p.id === hit.pointId);
244
+ if (!series || !point) {
245
+ return;
246
+ }
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
+ };
259
+
260
+ /**
261
+ * Screen-reader traversal: increment and decrement walk the selectable
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
+ };
296
+
297
+ const summary = useMemo(() => {
298
+ if (props.accessibility?.label) {
299
+ return props.accessibility.label;
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,
321
+ status,
322
+ formatValue,
323
+ geometry.incomplete,
324
+ validation.totalIncomplete,
325
+ ]);
326
+
327
+ const selectedPoint = useMemo(() => {
328
+ if (!selection.resolved) {
329
+ return undefined;
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);
337
+
338
+ const legendEnabled =
339
+ props.legend === undefined ? (props.defaultLegend ?? false) : props.legend !== false;
340
+
341
+ const legendEntries = useMemo(
342
+ () =>
343
+ legendEnabled
344
+ ? buildLegendEntries(props.datasource, theme, visibility.hidden, visibility.enabled, family)
345
+ : [],
346
+ [legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled, family],
347
+ );
348
+
349
+ const legendPosition = typeof props.legend === 'object' ? props.legend.position : 'bottom';
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
+ );
424
+
425
+ const content = (
426
+ <>
427
+ {props.title ? (
428
+ <Text
429
+ style={[
430
+ styles.title,
431
+ {
432
+ color: theme.typography.valueLabel.color,
433
+ fontSize: theme.typography.valueLabel.fontSize,
434
+ },
435
+ ]}
436
+ accessibilityRole="header"
437
+ >
438
+ {props.title}
439
+ </Text>
440
+ ) : null}
441
+
442
+ {legendEnabled && legendPosition === 'top' ? (
443
+ <ChartLegend
444
+ entries={legendEntries}
445
+ theme={theme}
446
+ onToggle={visibility.enabled ? visibility.toggle : undefined}
447
+ strings={strings}
448
+ />
449
+ ) : null}
450
+
451
+ <View
452
+ accessible
453
+ accessibilityRole={interactive ? 'adjustable' : 'image'}
454
+ accessibilityLabel={summary}
455
+ accessibilityValue={
456
+ interactive && selectedPoint
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}
477
+ >
478
+ {body(error)}
479
+ </View>
480
+
481
+ {legendEnabled && legendPosition === 'bottom' ? (
482
+ <ChartLegend
483
+ entries={legendEntries}
484
+ theme={theme}
485
+ onToggle={visibility.enabled ? visibility.toggle : undefined}
486
+ strings={strings}
487
+ />
488
+ ) : null}
489
+
490
+ {geometry.incomplete || validation.totalIncomplete ? (
491
+ <Text
492
+ style={[
493
+ styles.note,
494
+ {
495
+ color: theme.muted,
496
+ fontSize: theme.typography.axisLabel.fontSize,
497
+ },
498
+ ]}
499
+ >
500
+ Some values are unavailable, so totals are incomplete.
501
+ </Text>
502
+ ) : null}
503
+
504
+ {props.footer}
505
+ </>
506
+ );
507
+
508
+ return (
509
+ <View style={[styles.container, { backgroundColor: theme.surface }, props.style]}>
510
+ {/*
511
+ The boundary wraps the chart's whole subtree, not just the plot.
512
+ A defect anywhere inside — geometry, the SVG substrate, or content
513
+ the app passed in as state copy or a footer — must collapse this one
514
+ chart and leave its siblings and the surrounding screen alive
515
+ (FR-031, FR-038a).
516
+ */}
517
+ <ChartBoundary
518
+ family={props.family}
519
+ chartId={props.chartId}
520
+ onFailure={props.onFailure}
521
+ resetKey={props.datasource}
522
+ renderFallback={containedError => (
523
+ <View
524
+ accessible
525
+ accessibilityRole="image"
526
+ accessibilityLabel={summary}
527
+ style={[styles.plotArea, { height: props.height ?? DEFAULT_HEIGHT }]}
528
+ >
529
+ <View style={styles.overlay}>
530
+ {props.stateContent?.error ?? (
531
+ <Text
532
+ style={{
533
+ color: theme.muted,
534
+ fontSize: theme.typography.valueLabel.fontSize,
535
+ }}
536
+ >
537
+ {containedError.message}
538
+ </Text>
539
+ )}
540
+ </View>
541
+ </View>
542
+ )}
543
+ >
544
+ {content}
545
+ </ChartBoundary>
546
+ </View>
547
+ );
548
+ }
549
+
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
+ const styles = StyleSheet.create({
570
+ // No outer margin: app content must be able to sit flush against a chart.
571
+ container: {
572
+ width: '100%',
573
+ },
574
+ title: {
575
+ fontWeight: '600',
576
+ marginBottom: 4,
577
+ },
578
+ plotArea: {
579
+ width: '100%',
580
+ position: 'relative',
581
+ },
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
+ // Colour and type size are applied inline from ChartTheme, so they follow
592
+ // the consuming app's tokens and the system text scale (UX-001).
593
+ note: {
594
+ marginTop: 4,
595
+ },
596
+ });