@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
package/package.json CHANGED
@@ -1,49 +1,46 @@
1
- {
2
- "name": "@chipmobilesdk/rn-chart",
3
- "version": "0.1.1",
4
- "private": false,
5
- "license": "UNLICENSED",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./src/index.ts",
11
- "react-native": "./src/index.ts",
12
- "default": "./src/index.ts"
13
- }
14
- },
15
- "files": [
16
- "src/index.ts",
17
- "src/a11y",
18
- "src/contract",
19
- "src/geometry",
20
- "src/react",
21
- "src/render",
22
- "src/validation",
23
- "skills",
24
- "README.md",
25
- "CHANGELOG.md"
26
- ],
27
- "sideEffects": false,
28
- "scripts": {
29
- "typecheck": "tsc -p tsconfig.json --noEmit",
30
- "pack:dry-run": "npm pack --dry-run"
31
- },
32
- "peerDependencies": {
33
- "react": ">=19",
34
- "react-native": ">=0.85",
35
- "react-native-svg": ">=15.15"
36
- },
37
- "peerDependenciesMeta": {
38
- "react-native-svg": {
39
- "optional": false
40
- }
41
- },
42
- "engines": {
43
- "node": ">=22.11.0"
44
- },
45
- "publishConfig": {
46
- "access": "public",
47
- "registry": "https://registry.npmjs.org/"
48
- }
49
- }
1
+ {
2
+ "name": "@chipmobilesdk/rn-chart",
3
+ "version": "0.2.1",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/index.ts",
17
+ "src/a11y",
18
+ "src/contract",
19
+ "src/geometry",
20
+ "src/react",
21
+ "src/render",
22
+ "src/validation",
23
+ "skills",
24
+ "README.md",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "sideEffects": false,
28
+ "scripts": {
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "pack:check": "npm pack --dry-run"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=19",
34
+ "react-native": ">=0.85",
35
+ "react-native-svg": ">=15.15"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react-native-svg": {
39
+ "optional": false
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ }
46
+ }
@@ -3,7 +3,6 @@ name: sdk-rn-chart
3
3
  description: "Charts via @chipmobilesdk/rn-chart: datasource contract, series theming, legends, selection, dataset budgets, chart accessibility. Use when rendering a chart, wiring chart data, theming series, or hitting a dataset size limit."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-chart"
6
- packageVersion: "0.1.0"
7
6
  user-invocable: true
8
7
  disable-model-invocation: false
9
8
  ---
@@ -37,7 +36,7 @@ the budget. See `## Dataset budgets`.
37
36
  Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
