@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 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**. It warns once per
303
- chart in development builds, and never in production. Aggregating or
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.2.1",
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 --noEmit",
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 refuses to render a large dataset
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
- ## Datasets are budgeted
29
+ ## Dataset budgets are a development warning, not a limit
29
30
 
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`.
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
- Runtime:
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
- - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
44
- - `ChartLegend`, `buildLegendEntries`
45
- - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
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
- Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
68
+ Runtime:
50
69
 
51
- Use `sameSelection` for selection equality — comparing selection objects by reference
52
- causes redundant re-renders.
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
 
@@ -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, string> = {
17
- progress: 'progress chart',
18
- distribution: 'distribution chart',
19
- timeSeries: 'time series chart',
20
- bar: 'bar chart',
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, 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.',
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
- let min: { label: string; value: number } | undefined;
52
- let max: { label: string; value: number } | undefined;
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
- 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;
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
- if (min.label === max.label) {
80
- return `Value ${formatValue({ kind: 'known', value: max.value })}.`;
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
- `Highest ${max.label} at ${formatValue({ kind: 'known', value: max.value })}. ` +
85
- `Lowest ${min.label} at ${formatValue({ kind: 'known', value: min.value })}.`
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(`${name} ${formatValue(marker.value)}`);
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 noun = FAMILY_NOUN[input.family];
117
- const heading = input.title ? `${input.title}, ${noun}.` : `${noun}.`;
118
-
119
- const statusPhrase = STATUS_PHRASE[input.status];
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 ? `${seriesCount} series, ${pointCount} points.` : `${pointCount} points.`;
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(input.datasource, input.formatValue),
135
- describeMarkers(input.datasource.markers, input.formatValue),
136
- input.incomplete ? 'Some values are missing, so totals are incomplete.' : '',
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 `${positionLabel}, ${seriesLabel}, not available.`;
189
+ return formatMessage(strings['chart.point.withheld'], vars);
151
190
  }
152
191
  if (!isKnown(value)) {
153
- return `${positionLabel}, ${seriesLabel}, no data.`;
192
+ return formatMessage(strings['chart.point.noData'], vars);
154
193
  }
155
- return `${positionLabel}, ${seriesLabel}, ${formatValue(value)}.`;
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
- ? `${text}, excluding ${total.excludedPoints} values that are not available.`
169
- : `${text}.`;
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
+ }
@@ -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.',
@@ -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' | 'segmentPress';
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: 'seriesSelect' | 'visibilityChange';
30
+ readonly type: 'visibilityChange';
31
31
  readonly seriesId: string;
32
32
  readonly visible?: boolean;
33
33
  readonly metadata?: M;
@@ -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;