@chipmobilesdk/rn-chart 0.1.1 → 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 -74
  2. package/README.md +337 -314
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +1 -2
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -134
  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 -139
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -256
  16. package/src/geometry/distribution.ts +197 -200
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -121
  20. package/src/geometry/markers.ts +55 -0
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -204
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -158
  26. package/src/geometry/types.ts +94 -88
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -620
  30. package/src/react/ChartLegend.tsx +211 -177
  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,620 +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
- );
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
- });
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
+ });