38
37
  `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
39
38
 
40
- ## Public API (v0.1.0)
39
+ ## Public API
41
40
 
42
41
  Runtime:
43
42
 
@@ -1,177 +1,170 @@
1
- /**
2
- * Composed accessible summaries (FR-067, FR-068, FR-069, FR-072).
3
- *
4
- * Exposing 2,500 individual points to a screen reader would be unusable even
5
- * if it were cheap. A composed summary covers the same need: what the chart is,
6
- * what it shows, where the extremes are, and what any target says.
7
- *
8
- * Withheld values never enter a summary — that would leak exactly what the
9
- * withheld class exists to protect (FR-033).
10
- */
11
- import type { ChartDatasource, ChartFamily } from '../contract/datasource';
12
- import type { ReferenceMarker } from '../contract/marker';
13
- import type { ChartStatus } from '../contract/state';
14
- import { isKnown, sumKnown, type ValueState } from '../contract/value';
15
-
16
- const FAMILY_NOUN: Record<ChartFamily, string> = {
17
- progress: 'progress chart',
18
- distribution: 'distribution chart',
19
- timeSeries: 'time series chart',
20
- bar: 'bar chart',
21
- heatmap: 'heatmap',
22
- };
23
-
24
- /**
25
- * What a screen reader hears when the chart is not showing data. Announcing
26
- * the state beats silence, and beats announcing a stale value as if current.
27
- */
28
- const STATUS_PHRASE: Record<ChartStatus, string | undefined> = {
29
- loading: 'Loading data.',
30
- empty: 'No data yet.',
31
- error: 'Data could not be shown.',
32
- stale: 'Showing data that may be out of date.',
33
- noPermission: 'This data is not available to you.',
34
- partial: 'Some data is missing.',
35
- ready: undefined,
36
- };
37
-
38
- export interface SummaryInput<M = unknown> {
39
- readonly datasource: ChartDatasource<M>;
40
- readonly family: ChartFamily;
41
- readonly status: ChartStatus;
42
- readonly title?: string;
43
- readonly formatValue: (value: ValueState) => string;
44
- readonly incomplete?: boolean;
45
- }
46
-
47
- function describeExtremes<M>(
48
- datasource: ChartDatasource<M>,
49
- formatValue: (v: ValueState) => string,
50
- ): string {
51
- let min: { label: string; value: number } | undefined;
52
- let max: { label: string; value: number } | undefined;
53
-
54
- for (const series of datasource.series) {
55
- for (const point of series.points) {
56
- // Withheld and unknown values are skipped entirely.
57
- if (!isKnown(point.value)) {
58
- continue;
59
- }
60
- const label =
61
- point.position.kind === 'category'
62
- ? point.position.category
63
- : point.position.kind === 'matrix'
64
- ? `${point.position.row} ${point.position.column}`
65
- : series.label;
66
-
67
- if (!min || point.value.value < min.value) {
68
- min = { label, value: point.value.value };
69
- }
70
- if (!max || point.value.value > max.value) {
71
- max = { label, value: point.value.value };
72
- }
73
- }
74
- }
75
-
76
- if (!min || !max) {
77
- return '';
78
- }
79
- if (min.label === max.label) {
80
- return `Value ${formatValue({ kind: 'known', value: max.value })}.`;
81
- }
82
-
83
- return (
84
- `Highest ${max.label} at ${formatValue({ kind: 'known', value: max.value })}. ` +
85
- `Lowest ${min.label} at ${formatValue({ kind: 'known', value: min.value })}.`
86
- );
87
- }
88
-
89
- function describeMarkers(
90
- markers: readonly ReferenceMarker[] | undefined,
91
- formatValue: (v: ValueState) => string,
92
- ): string {
93
- if (!markers || markers.length === 0) {
94
- return '';
95
- }
96
-
97
- const parts: string[] = [];
98
- for (const marker of markers) {
99
- if (!isKnown(marker.value)) {
100
- continue;
101
- }
102
- const name = marker.label ?? marker.kind;
103
- parts.push(`${name} ${formatValue(marker.value)}`);
104
- }
105
-
106
- return parts.length > 0 ? `${parts.join('. ')}.` : '';
107
- }
108
-
109
- /**
110
- * Builds the label read aloud for the chart as a whole.
111
- *
112
- * Non-ready statuses replace the data description rather than appending to it,
113
- * so a loading chart never announces figures it is not showing.
114
- */
115
- export function buildChartSummary<M>(input: SummaryInput<M>): string {
116
- const noun = FAMILY_NOUN[input.family];
117
- const heading = input.title ? `${input.title}, ${noun}.` : `${noun}.`;
118
-
119
- const statusPhrase = STATUS_PHRASE[input.status];
120
- if (input.status !== 'ready' && input.status !== 'partial' && input.status !== 'stale') {
121
- return `${heading} ${statusPhrase ?? ''}`.trim();
122
- }
123
-
124
- const seriesCount = input.datasource.series.length;
125
- const pointCount = input.datasource.series.reduce(
126
- (acc, s) => acc + s.points.length,
127
- 0,
128
- );
129
-
130
- const scale =
131
- seriesCount > 1
132
- ? `${seriesCount} series, ${pointCount} points.`
133
- : `${pointCount} points.`;
134
-
135
- const parts = [
136
- heading,
137
- statusPhrase,
138
- scale,
139
- describeExtremes(input.datasource, input.formatValue),
140
- describeMarkers(input.datasource.markers, input.formatValue),
141
- input.incomplete
142
- ? 'Some values are missing, so totals are incomplete.'
143
- : '',
144
- ];
145
-
146
- return parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
147
- }
148
-
149
- /** Announced for the currently selected point on an adjustable chart. */
150
- export function buildPointAnnouncement(
151
- seriesLabel: string,
152
- positionLabel: string,
153
- value: ValueState,
154
- formatValue: (v: ValueState) => string,
155
- ): string {
156
- if (value.kind === 'withheld') {
157
- return `${positionLabel}, ${seriesLabel}, not available.`;
158
- }
159
- if (!isKnown(value)) {
160
- return `${positionLabel}, ${seriesLabel}, no data.`;
161
- }
162
- return `${positionLabel}, ${seriesLabel}, ${formatValue(value)}.`;
163
- }
164
-
165
- /** Total plus an explicit note when it is not the whole picture (FR-013). */
166
- export function buildTotalAnnouncement<M>(
167
- datasource: ChartDatasource<M>,
168
- formatValue: (v: ValueState) => string,
169
- ): string {
170
- const values = datasource.series.flatMap(s => s.points.map(p => p.value));
171
- const total = sumKnown(values);
172
-
173
- const text = `Total ${formatValue({ kind: 'known', value: total.total })}`;
174
- return total.incomplete
175
- ? `${text}, excluding ${total.excludedPoints} values that are not available.`
176
- : `${text}.`;
177
- }
1
+ /**
2
+ * Composed accessible summaries (FR-067, FR-068, FR-069, FR-072).
3
+ *
4
+ * Exposing 2,500 individual points to a screen reader would be unusable even
5
+ * if it were cheap. A composed summary covers the same need: what the chart is,
6
+ * what it shows, where the extremes are, and what any target says.
7
+ *
8
+ * Withheld values never enter a summary — that would leak exactly what the
9
+ * withheld class exists to protect (FR-033).
10
+ */
11
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
12
+ import type { ReferenceMarker } from '../contract/marker';
13
+ import type { ChartStatus } from '../contract/state';
14
+ import { isKnown, sumKnown, type ValueState } from '../contract/value';
15
+
16
+ const FAMILY_NOUN: Record<ChartFamily, string> = {
17
+ progress: 'progress chart',
18
+ distribution: 'distribution chart',
19
+ timeSeries: 'time series chart',
20
+ bar: 'bar chart',
21
+ heatmap: 'heatmap',
22
+ };
23
+
24
+ /**
25
+ * What a screen reader hears when the chart is not showing data. Announcing
26
+ * the state beats silence, and beats announcing a stale value as if current.
27
+ */
28
+ const STATUS_PHRASE: Record<ChartStatus, string | undefined> = {
29
+ loading: 'Loading data.',
30
+ empty: 'No data yet.',
31
+ error: 'Data could not be shown.',
32
+ stale: 'Showing data that may be out of date.',
33
+ noPermission: 'This data is not available to you.',
34
+ partial: 'Some data is missing.',
35
+ ready: undefined,
36
+ };
37
+
38
+ export interface SummaryInput<M = unknown> {
39
+ readonly datasource: ChartDatasource<M>;
40
+ readonly family: ChartFamily;
41
+ readonly status: ChartStatus;
42
+ readonly title?: string;
43
+ readonly formatValue: (value: ValueState) => string;
44
+ readonly incomplete?: boolean;
45
+ }
46
+
47
+ function describeExtremes<M>(
48
+ datasource: ChartDatasource<M>,
49
+ formatValue: (v: ValueState) => string,
50
+ ): string {
51
+ let min: { label: string; value: number } | undefined;
52
+ let max: { label: string; value: number } | undefined;
53
+
54
+ for (const series of datasource.series) {
55
+ for (const point of series.points) {
56
+ // Withheld and unknown values are skipped entirely.
57
+ if (!isKnown(point.value)) {
58
+ continue;
59
+ }
60
+ const label =
61
+ point.position.kind === 'category'
62
+ ? point.position.category
63
+ : point.position.kind === 'matrix'
64
+ ? `${point.position.row} ${point.position.column}`
65
+ : series.label;
66
+
67
+ if (!min || point.value.value < min.value) {
68
+ min = { label, value: point.value.value };
69
+ }
70
+ if (!max || point.value.value > max.value) {
71
+ max = { label, value: point.value.value };
72
+ }
73
+ }
74
+ }
75
+
76
+ if (!min || !max) {
77
+ return '';
78
+ }
79
+ if (min.label === max.label) {
80
+ return `Value ${formatValue({ kind: 'known', value: max.value })}.`;
81
+ }
82
+
83
+ return (
84
+ `Highest ${max.label} at ${formatValue({ kind: 'known', value: max.value })}. ` +
85
+ `Lowest ${min.label} at ${formatValue({ kind: 'known', value: min.value })}.`
86
+ );
87
+ }
88
+
89
+ function describeMarkers(
90
+ markers: readonly ReferenceMarker[] | undefined,
91
+ formatValue: (v: ValueState) => string,
92
+ ): string {
93
+ if (!markers || markers.length === 0) {
94
+ return '';
95
+ }
96
+
97
+ const parts: string[] = [];
98
+ for (const marker of markers) {
99
+ if (!isKnown(marker.value)) {
100
+ continue;
101
+ }
102
+ const name = marker.label ?? marker.kind;
103
+ parts.push(`${name} ${formatValue(marker.value)}`);
104
+ }
105
+
106
+ return parts.length > 0 ? `${parts.join('. ')}.` : '';
107
+ }
108
+
109
+ /**
110
+ * Builds the label read aloud for the chart as a whole.
111
+ *
112
+ * Non-ready statuses replace the data description rather than appending to it,
113
+ * so a loading chart never announces figures it is not showing.
114
+ */
115
+ export function buildChartSummary<M>(input: SummaryInput<M>): string {
116
+ const noun = FAMILY_NOUN[input.family];
117
+ const heading = input.title ? `${input.title}, ${noun}.` : `${noun}.`;
118
+
119
+ const statusPhrase = STATUS_PHRASE[input.status];
120
+ if (input.status !== 'ready' && input.status !== 'partial' && input.status !== 'stale') {
121
+ return `${heading} ${statusPhrase ?? ''}`.trim();
122
+ }
123
+
124
+ const seriesCount = input.datasource.series.length;
125
+ const pointCount = input.datasource.series.reduce((acc, s) => acc + s.points.length, 0);
126
+
127
+ const scale =
128
+ seriesCount > 1 ? `${seriesCount} series, ${pointCount} points.` : `${pointCount} points.`;
129
+
130
+ const parts = [
131
+ heading,
132
+ statusPhrase,
133
+ scale,
134
+ describeExtremes(input.datasource, input.formatValue),
135
+ describeMarkers(input.datasource.markers, input.formatValue),
136
+ input.incomplete ? 'Some values are missing, so totals are incomplete.' : '',
137
+ ];
138
+
139
+ return parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
140
+ }
141
+
142
+ /** Announced for the currently selected point on an adjustable chart. */
143
+ export function buildPointAnnouncement(
144
+ seriesLabel: string,
145
+ positionLabel: string,
146
+ value: ValueState,
147
+ formatValue: (v: ValueState) => string,
148
+ ): string {
149
+ if (value.kind === 'withheld') {
150
+ return `${positionLabel}, ${seriesLabel}, not available.`;
151
+ }
152
+ if (!isKnown(value)) {
153
+ return `${positionLabel}, ${seriesLabel}, no data.`;
154
+ }
155
+ return `${positionLabel}, ${seriesLabel}, ${formatValue(value)}.`;
156
+ }
157
+
158
+ /** Total plus an explicit note when it is not the whole picture (FR-013). */
159
+ export function buildTotalAnnouncement<M>(
160
+ datasource: ChartDatasource<M>,
161
+ formatValue: (v: ValueState) => string,
162
+ ): string {
163
+ const values = datasource.series.flatMap(s => s.points.map(p => p.value));
164
+ const total = sumKnown(values);
165
+
166
+ const text = `Total ${formatValue({ kind: 'known', value: total.total })}`;
167
+ return total.incomplete
168
+ ? `${text}, excluding ${total.excludedPoints} values that are not available.`
169
+ : `${text}.`;
170
+ }