@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.
- package/CHANGELOG.md +126 -74
- package/README.md +337 -314
- package/package.json +46 -49
- package/skills/sdk-rn-chart/SKILL.md +1 -2
- package/src/a11y/summary.ts +170 -177
- package/src/contract/datasource.ts +126 -134
- package/src/contract/errors.ts +129 -133
- package/src/contract/format.ts +162 -168
- package/src/contract/interaction.ts +70 -71
- package/src/contract/marker.ts +75 -75
- package/src/contract/props.ts +57 -60
- package/src/contract/state.ts +88 -94
- package/src/contract/theme.ts +169 -139
- package/src/contract/value.ts +121 -125
- package/src/geometry/bar.ts +271 -256
- package/src/geometry/distribution.ts +197 -200
- package/src/geometry/heatmap.ts +189 -195
- package/src/geometry/hitTest.ts +117 -120
- package/src/geometry/layout.ts +162 -121
- package/src/geometry/markers.ts +55 -0
- package/src/geometry/path.ts +193 -199
- package/src/geometry/progress.ts +223 -204
- package/src/geometry/scale.ts +140 -146
- package/src/geometry/ticks.ts +145 -153
- package/src/geometry/timeSeries.ts +168 -158
- package/src/geometry/types.ts +94 -88
- package/src/index.ts +158 -176
- package/src/react/ChartBoundary.tsx +96 -96
- package/src/react/ChartFrame.tsx +583 -620
- package/src/react/ChartLegend.tsx +211 -177
- package/src/react/ChartThemeProvider.tsx +44 -48
- package/src/react/charts.tsx +154 -161
- package/src/react/useChartLayout.ts +124 -124
- package/src/react/useChartSelection.ts +152 -170
- package/src/render/Axis.tsx +120 -122
- package/src/render/Marks.tsx +173 -205
- package/src/validation/budgets.ts +103 -110
- package/src/validation/validateDatasource.ts +174 -174
package/src/react/ChartFrame.tsx
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from '
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
import
|
|
23
|
-
import type {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from '../
|
|
30
|
-
import
|
|
31
|
-
import
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
props.status
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
props.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
props.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
{
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
>
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
+
});
|