@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,205 +1,173 @@
1
- /**
2
- * Private SVG adapter for resolved geometry.
3
- *
4
- * Everything here consumes plain data produced by `geometry/`. Swapping the
5
- * drawing substrate means rewriting this directory and nothing else.
6
- */
7
- import React from 'react';
8
- import {
9
- Defs,
10
- G,
11
- Path,
12
- Pattern,
13
- Rect,
14
- Text as SvgText,
15
- } from 'react-native-svg';
16
-
17
- import type { SeriesPattern } from '../contract/datasource';
18
- import type { ChartTheme, NonValueTexture } from '../contract/theme';
19
- import type { GeometryLabel, MarkPath, MarkerLine } from '../geometry/types';
20
-
21
- /**
22
- * Dash patterns give series a second, non-colour identity (FR-064). `undefined`
23
- * means a solid stroke.
24
- */
25
- export function dashArrayFor(pattern: SeriesPattern): string | undefined {
26
- switch (pattern) {
27
- case 'dashed':
28
- return '6,4';
29
- case 'dotted':
30
- return '1,4';
31
- case 'dashDot':
32
- return '8,3,1,3';
33
- default:
34
- return undefined;
35
- }
36
- }
37
-
38
- const TEXTURE_IDS: Record<NonValueTexture, string> = {
39
- gap: 'rnchart-texture-gap',
40
- hatch: 'rnchart-texture-hatch',
41
- lockedHatch: 'rnchart-texture-locked',
42
- };
43
-
44
- /**
45
- * Texture definitions for the three non-value classes.
46
- *
47
- * Deliberately shape-based rather than colour-based: a reader in greyscale, or
48
- * with reduced colour perception, must still tell a gap from a hatch from a
49
- * locked cell (FR-064, SC-012).
50
- */
51
- export function NonValueTextures({ theme }: { readonly theme: ChartTheme }) {
52
- return (
53
- <Defs>
54
- {/* unknown: sparse dots — visibly "nothing here". */}
55
- <Pattern
56
- id={TEXTURE_IDS.gap}
57
- patternUnits="userSpaceOnUse"
58
- width="6"
59
- height="6"
60
- >
61
- <Rect width="6" height="6" fill={theme.surface} />
62
- <Rect x="2" y="2" width="1.5" height="1.5" fill={theme.muted} />
63
- </Pattern>
64
-
65
- {/* notApplicable: single diagonal hatch. */}
66
- <Pattern
67
- id={TEXTURE_IDS.hatch}
68
- patternUnits="userSpaceOnUse"
69
- width="8"
70
- height="8"
71
- >
72
- <Rect width="8" height="8" fill={theme.surface} />
73
- <Path d="M0 8L8 0" stroke={theme.muted} strokeWidth="1" />
74
- </Pattern>
75
-
76
- {/* withheld: cross hatch — denser, reading as deliberately closed off. */}
77
- <Pattern
78
- id={TEXTURE_IDS.lockedHatch}
79
- patternUnits="userSpaceOnUse"
80
- width="8"
81
- height="8"
82
- >
83
- <Rect width="8" height="8" fill={theme.surface} />
84
- <Path d="M0 8L8 0M0 0L8 8" stroke={theme.muted} strokeWidth="1" />
85
- </Pattern>
86
- </Defs>
87
- );
88
- }
89
-
90
- function fillFor(mark: MarkPath): string {
91
- if (mark.texture) {
92
- return `url(#${TEXTURE_IDS[mark.texture]})`;
93
- }
94
- return mark.filled ? mark.color : 'none';
95
- }
96
-
97
- export function Marks({
98
- paths,
99
- selectedKeys,
100
- }: {
101
- readonly paths: readonly MarkPath[];
102
- readonly selectedKeys?: ReadonlySet<string>;
103
- }) {
104
- return (
105
- <G>
106
- {paths.map(mark => {
107
- const highlighted = selectedKeys?.has(mark.seriesId);
108
- return (
109
- <Path
110
- key={mark.key}
111
- d={mark.d}
112
- fill={fillFor(mark)}
113
- stroke={mark.filled ? 'none' : mark.color}
114
- strokeWidth={mark.filled ? 0 : highlighted ? 3 : 2}
115
- strokeDasharray={
116
- mark.filled ? undefined : dashArrayFor(mark.pattern)
117
- }
118
- strokeLinejoin="round"
119
- strokeLinecap="round"
120
- opacity={mark.opacity ?? 1}
121
- />
122
- );
123
- })}
124
- </G>
125
- );
126
- }
127
-
128
- export function MarkerLines({
129
- markers,
130
- theme,
131
- }: {
132
- readonly markers: readonly MarkerLine[];
133
- readonly theme: ChartTheme;
134
- }) {
135
- return (
136
- <G>
137
- {markers.map(marker => (
138
- <Path
139
- key={marker.id}
140
- d={marker.d}
141
- stroke={theme.onSurface}
142
- strokeWidth={1}
143
- strokeDasharray="4,3"
144
- fill="none"
145
- />
146
- ))}
147
- </G>
148
- );
149
- }
150
-
151
- export function GeometryLabels({
152
- labels,
153
- theme,
154
- fontSize,
155
- }: {
156
- readonly labels: readonly GeometryLabel[];
157
- readonly theme: ChartTheme;
158
- readonly fontSize: number;
159
- }) {
160
- return (
161
- <G>
162
- {labels
163
- .filter(label => !label.muted)
164
- .map(label => (
165
- <SvgText
166
- key={label.key}
167
- x={label.x}
168
- y={label.y}
169
- fill={theme.typography.valueLabel.color}
170
- fontSize={fontSize}
171
- fontWeight={theme.typography.valueLabel.fontWeight}
172
- textAnchor={label.anchor}
173
- >
174
- {label.text}
175
- </SvgText>
176
- ))}
177
- </G>
178
- );
179
- }
180
-
181
- /** Marks the currently selected point, when it still resolves. */
182
- export function SelectionHighlight({
183
- x,
184
- y,
185
- color,
186
- }: {
187
- readonly x: number;
188
- readonly y: number;
189
- readonly color: string;
190
- }) {
191
- return (
192
- <G>
193
- <Rect
194
- x={x - 6}
195
- y={y - 6}
196
- width={12}
197
- height={12}
198
- rx={6}
199
- fill={color}
200
- opacity={0.25}
201
- />
202
- <Rect x={x - 3} y={y - 3} width={6} height={6} rx={3} fill={color} />
203
- </G>
204
- );
205
- }
1
+ /**
2
+ * Private SVG adapter for resolved geometry.
3
+ *
4
+ * Everything here consumes plain data produced by `geometry/`. Swapping the
5
+ * drawing substrate means rewriting this directory and nothing else.
6
+ */
7
+ import React from 'react';
8
+ import { Defs, G, Path, Pattern, Rect, Text as SvgText } from 'react-native-svg';
9
+
10
+ import type { SeriesPattern } from '../contract/datasource';
11
+ import type { ChartTheme, NonValueTexture } from '../contract/theme';
12
+ import type { GeometryLabel, MarkPath, MarkerLine } from '../geometry/types';
13
+
14
+ /**
15
+ * Dash patterns give series a second, non-colour identity (FR-064). `undefined`
16
+ * means a solid stroke.
17
+ */
18
+ export function dashArrayFor(pattern: SeriesPattern): string | undefined {
19
+ switch (pattern) {
20
+ case 'dashed':
21
+ return '6,4';
22
+ case 'dotted':
23
+ return '1,4';
24
+ case 'dashDot':
25
+ return '8,3,1,3';
26
+ default:
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ const TEXTURE_IDS: Record<NonValueTexture, string> = {
32
+ gap: 'rnchart-texture-gap',
33
+ hatch: 'rnchart-texture-hatch',
34
+ lockedHatch: 'rnchart-texture-locked',
35
+ };
36
+
37
+ /**
38
+ * Texture definitions for the three non-value classes.
39
+ *
40
+ * Deliberately shape-based rather than colour-based: a reader in greyscale, or
41
+ * with reduced colour perception, must still tell a gap from a hatch from a
42
+ * locked cell (FR-064, SC-012).
43
+ */
44
+ export function NonValueTextures({ theme }: { readonly theme: ChartTheme }) {
45
+ return (
46
+ <Defs>
47
+ {/* unknown: sparse dots — visibly "nothing here". */}
48
+ <Pattern id={TEXTURE_IDS.gap} patternUnits="userSpaceOnUse" width="6" height="6">
49
+ <Rect width="6" height="6" fill={theme.surface} />
50
+ <Rect x="2" y="2" width="1.5" height="1.5" fill={theme.muted} />
51
+ </Pattern>
52
+
53
+ {/* notApplicable: single diagonal hatch. */}
54
+ <Pattern id={TEXTURE_IDS.hatch} patternUnits="userSpaceOnUse" width="8" height="8">
55
+ <Rect width="8" height="8" fill={theme.surface} />
56
+ <Path d="M0 8L8 0" stroke={theme.muted} strokeWidth="1" />
57
+ </Pattern>
58
+
59
+ {/* withheld: cross hatch — denser, reading as deliberately closed off. */}
60
+ <Pattern id={TEXTURE_IDS.lockedHatch} patternUnits="userSpaceOnUse" width="8" height="8">
61
+ <Rect width="8" height="8" fill={theme.surface} />
62
+ <Path d="M0 8L8 0M0 0L8 8" stroke={theme.muted} strokeWidth="1" />
63
+ </Pattern>
64
+ </Defs>
65
+ );
66
+ }
67
+
68
+ function fillFor(mark: MarkPath): string {
69
+ if (mark.texture) {
70
+ return `url(#${TEXTURE_IDS[mark.texture]})`;
71
+ }
72
+ return mark.filled ? mark.color : 'none';
73
+ }
74
+
75
+ export function Marks({
76
+ paths,
77
+ selectedKeys,
78
+ }: {
79
+ readonly paths: readonly MarkPath[];
80
+ readonly selectedKeys?: ReadonlySet<string>;
81
+ }) {
82
+ return (
83
+ <G>
84
+ {paths.map(mark => {
85
+ const highlighted = selectedKeys?.has(mark.seriesId);
86
+ return (
87
+ <Path
88
+ key={mark.key}
89
+ d={mark.d}
90
+ fill={fillFor(mark)}
91
+ stroke={mark.filled ? 'none' : mark.color}
92
+ strokeWidth={mark.filled ? 0 : highlighted ? 3 : 2}
93
+ strokeDasharray={mark.filled ? undefined : dashArrayFor(mark.pattern)}
94
+ strokeLinejoin="round"
95
+ strokeLinecap="round"
96
+ opacity={mark.opacity ?? 1}
97
+ />
98
+ );
99
+ })}
100
+ </G>
101
+ );
102
+ }
103
+
104
+ export function MarkerLines({
105
+ markers,
106
+ theme,
107
+ }: {
108
+ readonly markers: readonly MarkerLine[];
109
+ readonly theme: ChartTheme;
110
+ }) {
111
+ return (
112
+ <G>
113
+ {markers.map(marker => (
114
+ <Path
115
+ key={marker.id}
116
+ d={marker.d}
117
+ stroke={theme.onSurface}
118
+ strokeWidth={1}
119
+ strokeDasharray="4,3"
120
+ fill="none"
121
+ />
122
+ ))}
123
+ </G>
124
+ );
125
+ }
126
+
127
+ export function GeometryLabels({
128
+ labels,
129
+ theme,
130
+ fontSize,
131
+ }: {
132
+ readonly labels: readonly GeometryLabel[];
133
+ readonly theme: ChartTheme;
134
+ readonly fontSize: number;
135
+ }) {
136
+ return (
137
+ <G>
138
+ {labels
139
+ .filter(label => !label.muted)
140
+ .map(label => (
141
+ <SvgText
142
+ key={label.key}
143
+ x={label.x}
144
+ y={label.y}
145
+ fill={theme.typography.valueLabel.color}
146
+ fontSize={fontSize}
147
+ fontWeight={theme.typography.valueLabel.fontWeight}
148
+ textAnchor={label.anchor}
149
+ >
150
+ {label.text}
151
+ </SvgText>
152
+ ))}
153
+ </G>
154
+ );
155
+ }
156
+
157
+ /** Marks the currently selected point, when it still resolves. */
158
+ export function SelectionHighlight({
159
+ x,
160
+ y,
161
+ color,
162
+ }: {
163
+ readonly x: number;
164
+ readonly y: number;
165
+ readonly color: string;
166
+ }) {
167
+ return (
168
+ <G>
169
+ <Rect x={x - 6} y={y - 6} width={12} height={12} rx={6} fill={color} opacity={0.25} />
170
+ <Rect x={x - 3} y={y - 3} width={6} height={6} rx={3} fill={color} />
171
+ </G>
172
+ );
173
+ }
@@ -1,110 +1,113 @@
1
- /**
2
- * Dataset budgets (FR-074, FR-075, FR-076).
3
- *
4
- * Over budget, the chart **still renders**. Degrading silently would break the
5
- * "never present a reduced picture as complete" rule, and refusing to draw
6
- * would turn a performance hint into an outage. Instead we warn the developer
7
- * once, in dev builds only.
8
- *
9
- * The `__DEV__` gate matters: it makes the check dead code that Metro strips
10
- * from release bundles, which is what makes "never in production" verifiable
11
- * rather than merely intended.
12
- *
13
- * The figures were validated against emitted element counts in
14
- * specs/007-rn-chart-package/research.md R2 — element count scales with series,
15
- * not with points, so these budgets are about JS work rather than view count.
16
- */
17
- import type { ChartDatasource, ChartFamily } from '../contract/datasource';
18
-
19
- export interface DatasetBudget {
20
- readonly maxSeries: number;
21
- readonly maxPointsPerSeries?: number;
22
- readonly maxCells?: number;
23
- }
24
-
25
- export const DATASET_BUDGETS: Readonly<Record<ChartFamily, DatasetBudget>> = {
26
- timeSeries: { maxSeries: 5, maxPointsPerSeries: 500 },
27
- bar: { maxSeries: 5, maxPointsPerSeries: 60 },
28
- distribution: { maxSeries: 1, maxPointsPerSeries: 12 },
29
- progress: { maxSeries: 1, maxPointsPerSeries: 8 },
30
- heatmap: { maxSeries: 1, maxCells: 1000 },
31
- };
32
-
33
- export interface BudgetCheck {
34
- readonly withinBudget: boolean;
35
- readonly seriesCount: number;
36
- readonly maxPointsInAnySeries: number;
37
- readonly totalPoints: number;
38
- }
39
-
40
- export function checkBudget<M>(
41
- datasource: ChartDatasource<M>,
42
- family: ChartFamily,
43
- ): BudgetCheck {
44
- const budget = DATASET_BUDGETS[family];
45
- const seriesCount = datasource.series.length;
46
-
47
- let maxPoints = 0;
48
- let totalPoints = 0;
49
- for (const series of datasource.series) {
50
- maxPoints = Math.max(maxPoints, series.points.length);
51
- totalPoints += series.points.length;
52
- }
53
-
54
- const overSeries = seriesCount > budget.maxSeries;
55
- const overPoints =
56
- budget.maxPointsPerSeries !== undefined &&
57
- maxPoints > budget.maxPointsPerSeries;
58
- const overCells =
59
- budget.maxCells !== undefined && totalPoints > budget.maxCells;
60
-
61
- return {
62
- withinBudget: !overSeries && !overPoints && !overCells,
63
- seriesCount,
64
- maxPointsInAnySeries: maxPoints,
65
- totalPoints,
66
- };
67
- }
68
-
69
- const warnedCharts = new Set<string>();
70
-
71
- /**
72
- * Warns once per chart instance per mount. De-duplicating matters: a scrolling
73
- * dashboard would otherwise flood the console on every frame.
74
- */
75
- export function warnIfOverBudget<M>(
76
- datasource: ChartDatasource<M>,
77
- family: ChartFamily,
78
- instanceKey: string,
79
- ): BudgetCheck {
80
- const check = checkBudget(datasource, family);
81
-
82
- if (__DEV__ && !check.withinBudget && !warnedCharts.has(instanceKey)) {
83
- warnedCharts.add(instanceKey);
84
- const budget = DATASET_BUDGETS[family];
85
- console.warn(
86
- `[rn-chart] ${family} chart "${instanceKey}" exceeds its documented dataset budget ` +
87
- `(${check.seriesCount} series x up to ${check.maxPointsInAnySeries} points; ` +
88
- `budget is ${budget.maxSeries} series` +
89
- (budget.maxPointsPerSeries
90
- ? ` x ${budget.maxPointsPerSeries} points`
91
- : '') +
92
- (budget.maxCells ? `, ${budget.maxCells} cells` : '') +
93
- '). The chart still renders and drops nothing — aggregate or downsample ' +
94
- 'upstream if it feels slow. This warning appears once per chart and never ' +
95
- 'in production builds.',
96
- );
97
- }
98
-
99
- return check;
100
- }
101
-
102
- /** Called on unmount so a remounted chart can warn again. */
103
- export function releaseBudgetWarning(instanceKey: string): void {
104
- warnedCharts.delete(instanceKey);
105
- }
106
-
107
- /** Test-only reset. */
108
- export function __resetBudgetWarnings(): void {
109
- warnedCharts.clear();
110
- }
1
+ /**
2
+ * Dataset budgets (FR-074, FR-075, FR-076).
3
+ *
4
+ * Over budget, the chart **still renders**. Degrading silently would break the
5
+ * "never present a reduced picture as complete" rule, and refusing to draw
6
+ * would turn a performance hint into an outage. Instead we warn the developer
7
+ * once, in dev builds only.
8
+ *
9
+ * The `__DEV__` gate matters: it makes the check dead code that Metro strips
10
+ * from release bundles, which is what makes "never in production" verifiable
11
+ * rather than merely intended.
12
+ *
13
+ * The figures were validated against emitted element counts in
14
+ * specs/007-rn-chart-package/research.md R2 — element count scales with series,
15
+ * not with points, so these budgets are about JS work rather than view count.
16
+ */
17
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
18
+
19
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
20
+
21
+ export interface DatasetBudget {
22
+ readonly maxSeries: number;
23
+ readonly maxPointsPerSeries?: number;
24
+ readonly maxCells?: number;
25
+ }
26
+
27
+ export const DATASET_BUDGETS: Readonly<Record<ChartFamily, DatasetBudget>> = {
28
+ timeSeries: { maxSeries: 5, maxPointsPerSeries: 500 },
29
+ bar: { maxSeries: 5, maxPointsPerSeries: 60 },
30
+ distribution: { maxSeries: 1, maxPointsPerSeries: 12 },
31
+ progress: { maxSeries: 1, maxPointsPerSeries: 8 },
32
+ heatmap: { maxSeries: 1, maxCells: 1000 },
33
+ };
34
+
35
+ export interface BudgetCheck {
36
+ readonly withinBudget: boolean;
37
+ readonly seriesCount: number;
38
+ readonly maxPointsInAnySeries: number;
39
+ readonly totalPoints: number;
40
+ }
41
+
42
+ export function checkBudget<M>(datasource: ChartDatasource<M>, family: ChartFamily): BudgetCheck {
43
+ const budget = DATASET_BUDGETS[family];
44
+ const seriesCount = datasource.series.length;
45
+
46
+ let maxPoints = 0;
47
+ let totalPoints = 0;
48
+ for (const series of datasource.series) {
49
+ maxPoints = Math.max(maxPoints, series.points.length);
50
+ totalPoints += series.points.length;
51
+ }
52
+
53
+ const overSeries = seriesCount > budget.maxSeries;
54
+ const overPoints =
55
+ budget.maxPointsPerSeries !== undefined && maxPoints > budget.maxPointsPerSeries;
56
+ const overCells = budget.maxCells !== undefined && totalPoints > budget.maxCells;
57
+
58
+ return {
59
+ withinBudget: !overSeries && !overPoints && !overCells,
60
+ seriesCount,
61
+ maxPointsInAnySeries: maxPoints,
62
+ totalPoints,
63
+ };
64
+ }
65
+
66
+ const warnedCharts = new Set<string>();
67
+
68
+ /** Chart diagnostics also reach the app's SDK sinks (`installSdkSink`, CORE-08). */
69
+ const channel = createDiagnosticChannel<'over_budget'>('rn-chart');
70
+
71
+ /**
72
+ * Warns once per chart instance per mount. De-duplicating matters: a scrolling
73
+ * dashboard would otherwise flood the console on every frame.
74
+ */
75
+ export function warnIfOverBudget<M>(
76
+ datasource: ChartDatasource<M>,
77
+ family: ChartFamily,
78
+ instanceKey: string,
79
+ ): BudgetCheck {
80
+ const check = checkBudget(datasource, family);
81
+
82
+ if (__DEV__ && !check.withinBudget && !warnedCharts.has(instanceKey)) {
83
+ warnedCharts.add(instanceKey);
84
+ channel.emit('over_budget', 'warn', {
85
+ family,
86
+ series: check.seriesCount,
87
+ maxPoints: check.maxPointsInAnySeries,
88
+ });
89
+ const budget = DATASET_BUDGETS[family];
90
+ console.warn(
91
+ `[rn-chart] ${family} chart "${instanceKey}" exceeds its documented dataset budget ` +
92
+ `(${check.seriesCount} series x up to ${check.maxPointsInAnySeries} points; ` +
93
+ `budget is ${budget.maxSeries} series` +
94
+ (budget.maxPointsPerSeries ? ` x ${budget.maxPointsPerSeries} points` : '') +
95
+ (budget.maxCells ? `, ${budget.maxCells} cells` : '') +
96
+ '). The chart still renders and drops nothing — aggregate or downsample ' +
97
+ 'upstream if it feels slow. This warning appears once per chart and never ' +
98
+ 'in production builds.',
99
+ );
100
+ }
101
+
102
+ return check;
103
+ }
104
+
105
+ /** Called on unmount so a remounted chart can warn again. */
106
+ export function releaseBudgetWarning(instanceKey: string): void {
107
+ warnedCharts.delete(instanceKey);
108
+ }
109
+
110
+ /** Test-only reset. */
111
+ export function __resetBudgetWarnings(): void {
112
+ warnedCharts.clear();
113
+ }