@chipmobilesdk/rn-chart 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +164 -115
  2. package/README.md +391 -381
  3. package/package.json +49 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +190 -177
  6. package/src/contract/datasource.ts +126 -136
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +168 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +64 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/strings.ts +48 -0
  14. package/src/contract/theme.ts +169 -177
  15. package/src/contract/value.ts +121 -125
  16. package/src/geometry/bar.ts +271 -273
  17. package/src/geometry/distribution.ts +197 -203
  18. package/src/geometry/heatmap.ts +189 -195
  19. package/src/geometry/hitTest.ts +117 -120
  20. package/src/geometry/layout.ts +162 -173
  21. package/src/geometry/markers.ts +55 -57
  22. package/src/geometry/path.ts +193 -199
  23. package/src/geometry/progress.ts +223 -230
  24. package/src/geometry/scale.ts +140 -146
  25. package/src/geometry/ticks.ts +145 -153
  26. package/src/geometry/timeSeries.ts +168 -168
  27. package/src/geometry/types.ts +94 -94
  28. package/src/index.ts +162 -176
  29. package/src/react/ChartBoundary.tsx +96 -96
  30. package/src/react/ChartFrame.tsx +596 -622
  31. package/src/react/ChartLegend.tsx +216 -215
  32. package/src/react/ChartThemeProvider.tsx +44 -48
  33. package/src/react/charts.tsx +154 -161
  34. package/src/react/useChartLayout.ts +124 -124
  35. package/src/react/useChartSelection.ts +152 -170
  36. package/src/render/Axis.tsx +120 -122
  37. package/src/render/Marks.tsx +173 -205
  38. package/src/validation/budgets.ts +113 -110
  39. package/src/validation/validateDatasource.ts +174 -174
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "@chipmobilesdk/rn-chart",
3
- "version": "0.2.0",
4
- "private": false,
5
- "license": "UNLICENSED",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./src/index.ts",
11
- "react-native": "./src/index.ts",
12
- "default": "./src/index.ts"
13
- }
14
- },
15
- "files": [
16
- "src/index.ts",
17
- "src/a11y",
18
- "src/contract",
19
- "src/geometry",
20
- "src/react",
21
- "src/render",
22
- "src/validation",
23
- "skills",
24
- "README.md",
25
- "CHANGELOG.md"
26
- ],
27
- "sideEffects": false,
28
- "scripts": {
29
- "typecheck": "tsc -p tsconfig.json --noEmit",
30
- "pack:dry-run": "npm pack --dry-run"
31
- },
32
- "peerDependencies": {
33
- "react": ">=19",
34
- "react-native": ">=0.85",
35
- "react-native-svg": ">=15.15"
36
- },
37
- "peerDependenciesMeta": {
38
- "react-native-svg": {
39
- "optional": false
40
- }
41
- },
42
- "engines": {
43
- "node": ">=22.11.0"
44
- },
45
- "publishConfig": {
46
- "access": "public",
47
- "registry": "https://registry.npmjs.org/"
48
- }
49
- }
1
+ {
2
+ "name": "@chipmobilesdk/rn-chart",
3
+ "version": "0.3.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/index.ts",
17
+ "src/a11y",
18
+ "src/contract",
19
+ "src/geometry",
20
+ "src/react",
21
+ "src/render",
22
+ "src/validation",
23
+ "skills",
24
+ "README.md",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "sideEffects": false,
28
+ "scripts": {
29
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
30
+ "pack:check": "npm pack --dry-run"
31
+ },
32
+ "dependencies": {
33
+ "@chipmobilesdk/rn-core": "^0.1.0"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=19",
37
+ "react-native": ">=0.85",
38
+ "react-native-svg": ">=15.15"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react-native-svg": {
42
+ "optional": false
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
@@ -1,69 +1,68 @@
1
- ---
2
- name: sdk-rn-chart
3
- description: "Charts via @chipmobilesdk/rn-chart: datasource contract, series theming, legends, selection, dataset budgets, chart accessibility. Use when rendering a chart, wiring chart data, theming series, or hitting a dataset size limit."
4
- metadata:
5
- package: "@chipmobilesdk/rn-chart"
6
- packageVersion: "0.1.0"
7
- user-invocable: true
8
- disable-model-invocation: false
9
- ---
10
-
11
- # @chipmobilesdk/rn-chart
12
-
13
- Chart rendering with an explicit datasource contract, its own theme provider,
14
- accessibility support and enforced dataset budgets.
15
-
16
- ## Use this when
17
-
18
- - Rendering any chart or adding a series
19
- - Shaping data to the datasource contract
20
- - Theming series colors or building a legend
21
- - Handling selection / interaction on a chart
22
- - A chart refuses to render a large dataset
23
-
24
- ## Check what it deliberately does not do
25
-
26
- The README opens with `## What it does not do`. Read that first — it prevents building
27
- a feature this package intentionally excludes.
28
-
29
- ## Datasets are budgeted
30
-
31
- `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
32
- low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
33
- the budget. See `## Dataset budgets`.
34
-
35
- ## Chart theming is separate from app theming
36
-
37
- Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
38
- `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
39
-
40
- ## Public API (v0.2.0)
41
-
42
- Runtime:
43
-
44
- - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
45
- - `ChartLegend`, `buildLegendEntries`
46
- - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
47
- - `DATASET_BUDGETS`, `checkBudget`
48
- - `isChartError`
49
-
50
- Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
51
-
52
- Use `sameSelection` for selection equality — comparing selection objects by reference
53
- causes redundant re-renders.
54
-
55
- ## Where the detail lives
56
-
57
- Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
58
-
59
- | Need | Section |
60
- |---|---|
61
- | Scope check | `## What it does not do` |
62
- | First integration | `## Install`, `## Quick start` |
63
- | Shaping data | `## The datasource contract`, `## Value semantics` |
64
- | Loading/empty/error rendering | `## States` |
65
- | Touch and selection | `## Interaction` |
66
- | Colors and legends | `## Theming` |
67
- | Axis/label formatting | `## Formatting` |
68
- | Screen reader support | `## Accessibility` |
69
- | Size limits | `## Dataset budgets` |
1
+ ---
2
+ name: sdk-rn-chart
3
+ description: "Charts via @chipmobilesdk/rn-chart: datasource contract, series theming, legends, selection, dataset budgets, chart accessibility. Use when rendering a chart, wiring chart data, theming series, or hitting a dataset size limit."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-chart"
6
+ user-invocable: true
7
+ disable-model-invocation: false
8
+ ---
9
+
10
+ # @chipmobilesdk/rn-chart
11
+
12
+ Chart rendering with an explicit datasource contract, its own theme provider,
13
+ accessibility support and enforced dataset budgets.
14
+
15
+ ## Use this when
16
+
17
+ - Rendering any chart or adding a series
18
+ - Shaping data to the datasource contract
19
+ - Theming series colors or building a legend
20
+ - Handling selection / interaction on a chart
21
+ - A chart refuses to render a large dataset
22
+
23
+ ## Check what it deliberately does not do
24
+
25
+ The README opens with `## What it does not do`. Read that first — it prevents building
26
+ a feature this package intentionally excludes.
27
+
28
+ ## Datasets are budgeted
29
+
30
+ `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
31
+ low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
32
+ the budget. See `## Dataset budgets`.
33
+
34
+ ## Chart theming is separate from app theming
35
+
36
+ Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
37
+ `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
38
+
39
+ ## Public API
40
+
41
+ Runtime:
42
+
43
+ - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
44
+ - `ChartLegend`, `buildLegendEntries`
45
+ - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
46
+ - `DATASET_BUDGETS`, `checkBudget`
47
+ - `isChartError`
48
+
49
+ Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
50
+
51
+ Use `sameSelection` for selection equality — comparing selection objects by reference
52
+ causes redundant re-renders.
53
+
54
+ ## Where the detail lives
55
+
56
+ Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
57
+
58
+ | Need | Section |
59
+ |---|---|
60
+ | Scope check | `## What it does not do` |
61
+ | First integration | `## Install`, `## Quick start` |
62
+ | Shaping data | `## The datasource contract`, `## Value semantics` |
63
+ | Loading/empty/error rendering | `## States` |
64
+ | Touch and selection | `## Interaction` |
65
+ | Colors and legends | `## Theming` |
66
+ | Axis/label formatting | `## Formatting` |
67
+ | Screen reader support | `## Accessibility` |
68
+ | Size limits | `## Dataset budgets` |
@@ -1,177 +1,190 @@
1
- /**
2
- * Composed accessible summaries (FR-067, FR-068, FR-069, FR-072).
3
- *
4
- * Exposing 2,500 individual points to a screen reader would be unusable even
5
- * if it were cheap. A composed summary covers the same need: what the chart is,
6
- * what it shows, where the extremes are, and what any target says.
7
- *
8
- * Withheld values never enter a summary — that would leak exactly what the
9
- * withheld class exists to protect (FR-033).
10
- */
11
- import type { ChartDatasource, ChartFamily } from '../contract/datasource';
12
- import type { ReferenceMarker } from '../contract/marker';
13
- import type { ChartStatus } from '../contract/state';
14
- import { isKnown, sumKnown, type ValueState } from '../contract/value';
15
-
16
- const FAMILY_NOUN: Record<ChartFamily, string> = {
17
- progress: 'progress chart',
18
- distribution: 'distribution chart',
19
- timeSeries: 'time series chart',
20
- bar: 'bar chart',
21
- heatmap: 'heatmap',
22
- };
23
-
24
- /**
25
- * What a screen reader hears when the chart is not showing data. Announcing
26
- * the state beats silence, and beats announcing a stale value as if current.
27
- */
28
- const STATUS_PHRASE: Record<ChartStatus, string | undefined> = {
29
- loading: 'Loading data.',
30
- empty: 'No data yet.',
31
- error: 'Data could not be shown.',
32
- stale: 'Showing data that may be out of date.',
33
- noPermission: 'This data is not available to you.',
34
- partial: 'Some data is missing.',
35
- ready: undefined,
36
- };
37
-
38
- export interface SummaryInput<M = unknown> {
39
- readonly datasource: ChartDatasource<M>;
40
- readonly family: ChartFamily;
41
- readonly status: ChartStatus;
42
- readonly title?: string;
43
- readonly formatValue: (value: ValueState) => string;
44
- readonly incomplete?: boolean;
45
- }
46
-
47
- function describeExtremes<M>(
48
- datasource: ChartDatasource<M>,
49
- formatValue: (v: ValueState) => string,
50
- ): string {
51
- let min: { label: string; value: number } | undefined;
52
- let max: { label: string; value: number } | undefined;
53
-
54
- for (const series of datasource.series) {
55
- for (const point of series.points) {
56
- // Withheld and unknown values are skipped entirely.
57
- if (!isKnown(point.value)) {
58
- continue;
59
- }
60
- const label =
61
- point.position.kind === 'category'
62
- ? point.position.category
63
- : point.position.kind === 'matrix'
64
- ? `${point.position.row} ${point.position.column}`
65
- : series.label;
66
-
67
- if (!min || point.value.value < min.value) {
68
- min = { label, value: point.value.value };
69
- }
70
- if (!max || point.value.value > max.value) {
71
- max = { label, value: point.value.value };
72
- }
73
- }
74
- }
75
-
76
- if (!min || !max) {
77
- return '';
78
- }
79
- if (min.label === max.label) {
80
- return `Value ${formatValue({ kind: 'known', value: max.value })}.`;
81
- }
82
-
83
- return (
84
- `Highest ${max.label} at ${formatValue({ kind: 'known', value: max.value })}. ` +
85
- `Lowest ${min.label} at ${formatValue({ kind: 'known', value: min.value })}.`
86
- );
87
- }
88
-
89
- function describeMarkers(
90
- markers: readonly ReferenceMarker[] | undefined,
91
- formatValue: (v: ValueState) => string,
92
- ): string {
93
- if (!markers || markers.length === 0) {
94
- return '';
95
- }
96
-
97
- const parts: string[] = [];
98
- for (const marker of markers) {
99
- if (!isKnown(marker.value)) {
100
- continue;
101
- }
102
- const name = marker.label ?? marker.kind;
103
- parts.push(`${name} ${formatValue(marker.value)}`);
104
- }
105
-
106
- return parts.length > 0 ? `${parts.join('. ')}.` : '';
107
- }
108
-
109
- /**
110
- * Builds the label read aloud for the chart as a whole.
111
- *
112
- * Non-ready statuses replace the data description rather than appending to it,
113
- * so a loading chart never announces figures it is not showing.
114
- */
115
- export function buildChartSummary<M>(input: SummaryInput<M>): string {
116
- const noun = FAMILY_NOUN[input.family];
117
- const heading = input.title ? `${input.title}, ${noun}.` : `${noun}.`;
118
-
119
- const statusPhrase = STATUS_PHRASE[input.status];
120
- if (input.status !== 'ready' && input.status !== 'partial' && input.status !== 'stale') {
121
- return `${heading} ${statusPhrase ?? ''}`.trim();
122
- }
123
-
124
- const seriesCount = input.datasource.series.length;
125
- const pointCount = input.datasource.series.reduce(
126
- (acc, s) => acc + s.points.length,
127
- 0,
128
- );
129
-
130
- const scale =
131
- seriesCount > 1
132
- ? `${seriesCount} series, ${pointCount} points.`
133
- : `${pointCount} points.`;
134
-
135
- const parts = [
136
- heading,
137
- statusPhrase,
138
- scale,
139
- describeExtremes(input.datasource, input.formatValue),
140
- describeMarkers(input.datasource.markers, input.formatValue),
141
- input.incomplete
142
- ? 'Some values are missing, so totals are incomplete.'
143
- : '',
144
- ];
145
-
146
- return parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
147
- }
148
-
149
- /** Announced for the currently selected point on an adjustable chart. */
150
- export function buildPointAnnouncement(
151
- seriesLabel: string,
152
- positionLabel: string,
153
- value: ValueState,
154
- formatValue: (v: ValueState) => string,
155
- ): string {
156
- if (value.kind === 'withheld') {
157
- return `${positionLabel}, ${seriesLabel}, not available.`;
158
- }
159
- if (!isKnown(value)) {
160
- return `${positionLabel}, ${seriesLabel}, no data.`;
161
- }
162
- return `${positionLabel}, ${seriesLabel}, ${formatValue(value)}.`;
163
- }
164
-
165
- /** Total plus an explicit note when it is not the whole picture (FR-013). */
166
- export function buildTotalAnnouncement<M>(
167
- datasource: ChartDatasource<M>,
168
- formatValue: (v: ValueState) => string,
169
- ): string {
170
- const values = datasource.series.flatMap(s => s.points.map(p => p.value));
171
- const total = sumKnown(values);
172
-
173
- const text = `Total ${formatValue({ kind: 'known', value: total.total })}`;
174
- return total.incomplete
175
- ? `${text}, excluding ${total.excludedPoints} values that are not available.`
176
- : `${text}.`;
177
- }
1
+ /**
2
+ * Composed accessible summaries (FR-067, FR-068, FR-069, FR-072).
3
+ *
4
+ * Exposing 2,500 individual points to a screen reader would be unusable even
5
+ * if it were cheap. A composed summary covers the same need: what the chart is,
6
+ * what it shows, where the extremes are, and what any target says.
7
+ *
8
+ * Withheld values never enter a summary — that would leak exactly what the
9
+ * withheld class exists to protect (FR-033).
10
+ */
11
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
12
+ import type { ReferenceMarker } from '../contract/marker';
13
+ import type { ChartStatus } from '../contract/state';
14
+ import { formatMessage } from '@chipmobilesdk/rn-core';
15
+
16
+ import { chartStrings, type ChartStrings } from '../contract/strings';
17
+ import { isKnown, sumKnown, type ValueState } from '../contract/value';
18
+
19
+ const FAMILY_NOUN: Record<ChartFamily, keyof ChartStrings> = {
20
+ progress: 'chart.family.progress',
21
+ distribution: 'chart.family.distribution',
22
+ timeSeries: 'chart.family.timeSeries',
23
+ bar: 'chart.family.bar',
24
+ heatmap: 'chart.family.heatmap',
25
+ };
26
+
27
+ /**
28
+ * What a screen reader hears when the chart is not showing data. Announcing
29
+ * the state beats silence, and beats announcing a stale value as if current.
30
+ */
31
+ const STATUS_PHRASE: Record<ChartStatus, keyof ChartStrings | undefined> = {
32
+ loading: 'chart.status.loading',
33
+ empty: 'chart.status.empty',
34
+ error: 'chart.status.error',
35
+ stale: 'chart.status.stale',
36
+ noPermission: 'chart.status.noPermission',
37
+ partial: 'chart.status.partial',
38
+ ready: undefined,
39
+ };
40
+
41
+ export interface SummaryInput<M = unknown> {
42
+ readonly datasource: ChartDatasource<M>;
43
+ readonly family: ChartFamily;
44
+ readonly status: ChartStatus;
45
+ readonly title?: string;
46
+ readonly formatValue: (value: ValueState) => string;
47
+ readonly incomplete?: boolean;
48
+ /** The texts to speak; the English defaults when omitted. */
49
+ readonly strings?: ChartStrings;
50
+ }
51
+
52
+ function describeExtremes<M>(
53
+ datasource: ChartDatasource<M>,
54
+ formatValue: (v: ValueState) => string,
55
+ strings: ChartStrings,
56
+ ): string {
57
+ let min: { label: string; value: number } | undefined;
58
+ let max: { label: string; value: number } | undefined;
59
+
60
+ for (const series of datasource.series) {
61
+ for (const point of series.points) {
62
+ // Withheld and unknown values are skipped entirely.
63
+ if (!isKnown(point.value)) {
64
+ continue;
65
+ }
66
+ const label =
67
+ point.position.kind === 'category'
68
+ ? point.position.category
69
+ : point.position.kind === 'matrix'
70
+ ? `${point.position.row} ${point.position.column}`
71
+ : series.label;
72
+
73
+ if (!min || point.value.value < min.value) {
74
+ min = { label, value: point.value.value };
75
+ }
76
+ if (!max || point.value.value > max.value) {
77
+ max = { label, value: point.value.value };
78
+ }
79
+ }
80
+ }
81
+
82
+ if (!min || !max) {
83
+ return '';
84
+ }
85
+ const maxText = formatValue({ kind: 'known', value: max.value });
86
+ if (min.label === max.label) {
87
+ return formatMessage(strings['chart.summary.single'], { value: maxText });
88
+ }
89
+
90
+ return formatMessage(strings['chart.summary.extremes'], {
91
+ maxLabel: max.label,
92
+ max: maxText,
93
+ minLabel: min.label,
94
+ min: formatValue({ kind: 'known', value: min.value }),
95
+ });
96
+ }
97
+
98
+ function describeMarkers(
99
+ markers: readonly ReferenceMarker[] | undefined,
100
+ formatValue: (v: ValueState) => string,
101
+ strings: ChartStrings,
102
+ ): string {
103
+ if (!markers || markers.length === 0) {
104
+ return '';
105
+ }
106
+
107
+ const parts: string[] = [];
108
+ for (const marker of markers) {
109
+ if (!isKnown(marker.value)) {
110
+ continue;
111
+ }
112
+ const name = marker.label ?? marker.kind;
113
+ parts.push(
114
+ formatMessage(strings['chart.summary.marker'], { name, value: formatValue(marker.value) }),
115
+ );
116
+ }
117
+
118
+ return parts.length > 0 ? `${parts.join('. ')}.` : '';
119
+ }
120
+
121
+ /**
122
+ * Builds the label read aloud for the chart as a whole.
123
+ *
124
+ * Non-ready statuses replace the data description rather than appending to it,
125
+ * so a loading chart never announces figures it is not showing.
126
+ */
127
+ export function buildChartSummary<M>(input: SummaryInput<M>): string {
128
+ const strings = input.strings ?? chartStrings.defaults;
129
+ const noun = strings[FAMILY_NOUN[input.family]];
130
+ const heading = input.title
131
+ ? formatMessage(strings['chart.summary.heading'], { title: input.title, noun })
132
+ : formatMessage(strings['chart.summary.headingUntitled'], { noun });
133
+
134
+ const statusKey = STATUS_PHRASE[input.status];
135
+ const statusPhrase = statusKey ? strings[statusKey] : undefined;
136
+ if (input.status !== 'ready' && input.status !== 'partial' && input.status !== 'stale') {
137
+ return `${heading} ${statusPhrase ?? ''}`.trim();
138
+ }
139
+
140
+ const seriesCount = input.datasource.series.length;
141
+ const pointCount = input.datasource.series.reduce((acc, s) => acc + s.points.length, 0);
142
+
143
+ const scale =
144
+ seriesCount > 1
145
+ ? formatMessage(strings['chart.summary.scale'], { series: seriesCount, points: pointCount })
146
+ : formatMessage(strings['chart.summary.scaleSingle'], { points: pointCount });
147
+
148
+ const parts = [
149
+ heading,
150
+ statusPhrase,
151
+ scale,
152
+ describeExtremes(input.datasource, input.formatValue, strings),
153
+ describeMarkers(input.datasource.markers, input.formatValue, strings),
154
+ input.incomplete ? strings['chart.summary.incomplete'] : '',
155
+ ];
156
+
157
+ return parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
158
+ }
159
+
160
+ /** Announced for the currently selected point on an adjustable chart. */
161
+ export function buildPointAnnouncement(
162
+ seriesLabel: string,
163
+ positionLabel: string,
164
+ value: ValueState,
165
+ formatValue: (v: ValueState) => string,
166
+ strings: ChartStrings = chartStrings.defaults,
167
+ ): string {
168
+ const vars = { position: positionLabel, series: seriesLabel };
169
+ if (value.kind === 'withheld') {
170
+ return formatMessage(strings['chart.point.withheld'], vars);
171
+ }
172
+ if (!isKnown(value)) {
173
+ return formatMessage(strings['chart.point.noData'], vars);
174
+ }
175
+ return formatMessage(strings['chart.point.value'], { ...vars, value: formatValue(value) });
176
+ }
177
+
178
+ /** Total plus an explicit note when it is not the whole picture (FR-013). */
179
+ export function buildTotalAnnouncement<M>(
180
+ datasource: ChartDatasource<M>,
181
+ formatValue: (v: ValueState) => string,
182
+ strings: ChartStrings = chartStrings.defaults,
183
+ ): string {
184
+ const values = datasource.series.flatMap(s => s.points.map(p => p.value));
185
+ const total = sumKnown(values);
186
+ const value = formatValue({ kind: 'known', value: total.total });
187
+ return total.incomplete
188
+ ? formatMessage(strings['chart.total.incomplete'], { value, count: total.excludedPoints })
189
+ : formatMessage(strings['chart.total'], { value });
190
+ }