@chipmobilesdk/rn-chart 0.1.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.
- package/CHANGELOG.md +74 -0
- package/README.md +358 -0
- package/package.json +48 -0
- package/src/a11y/summary.ts +177 -0
- package/src/contract/datasource.ts +134 -0
- package/src/contract/errors.ts +133 -0
- package/src/contract/format.ts +168 -0
- package/src/contract/interaction.ts +71 -0
- package/src/contract/marker.ts +75 -0
- package/src/contract/props.ts +60 -0
- package/src/contract/state.ts +94 -0
- package/src/contract/theme.ts +139 -0
- package/src/contract/value.ts +125 -0
- package/src/geometry/bar.ts +256 -0
- package/src/geometry/distribution.ts +200 -0
- package/src/geometry/heatmap.ts +195 -0
- package/src/geometry/hitTest.ts +120 -0
- package/src/geometry/layout.ts +121 -0
- package/src/geometry/path.ts +199 -0
- package/src/geometry/progress.ts +204 -0
- package/src/geometry/scale.ts +146 -0
- package/src/geometry/ticks.ts +153 -0
- package/src/geometry/timeSeries.ts +158 -0
- package/src/geometry/types.ts +88 -0
- package/src/index.ts +176 -0
- package/src/react/ChartBoundary.tsx +96 -0
- package/src/react/ChartFrame.tsx +620 -0
- package/src/react/ChartLegend.tsx +177 -0
- package/src/react/ChartThemeProvider.tsx +48 -0
- package/src/react/charts.tsx +161 -0
- package/src/react/useChartLayout.ts +124 -0
- package/src/react/useChartSelection.ts +170 -0
- package/src/render/Axis.tsx +122 -0
- package/src/render/Marks.tsx +205 -0
- package/src/validation/budgets.ts +110 -0
- package/src/validation/validateDatasource.ts +174 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@chipmobilesdk/rn-chart` are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
6
|
+
this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
Initial release. A domain-agnostic charting package for ChipMobileSdk-family
|
|
13
|
+
apps.
|
|
14
|
+
|
|
15
|
+
- **One datasource contract** across five chart families, with a shared point
|
|
16
|
+
envelope and a discriminated `Position` (category, instant, matrix). Families
|
|
17
|
+
accepting the same position kind interchange without changing the datasource.
|
|
18
|
+
- **Four-way value semantics** — `known` (including a real zero), `unknown`,
|
|
19
|
+
`notApplicable`, `withheld` — carried as a discriminated union with no numeric
|
|
20
|
+
field on the three non-value cases, so a gap cannot be coerced to zero.
|
|
21
|
+
- **Chart families**: `ProgressChart`, `DistributionChart`, `TimeSeriesChart`,
|
|
22
|
+
`BarChart`, `HeatmapChart`, plus `ChartLegend`.
|
|
23
|
+
- **Seven chart states** per family — loading, empty, ready, error, stale,
|
|
24
|
+
no-permission, partial — with per-chart failure containment and an
|
|
25
|
+
app-supplied failure-report callback that carries no data values.
|
|
26
|
+
- **Interaction callbacks** for point selection, series selection, segment
|
|
27
|
+
press, and series visibility. Uncontrolled by default; app-controlled when
|
|
28
|
+
the corresponding value prop is supplied. The package never navigates,
|
|
29
|
+
fetches, or mutates.
|
|
30
|
+
- **Theming** through a `ChartTheme` value type resolved from prop, context, or
|
|
31
|
+
a built-in light/dark default. No dependency on `@chipmobilesdk/rn-theme` or
|
|
32
|
+
`react-native-unistyles`.
|
|
33
|
+
- **Formatting** through app-supplied formatters for axis labels, values,
|
|
34
|
+
legend entries, and summaries, with documented locale-neutral defaults.
|
|
35
|
+
- **Accessibility**: composed per-chart summaries, state announcements, and
|
|
36
|
+
`adjustable` selection traversal for assistive technology.
|
|
37
|
+
- **Dataset budgets** (`DATASET_BUDGETS`) with a dev-only, once-per-chart
|
|
38
|
+
over-budget warning. Over-budget datasets still render.
|
|
39
|
+
|
|
40
|
+
### Release notes for consuming apps
|
|
41
|
+
|
|
42
|
+
What a team adopting this package needs to know and do:
|
|
43
|
+
|
|
44
|
+
- **Install two things, not one.** `@chipmobilesdk/rn-chart` and its required
|
|
45
|
+
peer `react-native-svg` (>= 15.15), then `pod install` on iOS.
|
|
46
|
+
- **No permissions, no manifest changes, no store-declaration changes.** The
|
|
47
|
+
package requests nothing and collects nothing. Note that its *no-permission
|
|
48
|
+
chart state* is an app-level data-access concept, not an OS permission.
|
|
49
|
+
- **Screenshots may need updating** wherever charts change how a screen looks.
|
|
50
|
+
No other store metadata is affected.
|
|
51
|
+
- **If you chart sensitive data**, read the data-behaviour note in the README
|
|
52
|
+
before completing Apple App Privacy or Google Play Data Safety. The package
|
|
53
|
+
introduces no collection of its own, but anything your app transmits is yours
|
|
54
|
+
to declare.
|
|
55
|
+
- **If you render narrative or AI-sourced text near a chart**, moderation and
|
|
56
|
+
content obligations are entirely yours — this package ships no free-text
|
|
57
|
+
surface.
|
|
58
|
+
- **One API habit to adopt**: keep your datasource referentially stable (state
|
|
59
|
+
or `useMemo`). Rebuilding it inline every render defeats the package's
|
|
60
|
+
memoization, and no library can fix that from the inside.
|
|
61
|
+
|
|
62
|
+
### Notes
|
|
63
|
+
|
|
64
|
+
- **Peer dependencies**: `react` >= 19, `react-native` >= 0.85,
|
|
65
|
+
`react-native-svg` >= 15.15. No runtime dependencies.
|
|
66
|
+
- **Native code**: none is authored in this package. `react-native-svg` is a
|
|
67
|
+
native-backed peer; see the README for install and setup.
|
|
68
|
+
- **Stability**: the value, datasource, state, error, and interaction contracts
|
|
69
|
+
are stable. `ChartTheme`, `LegendEntry`, family-specific props, and the
|
|
70
|
+
`DATASET_BUDGETS` values are experimental and may change in a minor release —
|
|
71
|
+
see `specs/007-rn-chart-package/contracts/public-api.md` §6.
|
|
72
|
+
- **Out of scope, reserved for a future additive minor**: insight/narrative
|
|
73
|
+
presentation and composable chart primitives. Neither will change the
|
|
74
|
+
datasource, state, or interaction contracts when it lands.
|
package/README.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# @chipmobilesdk/rn-chart
|
|
2
|
+
|
|
3
|
+
Domain-agnostic charts for ChipMobileSdk-family React Native apps.
|
|
4
|
+
|
|
5
|
+
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
6
|
+
> applications; no open-source license is granted.
|
|
7
|
+
|
|
8
|
+
- **Version**: `0.1.0`
|
|
9
|
+
- **Entrypoint**: `@chipmobilesdk/rn-chart`
|
|
10
|
+
|
|
11
|
+
One datasource contract spans five chart families. The package knows nothing
|
|
12
|
+
about nutrition, finance, health, or productivity — you hand it aggregated,
|
|
13
|
+
normalised data and it draws it.
|
|
14
|
+
|
|
15
|
+
## What it does not do
|
|
16
|
+
|
|
17
|
+
Worth stating up front, because these are deliberate:
|
|
18
|
+
|
|
19
|
+
- **No insight or narrative surface.** The package draws charts. Render your own
|
|
20
|
+
explanatory text above or below a chart; it imposes no outer margin that would
|
|
21
|
+
fight your layout.
|
|
22
|
+
- **No data fetching, no database access, no AI calls, no network of any kind.**
|
|
23
|
+
Everything arrives as props.
|
|
24
|
+
- **No aggregation.** Your domain layer decides what the numbers mean.
|
|
25
|
+
- **No navigation.** Interactions call back; what happens next is yours.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @chipmobilesdk/rn-chart react-native-svg
|
|
31
|
+
cd ios && pod install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`react-native-svg` is a required peer. There is no degraded no-SVG mode.
|
|
35
|
+
|
|
36
|
+
No permissions, entitlements, `Info.plist` usage strings, or Android manifest
|
|
37
|
+
changes are needed. Note that this package's **no-permission chart state** is an
|
|
38
|
+
app-level data-access concept, not an operating-system permission.
|
|
39
|
+
|
|
40
|
+
## Compatibility
|
|
41
|
+
|
|
42
|
+
| | Supported |
|
|
43
|
+
|---|---|
|
|
44
|
+
| React | 19.2+ |
|
|
45
|
+
| React Native | 0.85+ |
|
|
46
|
+
| `react-native-svg` | 15.15+ |
|
|
47
|
+
| Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
|
|
48
|
+
| iOS | 15.1+ |
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { BarChart, known, unknown, type ChartDatasource } from '@chipmobilesdk/rn-chart';
|
|
54
|
+
|
|
55
|
+
const datasource: ChartDatasource = {
|
|
56
|
+
positionKind: 'category',
|
|
57
|
+
series: [{
|
|
58
|
+
id: 'weekly',
|
|
59
|
+
label: 'Weekly total',
|
|
60
|
+
points: [
|
|
61
|
+
{ id: 'mon', position: { kind: 'category', category: 'Mon' }, value: known(12) },
|
|
62
|
+
{ id: 'tue', position: { kind: 'category', category: 'Tue' }, value: known(0) },
|
|
63
|
+
{ id: 'wed', position: { kind: 'category', category: 'Wed' }, value: unknown() },
|
|
64
|
+
],
|
|
65
|
+
}],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function Example() {
|
|
69
|
+
return <BarChart datasource={datasource} height={220} title="This week" />;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Monday draws a bar. Tuesday draws a **zero-height bar that reads as a real
|
|
74
|
+
zero**. Wednesday draws **nothing and is marked as a gap** — never a zero.
|
|
75
|
+
|
|
76
|
+
> **Keep your datasource referentially stable.** Hold it in state or a `useMemo`.
|
|
77
|
+
> Rebuilding the object inline on every render defeats the memoization the
|
|
78
|
+
> package relies on, and that is not something a library can fix for you.
|
|
79
|
+
|
|
80
|
+
## Value semantics
|
|
81
|
+
|
|
82
|
+
The most important part of the contract. Four classes, and only one carries a
|
|
83
|
+
number:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
known(42) // a real observation, including known(0)
|
|
87
|
+
unknown() // we do not know
|
|
88
|
+
notApplicable() // does not apply here
|
|
89
|
+
withheld() // hidden: permission, or not enough data
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
There is no `value` property on the three non-value cases, so `value ?? 0`
|
|
93
|
+
cannot compile. That is the point — turning a gap into a zero is the single most
|
|
94
|
+
damaging thing a charting library can do to someone's data.
|
|
95
|
+
|
|
96
|
+
Consequences you can rely on:
|
|
97
|
+
|
|
98
|
+
- An unknown value is never rendered as zero and never interpolated across. In a
|
|
99
|
+
line chart the gap ends the subpath, so there is physically no segment to
|
|
100
|
+
bridge it.
|
|
101
|
+
- `notApplicable` and `withheld` are excluded from totals, and any total that
|
|
102
|
+
excluded something is reported as incomplete.
|
|
103
|
+
- A withheld value never appears in a rendering, a label, an accessible summary,
|
|
104
|
+
or an interaction callback.
|
|
105
|
+
- Each non-value class renders as its own **texture**, not a colour difference,
|
|
106
|
+
so they stay distinguishable in greyscale.
|
|
107
|
+
|
|
108
|
+
## The datasource contract
|
|
109
|
+
|
|
110
|
+
Every point shares one envelope — identity, value, optional unit and format
|
|
111
|
+
hint, opaque metadata — and only its **position** varies:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
{ kind: 'category', category: 'Mon' }
|
|
115
|
+
{ kind: 'instant', at: 1767225600000 } // epoch ms, UTC
|
|
116
|
+
{ kind: 'matrix', row: 'week-1', column: 'Mon' }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Which families accept which:
|
|
120
|
+
|
|
121
|
+
| Family | `category` | `instant` | `matrix` |
|
|
122
|
+
|---|:---:|:---:|:---:|
|
|
123
|
+
| `ProgressChart` | ✓ | | |
|
|
124
|
+
| `DistributionChart` | ✓ | | |
|
|
125
|
+
| `BarChart` | ✓ | ✓ | |
|
|
126
|
+
| `TimeSeriesChart` | | ✓ | |
|
|
127
|
+
| `HeatmapChart` | | | ✓ |
|
|
128
|
+
|
|
129
|
+
Bar accepting both is what makes swapping a time-series chart for a bar chart
|
|
130
|
+
free — same datasource, different component. Any other pairing is a **validation
|
|
131
|
+
error**, not a rendering guess.
|
|
132
|
+
|
|
133
|
+
Time is epoch milliseconds and carries no timezone. All date wording comes from
|
|
134
|
+
your formatters.
|
|
135
|
+
|
|
136
|
+
## States
|
|
137
|
+
|
|
138
|
+
Seven, and the package derives the two that only the data can determine:
|
|
139
|
+
|
|
140
|
+
| Status | Who decides |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `empty`, `partial` | derived from your datasource |
|
|
143
|
+
| `loading`, `stale`, `noPermission`, `error` | you declare via `status` |
|
|
144
|
+
|
|
145
|
+
Precedence: `noPermission > error > loading > empty > stale > partial > ready`.
|
|
146
|
+
`noPermission` outranks everything so restricted values cannot slip through
|
|
147
|
+
another state.
|
|
148
|
+
|
|
149
|
+
Supply your own copy — the package's built-in wording is a terse placeholder,
|
|
150
|
+
not product voice:
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<BarChart
|
|
154
|
+
datasource={ds}
|
|
155
|
+
status="loading"
|
|
156
|
+
stateContent={{
|
|
157
|
+
empty: <Text>Log your first meal to see this chart</Text>,
|
|
158
|
+
loading: <Spinner />,
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Failure containment
|
|
164
|
+
|
|
165
|
+
Each chart contains its own render exceptions. One bad datasource collapses one
|
|
166
|
+
chart, never the dashboard around it:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
<BarChart datasource={ds} chartId="calories" onFailure={report => crashReporter.log(report)} />
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Failure reports carry the chart identity, family, and a catalogue message —
|
|
173
|
+
**never data values or series labels**. This matters if you chart health or
|
|
174
|
+
financial data, because a report is the easiest accidental path from a chart
|
|
175
|
+
into a third-party crash reporting service.
|
|
176
|
+
|
|
177
|
+
## Interaction
|
|
178
|
+
|
|
179
|
+
Uncontrolled by default. Supply a `selection` value (including `null`) to take
|
|
180
|
+
ownership; the same callbacks fire either way.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
// Uncontrolled: the chart tracks its own selection.
|
|
184
|
+
<BarChart datasource={ds} onSelectionChange={(ref, event) => log(event)} />
|
|
185
|
+
|
|
186
|
+
// Controlled: you are the source of truth.
|
|
187
|
+
<BarChart
|
|
188
|
+
datasource={ds}
|
|
189
|
+
selection={selection}
|
|
190
|
+
onSelectionChange={setSelectionFromRef}
|
|
191
|
+
onSelectionUnresolvable={ref => setSelection(null)}
|
|
192
|
+
/>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Every payload carries the point id, series id, value, position, and whatever
|
|
196
|
+
metadata you attached. The package never navigates, fetches, or mutates.
|
|
197
|
+
|
|
198
|
+
Legends can also toggle series visibility. The state is uncontrolled unless
|
|
199
|
+
you supply `hiddenSeriesIds`:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
<BarChart
|
|
203
|
+
datasource={ds}
|
|
204
|
+
seriesToggleEnabled
|
|
205
|
+
hiddenSeriesIds={hiddenSeriesIds}
|
|
206
|
+
onVisibilityChange={event => updateHiddenSeries(event.seriesId)}
|
|
207
|
+
/>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Pan, pinch-zoom, and brush-to-select are out of scope. `range` on
|
|
211
|
+
`TimeSeriesChart` is a window **you** supply, not one the user draws.
|
|
212
|
+
|
|
213
|
+
## Theming
|
|
214
|
+
|
|
215
|
+
The package defines its own `ChartTheme` value type and takes no dependency on
|
|
216
|
+
`@chipmobilesdk/rn-theme` or `react-native-unistyles`. Any design system can
|
|
217
|
+
satisfy it:
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
import { ChartThemeProvider, defaultChartTheme } from '@chipmobilesdk/rn-chart';
|
|
221
|
+
|
|
222
|
+
const chartTheme = {
|
|
223
|
+
...defaultChartTheme(scheme),
|
|
224
|
+
palette: myTokens.dataviz,
|
|
225
|
+
surface: myTokens.surface,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
<ChartThemeProvider theme={chartTheme}>{children}</ChartThemeProvider>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Resolution order: explicit `theme` prop → provider context → built-in default
|
|
232
|
+
keyed on `useColorScheme()`. With no configuration at all, charts still render
|
|
233
|
+
correctly in light and dark.
|
|
234
|
+
|
|
235
|
+
Meaning never rides on colour alone: series carry dash patterns as well as
|
|
236
|
+
colours, and non-value classes use textures.
|
|
237
|
+
|
|
238
|
+
## Formatting
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
<TimeSeriesChart
|
|
242
|
+
datasource={ds}
|
|
243
|
+
formatters={{
|
|
244
|
+
value: v => (v.kind === 'known' ? intl.number(v.value) : '—'),
|
|
245
|
+
axisLabel: p => ('at' in p ? intl.date(p.at) : String(p)),
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Your output is used verbatim. With no formatter, a documented locale-neutral
|
|
251
|
+
default applies — the package never infers or converts a business unit. A
|
|
252
|
+
formatter that throws degrades to the default **for that one label** and does
|
|
253
|
+
not crash the screen.
|
|
254
|
+
|
|
255
|
+
## Accessibility
|
|
256
|
+
|
|
257
|
+
Each chart is a single accessible element with a composed summary: what it is,
|
|
258
|
+
what it shows, where the extremes are, what any target says. With selection
|
|
259
|
+
enabled the root becomes `adjustable`, so increment and decrement walk the data
|
|
260
|
+
points — the same access touch gives.
|
|
261
|
+
|
|
262
|
+
Non-ready charts announce their state rather than reading out figures they are
|
|
263
|
+
not showing. Accessibility props you supply are never overridden.
|
|
264
|
+
|
|
265
|
+
Axis labels are thinned to fit rather than overlapped, and scale with the system
|
|
266
|
+
text size up to **1.6×**; beyond that the growth is capped so the plot area
|
|
267
|
+
survives.
|
|
268
|
+
|
|
269
|
+
## Dataset budgets
|
|
270
|
+
|
|
271
|
+
| Family | Budget |
|
|
272
|
+
|---|---|
|
|
273
|
+
| Time series | 5 series × 500 points |
|
|
274
|
+
| Bar | 5 series × 60 categories |
|
|
275
|
+
| Distribution | 12 segments |
|
|
276
|
+
| Progress | 8 metrics |
|
|
277
|
+
| Heatmap | 1,000 cells |
|
|
278
|
+
|
|
279
|
+
Over budget, a chart **still renders and drops nothing**. It warns once per
|
|
280
|
+
chart in development builds, and never in production. Aggregating or
|
|
281
|
+
downsampling above these sizes is your call — the package will not silently
|
|
282
|
+
decide what data to discard.
|
|
283
|
+
|
|
284
|
+
Element count scales with series, not points: an at-budget time-series chart
|
|
285
|
+
emits about six SVG elements, not 2,500.
|
|
286
|
+
|
|
287
|
+
## Data behaviour (for App Privacy and Data Safety)
|
|
288
|
+
|
|
289
|
+
For consuming apps completing Apple App Privacy or Google Play Data Safety
|
|
290
|
+
declarations:
|
|
291
|
+
|
|
292
|
+
- The package holds the values and labels you pass it **in memory only**, for the
|
|
293
|
+
lifetime of the rendered component.
|
|
294
|
+
- It collects nothing, generates nothing, persists nothing, transmits nothing,
|
|
295
|
+
and shares nothing with third parties.
|
|
296
|
+
- It logs no data point value and no series label in production builds,
|
|
297
|
+
including inside failure reports.
|
|
298
|
+
- It requests no permissions and reads no device identifiers.
|
|
299
|
+
|
|
300
|
+
**This package introduces no data-collection declaration of its own.** If your
|
|
301
|
+
app sends charted data anywhere, that collection is yours to declare.
|
|
302
|
+
|
|
303
|
+
If you render your own narrative text near a chart — particularly text from an
|
|
304
|
+
AI service or from users — moderation and content obligations are entirely
|
|
305
|
+
yours. The package ships no free-text surface at all.
|
|
306
|
+
|
|
307
|
+
## Public API
|
|
308
|
+
|
|
309
|
+
`src/index.ts` is the only entrypoint. Geometry, scales, layout maths, path
|
|
310
|
+
builders, the chart shell, and the SVG adapter are private so they can change
|
|
311
|
+
without a breaking release.
|
|
312
|
+
|
|
313
|
+
**Stable**: value semantics, `Position`, the datasource types, the five chart
|
|
314
|
+
components and their base props, `ChartStatus`, the error types, interaction
|
|
315
|
+
events.
|
|
316
|
+
|
|
317
|
+
The single entrypoint exports these public groups:
|
|
318
|
+
|
|
319
|
+
- Components: `BarChart`, `DistributionChart`, `HeatmapChart`,
|
|
320
|
+
`ProgressChart`, `TimeSeriesChart`, `ChartLegend`, `ChartThemeProvider`,
|
|
321
|
+
`useChartTheme`, `buildLegendEntries`, and their props
|
|
322
|
+
- Values and datasource: `known`, `unknown`, `notApplicable`, `withheld`,
|
|
323
|
+
`isKnown`, `isWithheld`, `sumKnown`, position/series/datasource types,
|
|
324
|
+
marker helpers, and format resolution helpers
|
|
325
|
+
- State and validation: `resolveStatus`, `drawsData`, `suppressesInteraction`,
|
|
326
|
+
`STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
|
|
327
|
+
and `isChartError`
|
|
328
|
+
- Interaction: selection and series interaction types plus `sameSelection`
|
|
329
|
+
- Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
|
|
330
|
+
formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
|
|
331
|
+
- Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
|
|
332
|
+
`buildTotalAnnouncement`
|
|
333
|
+
|
|
334
|
+
**Experimental** — may change in a minor: `ChartTheme` token set, `LegendEntry`,
|
|
335
|
+
family-specific props (`shape`, `grouping`, `buckets`), and the
|
|
336
|
+
`DATASET_BUDGETS` figures.
|
|
337
|
+
|
|
338
|
+
**Reserved for a future additive minor**: an insight presentation surface and
|
|
339
|
+
composable chart primitives. Neither will change the datasource, state, or
|
|
340
|
+
interaction contracts when it lands.
|
|
341
|
+
|
|
342
|
+
## Contributing
|
|
343
|
+
|
|
344
|
+
The one architectural rule worth knowing: **nothing under `src/geometry/` may
|
|
345
|
+
import React or `react-native-svg`.** It is pure functions over plain data,
|
|
346
|
+
enforced by an ESLint override and by `geometryPurity.test.ts`. That is what
|
|
347
|
+
keeps the geometry testable without a renderer and the drawing substrate
|
|
348
|
+
replaceable.
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
npm run lint
|
|
352
|
+
npm test
|
|
353
|
+
npm run typecheck:chart
|
|
354
|
+
npm run pack:chart
|
|
355
|
+
npm run validate:chart # all of the above
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The demo integration lives in `src/screens/ChartDemoScreen.tsx`.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-chart",
|
|
3
|
+
"version": "0.1.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
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=19",
|
|
33
|
+
"react-native": ">=0.85",
|
|
34
|
+
"react-native-svg": ">=15.15"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"react-native-svg": {
|
|
38
|
+
"optional": false
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.11.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"registry": "https://registry.npmjs.org/"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,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(
|
|
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
|
+
}
|