@chipmobilesdk/rn-chart 0.2.0 → 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 -115
  2. package/README.md +381 -381
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -136
  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 -177
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -273
  16. package/src/geometry/distribution.ts +197 -203
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -173
  20. package/src/geometry/markers.ts +55 -57
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -230
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -168
  26. package/src/geometry/types.ts +94 -94
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -622
  30. package/src/react/ChartLegend.tsx +211 -215
  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.2.0",
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
+ }
@@ -1,69 +1,68 @@
1
- ---
2
- name: sdk-rn-chart
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
- metadata:
5
- package: "@chipmobilesdk/rn-chart"
6
- packageVersion: "0.1.0"
7
- user-invocable: true
8
- disable-model-invocation: false
9
- ---
10
-
11
- # @chipmobilesdk/rn-chart
12
-
13
- Chart rendering with an explicit datasource contract, its own theme provider,
14
- accessibility support and enforced dataset budgets.
15
-
16
- ## Use this when
17
-
18
- - Rendering any chart or adding a series
19
- - Shaping data to the datasource contract
20
- - Theming series colors or building a legend
21
- - Handling selection / interaction on a chart
22
- - A chart refuses to render a large dataset
23
-
24
- ## Check what it deliberately does not do
25
-
26
- The README opens with `## What it does not do`. Read that first — it prevents building
27
- a feature this package intentionally excludes.
28
-
29
- ## Datasets are budgeted
30
-
31
- `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
32
- low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
33
- the budget. See `## Dataset budgets`.
34
-
35
- ## Chart theming is separate from app theming
36
-
37
- Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
38
- `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
39
-
40
- ## Public API (v0.2.0)
41
-
42
- Runtime:
43
-
44
- - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
45
- - `ChartLegend`, `buildLegendEntries`
46
- - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
47
- - `DATASET_BUDGETS`, `checkBudget`
48
- - `isChartError`
49
-
50
- Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
51
-
52
- Use `sameSelection` for selection equality — comparing selection objects by reference
53
- causes redundant re-renders.
54
-
55
- ## Where the detail lives
56
-
57
- Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
58
-
59
- | Need | Section |
60
- |---|---|
61
- | Scope check | `## What it does not do` |
62
- | First integration | `## Install`, `## Quick start` |
63
- | Shaping data | `## The datasource contract`, `## Value semantics` |
64
- | Loading/empty/error rendering | `## States` |
65
- | Touch and selection | `## Interaction` |
66
- | Colors and legends | `## Theming` |
67
- | Axis/label formatting | `## Formatting` |
68
- | Screen reader support | `## Accessibility` |
69
- | Size limits | `## Dataset budgets` |
1
+ ---
2
+ name: sdk-rn-chart
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
+ metadata:
5
+ package: "@chipmobilesdk/rn-chart"
6
+ user-invocable: true
7
+ disable-model-invocation: false
8
+ ---
9
+
10
+ # @chipmobilesdk/rn-chart
11
+
12
+ Chart rendering with an explicit datasource contract, its own theme provider,
13
+ accessibility support and enforced dataset budgets.
14
+
15
+ ## Use this when
16
+
17
+ - Rendering any chart or adding a series
18
+ - Shaping data to the datasource contract
19
+ - Theming series colors or building a legend
20
+ - Handling selection / interaction on a chart
21
+ - A chart refuses to render a large dataset
22
+
23
+ ## Check what it deliberately does not do
24
+
25
+ The README opens with `## What it does not do`. Read that first — it prevents building
26
+ a feature this package intentionally excludes.
27
+
28
+ ## Datasets are budgeted
29
+
30
+ `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
31
+ low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
32
+ the budget. See `## Dataset budgets`.
33
+
34
+ ## Chart theming is separate from app theming
35
+
36
+ Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
37
+ `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
38
+
39
+ ## Public API
40
+
41
+ Runtime:
42
+
43
+ - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
44
+ - `ChartLegend`, `buildLegendEntries`
45
+ - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
46
+ - `DATASET_BUDGETS`, `checkBudget`
47
+ - `isChartError`
48
+
49
+ Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
50
+
51
+ Use `sameSelection` for selection equality — comparing selection objects by reference
52
+ causes redundant re-renders.
53
+
54
+ ## Where the detail lives
55
+
56
+ Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
57
+
58
+ | Need | Section |
59
+ |---|---|
60
+ | Scope check | `## What it does not do` |
61
+ | First integration | `## Install`, `## Quick start` |
62
+ | Shaping data | `## The datasource contract`, `## Value semantics` |
63
+ | Loading/empty/error rendering | `## States` |
64
+ | Touch and selection | `## Interaction` |
65
+ | Colors and legends | `## Theming` |
66
+ | Axis/label formatting | `## Formatting` |
67
+ | Screen reader support | `## Accessibility` |
68
+ | Size limits | `## Dataset budgets` |
@@ -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
+ }