@chipmobilesdk/rn-chart 0.2.1 → 0.4.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 +56 -0
- package/README.md +54 -2
- package/package.json +5 -2
- package/skills/sdk-rn-chart/SKILL.md +49 -14
- package/src/a11y/summary.ts +85 -46
- package/src/contract/distribution.ts +44 -0
- package/src/contract/errors.ts +6 -0
- package/src/contract/format.ts +6 -0
- package/src/contract/interaction.ts +2 -2
- package/src/contract/props.ts +7 -0
- package/src/contract/strings.ts +50 -0
- package/src/geometry/bar.ts +27 -16
- package/src/geometry/distribution.ts +11 -6
- package/src/geometry/heatmap.ts +34 -17
- package/src/geometry/hitTest.ts +35 -0
- package/src/geometry/progress.ts +0 -1
- package/src/geometry/ticks.ts +0 -13
- package/src/geometry/timeSeries.ts +7 -2
- package/src/geometry/types.ts +11 -0
- package/src/index.ts +8 -1
- package/src/react/ChartBoundary.tsx +2 -2
- package/src/react/ChartFrame.tsx +128 -362
- package/src/react/ChartLegend.tsx +65 -3
- package/src/react/ChartPlot.tsx +57 -0
- package/src/react/ChartStateOverlay.tsx +94 -0
- package/src/react/charts.tsx +27 -12
- package/src/react/pointEvent.ts +40 -0
- package/src/react/useChartA11y.ts +113 -0
- package/src/react/useChartFormatters.ts +105 -0
- package/src/react/useChartLayout.ts +5 -2
- package/src/react/useChartSelection.ts +77 -40
- package/src/react/useChartStatus.ts +85 -0
- package/src/render/Axis.tsx +26 -22
- package/src/render/Marks.tsx +2 -9
- package/src/validation/budgets.ts +16 -4
- package/src/validation/validateDatasource.ts +38 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-chart
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Props and formatters that were declared but never read now have an effect. `formatters.legendLabel` labels legend entries, `formatters.summary` words the values a screen reader hears, and `theme.radius` rounds bars and heatmap cells. `accessibility.summaryFormatter` already worked; `ProgressChart.orientation`, which did nothing, is gone, and the `segmentPress` and `seriesSelect` event kinds, which were never emitted, are gone from the interaction types. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
8
|
+
|
|
9
|
+
Tapping the selected point again clears the selection and now reports `(null, null)` instead of the point the chart just stopped highlighting. Screen-reader stepping moves the selection rather than toggling it, so a chart with one point stays on that point. A screen reader hears a position in the app's own words through `formatters.axisLabel` rather than a raw epoch number, and the summary names the highest and lowest points by when or where they are — a single time series used to report one value and lose its lowest.
|
|
10
|
+
|
|
11
|
+
Values are checked rather than guessed at. `NaN` or `Infinity` in a known value or an instant is a `nonFiniteValue` validation error instead of a mark drawn at the origin, and a negative distribution segment is a `negativeDistributionValue` one. `DistributionChart` takes `valuesAre` to say whether its numbers are proportions or absolute amounts; inference no longer reads two counts of 1 as two full circles. A donut or pie segment is hit by the angle and radius under the finger, so the edge of a large segment selects that segment.
|
|
12
|
+
|
|
13
|
+
`HeatmapChart` draws the names of its rows and columns, and a key to its colours — the lowest and highest value with the ramp between them, as one accessible element. `ColorScaleLegend` and `ColorScale` are exported for apps that lay it out themselves. Bar charts label their value axis in round steps and put instant categories in time order, as the time-series chart does.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Charts keep their memoization under the way apps actually write props. `formatters` and `TimeSeriesChart`'s `range` are compared by what they carry rather than by object identity, so writing either inline no longer rebuilds every scale and path string on each parent render. The hidden-series set, the selection callbacks and the legend toggle keep one identity, the drawn geometry is memoized apart from the selection so highlighting a point no longer reconciles the whole SVG tree, and the dataset budget is measured only in development builds. Text size is read reactively, so changing the system font size re-lays-out the chart immediately. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
18
|
+
|
|
19
|
+
Internally, the chart shell is split into `useChartStatus`, `useChartFormatters`, `useChartA11y`, `ChartPlot` and `ChartStateOverlay` instead of one 600-line component; the budget warning runs in an effect rather than during render; and the bar, time-series and heatmap builders each make one pass over their points where they made several.
|
|
20
|
+
|
|
21
|
+
## 0.3.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
26
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
27
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
28
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
29
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
30
|
+
|
|
31
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
32
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
33
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
34
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
35
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
36
|
+
builds only. The warn-once memory is bounded.
|
|
37
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
38
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
39
|
+
instead of swallowing it.
|
|
40
|
+
|
|
41
|
+
- Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
|
|
44
|
+
line, converting `{name}` variables to `{{name}}`.
|
|
45
|
+
- rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
|
|
46
|
+
`aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
|
|
47
|
+
two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
|
|
48
|
+
- rn-auth: new `authMessages`, the catalog split into user and developer copy.
|
|
49
|
+
- rn-payment: new `paymentMessages`.
|
|
50
|
+
- rn-chart: summaries, announcements, adjustable-action labels, built-in state text
|
|
51
|
+
and legend hints come from `chartStrings`; pass `strings` to translate them. The
|
|
52
|
+
`accessibility.summaryFormatter` prop, declared before, now replaces the summary.
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
57
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
58
|
+
|
|
3
59
|
## 0.2.1
|
|
4
60
|
|
|
5
61
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -150,6 +150,11 @@ Axis labels have room reserved for them: the plot shrinks to fit the widest
|
|
|
150
150
|
value label on the left and one line of time or category labels below, so no
|
|
151
151
|
label is clipped by the chart's edge.
|
|
152
152
|
|
|
153
|
+
Every known value, and every instant, must be a **finite number**. `NaN` or
|
|
154
|
+
`Infinity` is a validation error (`nonFiniteValue`) rather than a mark drawn at
|
|
155
|
+
the origin: data that arrived broken is reported as broken. Use `unknown()` for
|
|
156
|
+
a value you do not have.
|
|
157
|
+
|
|
153
158
|
## States
|
|
154
159
|
|
|
155
160
|
Seven, and the package derives the two that only the data can determine:
|
|
@@ -212,6 +217,12 @@ ownership; the same callbacks fire either way.
|
|
|
212
217
|
Every payload carries the point id, series id, value, position, and whatever
|
|
213
218
|
metadata you attached. The package never navigates, fetches, or mutates.
|
|
214
219
|
|
|
220
|
+
Tapping the selected point again clears the selection, and the callback is told
|
|
221
|
+
so: `(null, null)`. Empty space clears it the same way. Screen-reader stepping
|
|
222
|
+
moves the selection rather than toggling it, so a chart with one point stays on
|
|
223
|
+
that point. A donut or pie is hit by the segment the finger is in — its angle
|
|
224
|
+
and radius — not by distance to its label.
|
|
225
|
+
|
|
215
226
|
Legends can also toggle series visibility. The state is uncontrolled unless
|
|
216
227
|
you supply `hiddenSeriesIds`:
|
|
217
228
|
|
|
@@ -252,6 +263,18 @@ correctly in light and dark.
|
|
|
252
263
|
Meaning never rides on colour alone: series carry dash patterns as well as
|
|
253
264
|
colours, and non-value classes use textures.
|
|
254
265
|
|
|
266
|
+
`DistributionChart` reads its numbers either as **proportions** (each in 0..1,
|
|
267
|
+
summing to 1) or as **absolute** amounts it normalises by their own total. It
|
|
268
|
+
infers which from the data, and a short sum of proportions is drawn short and
|
|
269
|
+
reported incomplete rather than stretched to fill the circle. Two counts of `1`
|
|
270
|
+
are ambiguous, so say what you mean when counts can be small:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
<DistributionChart datasource={ds} valuesAre="absolute" />
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
A negative segment is a validation error: a share of a whole cannot be negative.
|
|
277
|
+
|
|
255
278
|
`DistributionChart` colours **segments**, not series. Palette entries are taken
|
|
256
279
|
in order across every point, so one series of four points and four series of
|
|
257
280
|
one point both give four colours. A point's own `color` overrides its segment;
|
|
@@ -275,6 +298,18 @@ default applies — the package never infers or converts a business unit. A
|
|
|
275
298
|
formatter that throws degrades to the default **for that one label** and does
|
|
276
299
|
not crash the screen.
|
|
277
300
|
|
|
301
|
+
Four slots: `value` (drawn labels), `axisLabel` (axis ticks, and how a position
|
|
302
|
+
is spoken to a screen reader), `legendLabel` (legend entries) and `summary` (the
|
|
303
|
+
values inside the accessible summary; falls back to `value`).
|
|
304
|
+
|
|
305
|
+
The `formatters` object itself may be written inline — the chart depends on the
|
|
306
|
+
functions it carries, not on the object. The **functions** must be stable:
|
|
307
|
+
define them at module scope or hold them in `useCallback`/`useMemo`, as with
|
|
308
|
+
`datasource`. A formatter rebuilt every render rebuilds every scale and path
|
|
309
|
+
string, which is the one thing `FR-073` exists to prevent. The same goes for
|
|
310
|
+
`range` on `TimeSeriesChart`, which is compared by its `from` and `to`, so
|
|
311
|
+
`range={{ from, to }}` inline is free.
|
|
312
|
+
|
|
278
313
|
## Accessibility
|
|
279
314
|
|
|
280
315
|
Each chart is a single accessible element with a composed summary: what it is,
|
|
@@ -285,6 +320,21 @@ points — the same access touch gives.
|
|
|
285
320
|
Non-ready charts announce their state rather than reading out figures they are
|
|
286
321
|
not showing. Accessibility props you supply are never overridden.
|
|
287
322
|
|
|
323
|
+
Everything the package says or shows itself (summaries, point announcements, the
|
|
324
|
+
adjustable actions, built-in state text, legend hints) comes from `chartStrings`,
|
|
325
|
+
with English defaults. Pass `strings` to speak the app's language; any key you
|
|
326
|
+
leave out keeps its default. `accessibility.summaryFormatter` replaces the composed
|
|
327
|
+
summary entirely, and `accessibility.label` replaces everything.
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
<BarChart datasource={steps} title="Bước" strings={{ 'chart.family.bar': 'biểu đồ cột' }} />
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`HeatmapChart` names its rows and columns along the edges of the plot and shows
|
|
334
|
+
the key to its colours — the lowest and highest value with the ramp between them
|
|
335
|
+
— below it, as one accessible element. Pass `legend={false}` to leave the key
|
|
336
|
+
out.
|
|
337
|
+
|
|
288
338
|
Axis labels are thinned to fit rather than overlapped, and scale with the system
|
|
289
339
|
text size up to **1.6×**; beyond that the growth is capped so the plot area
|
|
290
340
|
survives.
|
|
@@ -299,8 +349,9 @@ survives.
|
|
|
299
349
|
| Progress | 8 metrics |
|
|
300
350
|
| Heatmap | 1,000 cells |
|
|
301
351
|
|
|
302
|
-
Over budget, a chart **still renders and drops nothing
|
|
303
|
-
chart in development builds, and
|
|
352
|
+
Over budget, a chart **still renders and drops nothing**: nothing is enforced
|
|
353
|
+
and nothing is refused. It warns once per chart in development builds, and in
|
|
354
|
+
production the dataset is not even measured. Aggregating or
|
|
304
355
|
downsampling above these sizes is your call — the package will not silently
|
|
305
356
|
decide what data to discard.
|
|
306
357
|
|
|
@@ -349,6 +400,7 @@ The single entrypoint exports these public groups:
|
|
|
349
400
|
`STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
|
|
350
401
|
and `isChartError`
|
|
351
402
|
- Interaction: selection and series interaction types plus `sameSelection`
|
|
403
|
+
- Heatmap colour key: `ColorScaleLegend` and `ColorScale`
|
|
352
404
|
- Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
|
|
353
405
|
formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
|
|
354
406
|
- Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-chart",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
],
|
|
27
27
|
"sideEffects": false,
|
|
28
28
|
"scripts": {
|
|
29
|
-
"typecheck": "tsc -p tsconfig.json
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
30
30
|
"pack:check": "npm pack --dry-run"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
34
|
+
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"react": ">=19",
|
|
34
37
|
"react-native": ">=0.85",
|
|
@@ -18,18 +18,36 @@ accessibility support and enforced dataset budgets.
|
|
|
18
18
|
- Shaping data to the datasource contract
|
|
19
19
|
- Theming series colors or building a legend
|
|
20
20
|
- Handling selection / interaction on a chart
|
|
21
|
-
- A chart
|
|
21
|
+
- A chart is slower than expected, or a dataset is larger than its budget
|
|
22
|
+
- A formatter or a prop appears to have no effect
|
|
22
23
|
|
|
23
24
|
## Check what it deliberately does not do
|
|
24
25
|
|
|
25
26
|
The README opens with `## What it does not do`. Read that first — it prevents building
|
|
26
27
|
a feature this package intentionally excludes.
|
|
27
28
|
|
|
28
|
-
##
|
|
29
|
+
## Dataset budgets are a development warning, not a limit
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
the
|
|
31
|
+
Nothing is enforced and nothing is refused: over budget a chart **still renders and
|
|
32
|
+
drops nothing**, warns once per chart in development, and in production does not even
|
|
33
|
+
measure the dataset. `DATASET_BUDGETS` and `checkBudget` are the figures and the check,
|
|
34
|
+
for a consumer that wants to aggregate or window its own data. Do not debug a "chart
|
|
35
|
+
refuses to render": no such behaviour exists. See `## Dataset budgets`.
|
|
36
|
+
|
|
37
|
+
## Memoization has two requirements
|
|
38
|
+
|
|
39
|
+
`datasource` must be referentially stable, and so must each **formatter function**
|
|
40
|
+
(module scope, or `useCallback`). The `formatters` object itself may be inline, as may
|
|
41
|
+
`range={{ from, to }}` — those are compared by what they carry. An unstable datasource
|
|
42
|
+
or formatter rebuilds every scale and path string on each parent render.
|
|
43
|
+
|
|
44
|
+
## What the numbers mean, where it is ambiguous
|
|
45
|
+
|
|
46
|
+
`DistributionChart` reads values as proportions (0..1, summing to 1) or as absolute
|
|
47
|
+
amounts, inferred from the data. Pass `valuesAre="absolute" | "proportions"` when counts
|
|
48
|
+
could be read as shares (two counts of `1`). Known values and instants must be finite:
|
|
49
|
+
`NaN` is a `nonFiniteValue` validation error, and a negative distribution segment is a
|
|
50
|
+
`negativeDistributionValue` one. Use `unknown()` for data you do not have.
|
|
33
51
|
|
|
34
52
|
## Chart theming is separate from app theming
|
|
35
53
|
|
|
@@ -38,18 +56,35 @@ Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provid
|
|
|
38
56
|
|
|
39
57
|
## Public API
|
|
40
58
|
|
|
41
|
-
|
|
59
|
+
Components — all five families are memoized, and all share `BaseChartProps`
|
|
60
|
+
(`datasource`, `status`, `stateContent`, `title`, `legend`, `theme`, `formatters`,
|
|
61
|
+
`strings`, `accessibility`, `height`, `style`, `footer`, selection and visibility
|
|
62
|
+
props):
|
|
42
63
|
|
|
43
|
-
- `
|
|
44
|
-
|
|
45
|
-
- `
|
|
46
|
-
- `DATASET_BUDGETS`, `checkBudget`
|
|
47
|
-
- `isChartError`
|
|
64
|
+
- `BarChart` (`grouping`), `TimeSeriesChart` (`range`), `DistributionChart` (`shape`,
|
|
65
|
+
`innerRadiusRatio`, `valuesAre`), `ProgressChart`, `HeatmapChart` (`buckets`)
|
|
66
|
+
- `ChartLegend`, `ColorScaleLegend`, `buildLegendEntries`, `ChartThemeProvider`
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
Runtime:
|
|
50
69
|
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
- `known`, `unknown`, `notApplicable`, `withheld`, `isKnown`, `isWithheld`, `sumKnown`,
|
|
71
|
+
`participatesInTotals`
|
|
72
|
+
- `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
|
|
73
|
+
- `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
|
|
74
|
+
- `resolveStatus`, `drawsData`, `suppressesInteraction`, `STATUS_PRECEDENCE`
|
|
75
|
+
- `formatValueDefault`, `formatPositionDefault`, `formatLegendDefault`, `seriesRef`,
|
|
76
|
+
`NON_VALUE_TEXT`
|
|
77
|
+
- `buildChartSummary`, `buildPointAnnouncement`, `buildTotalAnnouncement`
|
|
78
|
+
- `DATASET_BUDGETS`, `checkBudget`, `isChartError`, `chartStrings`
|
|
79
|
+
- `FAMILY_POSITION_KINDS`, `familyAcceptsPositionKind`, `resolveFormat`, `resolveUnit`
|
|
80
|
+
|
|
81
|
+
Types: `ValueState`, `ChartDatasource`, `Series`, `DataPoint`, `Position`, `ChartFamily`,
|
|
82
|
+
`ChartStatus`, `ChartError`, `SelectionRef`, `PointInteractionEvent`, `ChartFormatters`,
|
|
83
|
+
`ChartTheme`, `LegendEntry`, `ColorScale`, `DistributionValues`, `ChartStrings`,
|
|
84
|
+
`BudgetCheck`, `DatasetBudget`
|
|
85
|
+
|
|
86
|
+
Use `sameSelection` to compare two selections; comparing the objects by reference
|
|
87
|
+
reports a change that did not happen.
|
|
53
88
|
|
|
54
89
|
## Where the detail lives
|
|
55
90
|
|
package/src/a11y/summary.ts
CHANGED
|
@@ -8,30 +8,34 @@
|
|
|
8
8
|
* Withheld values never enter a summary — that would leak exactly what the
|
|
9
9
|
* withheld class exists to protect (FR-033).
|
|
10
10
|
*/
|
|
11
|
-
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
11
|
+
import type { ChartDatasource, ChartFamily, Position } from '../contract/datasource';
|
|
12
|
+
import { formatPositionDefault } from '../contract/format';
|
|
12
13
|
import type { ReferenceMarker } from '../contract/marker';
|
|
13
14
|
import type { ChartStatus } from '../contract/state';
|
|
15
|
+
import { formatMessage } from '@chipmobilesdk/rn-core';
|
|
16
|
+
|
|
17
|
+
import { chartStrings, type ChartStrings } from '../contract/strings';
|
|
14
18
|
import { isKnown, sumKnown, type ValueState } from '../contract/value';
|
|
15
19
|
|
|
16
|
-
const FAMILY_NOUN: Record<ChartFamily,
|
|
17
|
-
progress: 'progress
|
|
18
|
-
distribution: 'distribution
|
|
19
|
-
timeSeries: '
|
|
20
|
-
bar: 'bar
|
|
21
|
-
heatmap: 'heatmap',
|
|
20
|
+
const FAMILY_NOUN: Record<ChartFamily, keyof ChartStrings> = {
|
|
21
|
+
progress: 'chart.family.progress',
|
|
22
|
+
distribution: 'chart.family.distribution',
|
|
23
|
+
timeSeries: 'chart.family.timeSeries',
|
|
24
|
+
bar: 'chart.family.bar',
|
|
25
|
+
heatmap: 'chart.family.heatmap',
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* What a screen reader hears when the chart is not showing data. Announcing
|
|
26
30
|
* the state beats silence, and beats announcing a stale value as if current.
|
|
27
31
|
*/
|
|
28
|
-
const STATUS_PHRASE: Record<ChartStatus,
|
|
29
|
-
loading: '
|
|
30
|
-
empty: '
|
|
31
|
-
error: '
|
|
32
|
-
stale: '
|
|
33
|
-
noPermission: '
|
|
34
|
-
partial: '
|
|
32
|
+
const STATUS_PHRASE: Record<ChartStatus, keyof ChartStrings | undefined> = {
|
|
33
|
+
loading: 'chart.status.loading',
|
|
34
|
+
empty: 'chart.status.empty',
|
|
35
|
+
error: 'chart.status.error',
|
|
36
|
+
stale: 'chart.status.stale',
|
|
37
|
+
noPermission: 'chart.status.noPermission',
|
|
38
|
+
partial: 'chart.status.partial',
|
|
35
39
|
ready: undefined,
|
|
36
40
|
};
|
|
37
41
|
|
|
@@ -41,15 +45,23 @@ export interface SummaryInput<M = unknown> {
|
|
|
41
45
|
readonly status: ChartStatus;
|
|
42
46
|
readonly title?: string;
|
|
43
47
|
readonly formatValue: (value: ValueState) => string;
|
|
48
|
+
/** How a position is spoken; the app's axis formatter when it has one. */
|
|
49
|
+
readonly formatPosition?: (position: Position) => string;
|
|
44
50
|
readonly incomplete?: boolean;
|
|
51
|
+
/** The texts to speak; the English defaults when omitted. */
|
|
52
|
+
readonly strings?: ChartStrings;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
function describeExtremes<M>(
|
|
48
56
|
datasource: ChartDatasource<M>,
|
|
49
57
|
formatValue: (v: ValueState) => string,
|
|
58
|
+
strings: ChartStrings,
|
|
59
|
+
formatPosition: (position: Position) => string,
|
|
50
60
|
): string {
|
|
51
|
-
|
|
52
|
-
let
|
|
61
|
+
type Extreme = { label: () => string; value: number; point: unknown };
|
|
62
|
+
let min: Extreme | undefined;
|
|
63
|
+
let max: Extreme | undefined;
|
|
64
|
+
const several = datasource.series.length > 1;
|
|
53
65
|
|
|
54
66
|
for (const series of datasource.series) {
|
|
55
67
|
for (const point of series.points) {
|
|
@@ -57,18 +69,25 @@ function describeExtremes<M>(
|
|
|
57
69
|
if (!isKnown(point.value)) {
|
|
58
70
|
continue;
|
|
59
71
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
// Named by where the point is. A time series used to name both
|
|
73
|
+
// extremes after the series, so a one-series chart reported a single
|
|
74
|
+
// value and lost its lowest (CHT-07). Built lazily: only two are read.
|
|
75
|
+
const label = (): string => {
|
|
76
|
+
if (point.position.kind === 'category') {
|
|
77
|
+
return point.position.category;
|
|
78
|
+
}
|
|
79
|
+
if (point.position.kind === 'matrix') {
|
|
80
|
+
return `${point.position.row} ${point.position.column}`;
|
|
81
|
+
}
|
|
82
|
+
const at = formatPosition(point.position);
|
|
83
|
+
return several ? `${series.label} ${at}` : at;
|
|
84
|
+
};
|
|
66
85
|
|
|
67
86
|
if (!min || point.value.value < min.value) {
|
|
68
|
-
min = { label, value: point.value.value };
|
|
87
|
+
min = { label, value: point.value.value, point };
|
|
69
88
|
}
|
|
70
89
|
if (!max || point.value.value > max.value) {
|
|
71
|
-
max = { label, value: point.value.value };
|
|
90
|
+
max = { label, value: point.value.value, point };
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
}
|
|
@@ -76,19 +95,24 @@ function describeExtremes<M>(
|
|
|
76
95
|
if (!min || !max) {
|
|
77
96
|
return '';
|
|
78
97
|
}
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
const maxText = formatValue({ kind: 'known', value: max.value });
|
|
99
|
+
// One figure only when the highest and the lowest are the same point.
|
|
100
|
+
if (min.point === max.point) {
|
|
101
|
+
return formatMessage(strings['chart.summary.single'], { value: maxText });
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
return (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
return formatMessage(strings['chart.summary.extremes'], {
|
|
105
|
+
maxLabel: max.label(),
|
|
106
|
+
max: maxText,
|
|
107
|
+
minLabel: min.label(),
|
|
108
|
+
min: formatValue({ kind: 'known', value: min.value }),
|
|
109
|
+
});
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
function describeMarkers(
|
|
90
113
|
markers: readonly ReferenceMarker[] | undefined,
|
|
91
114
|
formatValue: (v: ValueState) => string,
|
|
115
|
+
strings: ChartStrings,
|
|
92
116
|
): string {
|
|
93
117
|
if (!markers || markers.length === 0) {
|
|
94
118
|
return '';
|
|
@@ -100,7 +124,9 @@ function describeMarkers(
|
|
|
100
124
|
continue;
|
|
101
125
|
}
|
|
102
126
|
const name = marker.label ?? marker.kind;
|
|
103
|
-
parts.push(
|
|
127
|
+
parts.push(
|
|
128
|
+
formatMessage(strings['chart.summary.marker'], { name, value: formatValue(marker.value) }),
|
|
129
|
+
);
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
return parts.length > 0 ? `${parts.join('. ')}.` : '';
|
|
@@ -113,10 +139,14 @@ function describeMarkers(
|
|
|
113
139
|
* so a loading chart never announces figures it is not showing.
|
|
114
140
|
*/
|
|
115
141
|
export function buildChartSummary<M>(input: SummaryInput<M>): string {
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
const strings = input.strings ?? chartStrings.defaults;
|
|
143
|
+
const noun = strings[FAMILY_NOUN[input.family]];
|
|
144
|
+
const heading = input.title
|
|
145
|
+
? formatMessage(strings['chart.summary.heading'], { title: input.title, noun })
|
|
146
|
+
: formatMessage(strings['chart.summary.headingUntitled'], { noun });
|
|
147
|
+
|
|
148
|
+
const statusKey = STATUS_PHRASE[input.status];
|
|
149
|
+
const statusPhrase = statusKey ? strings[statusKey] : undefined;
|
|
120
150
|
if (input.status !== 'ready' && input.status !== 'partial' && input.status !== 'stale') {
|
|
121
151
|
return `${heading} ${statusPhrase ?? ''}`.trim();
|
|
122
152
|
}
|
|
@@ -125,15 +155,22 @@ export function buildChartSummary<M>(input: SummaryInput<M>): string {
|
|
|
125
155
|
const pointCount = input.datasource.series.reduce((acc, s) => acc + s.points.length, 0);
|
|
126
156
|
|
|
127
157
|
const scale =
|
|
128
|
-
seriesCount > 1
|
|
158
|
+
seriesCount > 1
|
|
159
|
+
? formatMessage(strings['chart.summary.scale'], { series: seriesCount, points: pointCount })
|
|
160
|
+
: formatMessage(strings['chart.summary.scaleSingle'], { points: pointCount });
|
|
129
161
|
|
|
130
162
|
const parts = [
|
|
131
163
|
heading,
|
|
132
164
|
statusPhrase,
|
|
133
165
|
scale,
|
|
134
|
-
describeExtremes(
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
describeExtremes(
|
|
167
|
+
input.datasource,
|
|
168
|
+
input.formatValue,
|
|
169
|
+
strings,
|
|
170
|
+
input.formatPosition ?? formatPositionDefault,
|
|
171
|
+
),
|
|
172
|
+
describeMarkers(input.datasource.markers, input.formatValue, strings),
|
|
173
|
+
input.incomplete ? strings['chart.summary.incomplete'] : '',
|
|
137
174
|
];
|
|
138
175
|
|
|
139
176
|
return parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
|
@@ -145,26 +182,28 @@ export function buildPointAnnouncement(
|
|
|
145
182
|
positionLabel: string,
|
|
146
183
|
value: ValueState,
|
|
147
184
|
formatValue: (v: ValueState) => string,
|
|
185
|
+
strings: ChartStrings = chartStrings.defaults,
|
|
148
186
|
): string {
|
|
187
|
+
const vars = { position: positionLabel, series: seriesLabel };
|
|
149
188
|
if (value.kind === 'withheld') {
|
|
150
|
-
return
|
|
189
|
+
return formatMessage(strings['chart.point.withheld'], vars);
|
|
151
190
|
}
|
|
152
191
|
if (!isKnown(value)) {
|
|
153
|
-
return
|
|
192
|
+
return formatMessage(strings['chart.point.noData'], vars);
|
|
154
193
|
}
|
|
155
|
-
return
|
|
194
|
+
return formatMessage(strings['chart.point.value'], { ...vars, value: formatValue(value) });
|
|
156
195
|
}
|
|
157
196
|
|
|
158
197
|
/** Total plus an explicit note when it is not the whole picture (FR-013). */
|
|
159
198
|
export function buildTotalAnnouncement<M>(
|
|
160
199
|
datasource: ChartDatasource<M>,
|
|
161
200
|
formatValue: (v: ValueState) => string,
|
|
201
|
+
strings: ChartStrings = chartStrings.defaults,
|
|
162
202
|
): string {
|
|
163
203
|
const values = datasource.series.flatMap(s => s.points.map(p => p.value));
|
|
164
204
|
const total = sumKnown(values);
|
|
165
|
-
|
|
166
|
-
const text = `Total ${formatValue({ kind: 'known', value: total.total })}`;
|
|
205
|
+
const value = formatValue({ kind: 'known', value: total.total });
|
|
167
206
|
return total.incomplete
|
|
168
|
-
?
|
|
169
|
-
:
|
|
207
|
+
? formatMessage(strings['chart.total.incomplete'], { value, count: total.excludedPoints })
|
|
208
|
+
: formatMessage(strings['chart.total'], { value });
|
|
170
209
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the numbers of a distribution mean (CHT-10).
|
|
3
|
+
*
|
|
4
|
+
* `proportions`: each value is already a share of the whole, in 0..1. A sum
|
|
5
|
+
* short of 1 is drawn short and reported incomplete. `absolute`: counts or
|
|
6
|
+
* amounts, normalised by their own total.
|
|
7
|
+
*
|
|
8
|
+
* Until 0.4.0 this was guessed from the values alone: anything in 0..1 was a
|
|
9
|
+
* proportion, so two segments counting 1 each drew two full circles on top of
|
|
10
|
+
* one another. The guess now also requires the shares to fit in one whole, and
|
|
11
|
+
* an app that knows can say so.
|
|
12
|
+
*/
|
|
13
|
+
import type { ChartDatasource } from './datasource';
|
|
14
|
+
import { isKnown } from './value';
|
|
15
|
+
|
|
16
|
+
export type DistributionValues = 'proportions' | 'absolute';
|
|
17
|
+
|
|
18
|
+
export const DISTRIBUTION_SUM_TOLERANCE = 0.001;
|
|
19
|
+
|
|
20
|
+
export function resolveDistributionValues<M>(
|
|
21
|
+
datasource: ChartDatasource<M>,
|
|
22
|
+
declared?: DistributionValues,
|
|
23
|
+
): DistributionValues {
|
|
24
|
+
if (declared !== undefined) {
|
|
25
|
+
return declared;
|
|
26
|
+
}
|
|
27
|
+
if (datasource.format?.kind === 'percent') {
|
|
28
|
+
return 'proportions';
|
|
29
|
+
}
|
|
30
|
+
let sum = 0;
|
|
31
|
+
for (const series of datasource.series) {
|
|
32
|
+
for (const point of series.points) {
|
|
33
|
+
if (!isKnown(point.value)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const value = point.value.value;
|
|
37
|
+
if (value < 0 || value > 1) {
|
|
38
|
+
return 'absolute';
|
|
39
|
+
}
|
|
40
|
+
sum += value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return sum <= 1 + DISTRIBUTION_SUM_TOLERANCE ? 'proportions' : 'absolute';
|
|
44
|
+
}
|
package/src/contract/errors.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type ChartErrorCode =
|
|
|
18
18
|
| 'familyPositionKindUnsupported'
|
|
19
19
|
| 'inconsistentMatrix'
|
|
20
20
|
| 'distributionNotWhole'
|
|
21
|
+
| 'nonFiniteValue'
|
|
22
|
+
| 'negativeDistributionValue'
|
|
21
23
|
| 'declaredByApp'
|
|
22
24
|
| 'renderException';
|
|
23
25
|
|
|
@@ -34,6 +36,10 @@ const MESSAGES: Readonly<Record<ChartErrorCode, string>> = {
|
|
|
34
36
|
inconsistentMatrix: 'Matrix datasource rows and columns are inconsistent across points.',
|
|
35
37
|
distributionNotWhole:
|
|
36
38
|
'Distribution segments do not sum to the whole. The chart marks the total incomplete rather than rescaling.',
|
|
39
|
+
nonFiniteValue:
|
|
40
|
+
'A known value or an instant is not a finite number. Use an unknown value for missing data.',
|
|
41
|
+
negativeDistributionValue:
|
|
42
|
+
'A distribution segment has a negative value. A share of a whole cannot be negative.',
|
|
37
43
|
declaredByApp: 'The application declared this chart to be in an error state.',
|
|
38
44
|
renderException:
|
|
39
45
|
'The chart failed while rendering and was contained. See the report for details.',
|
package/src/contract/format.ts
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* applies a documented locale-neutral default, and uses app-supplied formatter
|
|
6
6
|
* output verbatim whenever one is given.
|
|
7
7
|
*/
|
|
8
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
8
10
|
import type { Position, Series } from './datasource';
|
|
9
11
|
import { isKnown, type ValueState } from './value';
|
|
10
12
|
|
|
13
|
+
/** Chart diagnostics also reach the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
14
|
+
const channel = createDiagnosticChannel<'formatter_threw'>('rn-chart');
|
|
15
|
+
|
|
11
16
|
export type FormatHint =
|
|
12
17
|
| { readonly kind: 'number'; readonly decimals?: number }
|
|
13
18
|
| { readonly kind: 'percent'; readonly decimals?: number }
|
|
@@ -144,6 +149,7 @@ function warnFormatterFailureOnce(
|
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
146
151
|
warnedFormatters.add(key);
|
|
152
|
+
channel.emit('formatter_threw', 'warn', { slot });
|
|
147
153
|
const reason = error instanceof Error ? error.message : 'unknown error';
|
|
148
154
|
console.warn(
|
|
149
155
|
`[rn-chart] A supplied "${slot}" formatter threw (${reason}). ` +
|
|
@@ -18,7 +18,7 @@ export interface SelectionRef {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface PointInteractionEvent<M = unknown> {
|
|
21
|
-
readonly type: 'pointSelect'
|
|
21
|
+
readonly type: 'pointSelect';
|
|
22
22
|
readonly seriesId: string;
|
|
23
23
|
readonly pointId: string;
|
|
24
24
|
readonly value: ValueState;
|
|
@@ -27,7 +27,7 @@ export interface PointInteractionEvent<M = unknown> {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface SeriesInteractionEvent<M = unknown> {
|
|
30
|
-
readonly type: '
|
|
30
|
+
readonly type: 'visibilityChange';
|
|
31
31
|
readonly seriesId: string;
|
|
32
32
|
readonly visible?: boolean;
|
|
33
33
|
readonly metadata?: M;
|
package/src/contract/props.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* See specs/007-rn-chart-package/contracts/public-api.md §2 for the contract
|
|
5
5
|
* behind each field.
|
|
6
6
|
*/
|
|
7
|
+
import type { ChartStrings } from './strings';
|
|
7
8
|
import type { ReactNode } from 'react';
|
|
8
9
|
import type { StyleProp, ViewStyle } from 'react-native';
|
|
9
10
|
|
|
@@ -46,6 +47,12 @@ export interface BaseChartProps<M = unknown> extends InteractionProps<M> {
|
|
|
46
47
|
readonly legend?: boolean | { readonly position: 'top' | 'bottom' };
|
|
47
48
|
|
|
48
49
|
readonly accessibility?: ChartAccessibility<M>;
|
|
50
|
+
/**
|
|
51
|
+
* The package's own texts in the app's language: summaries, announcements,
|
|
52
|
+
* action labels, built-in state text, legend hints (`chartStrings` holds the
|
|
53
|
+
* keys and English defaults).
|
|
54
|
+
*/
|
|
55
|
+
readonly strings?: Partial<ChartStrings>;
|
|
49
56
|
|
|
50
57
|
/** Width always comes from the container. */
|
|
51
58
|
readonly height?: number;
|