@chipmobilesdk/rn-chart 0.3.0 → 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.
@@ -6,11 +6,13 @@
6
6
  * carries a dash pattern as well as a colour, so series stay distinguishable
7
7
  * without colour perception.
8
8
  */
9
- import { chartStrings, type ChartStrings } from '../contract/strings';
9
+ import { formatMessage } from '@chipmobilesdk/rn-core';
10
10
  import React from 'react';
11
11
  import { Pressable, StyleSheet, Text, View } from 'react-native';
12
12
 
13
13
  import type { ChartDatasource, ChartFamily } from '../contract/datasource';
14
+ import type { SeriesRef } from '../contract/format';
15
+ import { chartStrings, type ChartStrings } from '../contract/strings';
14
16
  import {
15
17
  resolveSegmentColors,
16
18
  resolveSeriesColor,
@@ -18,6 +20,7 @@ import {
18
20
  type ChartTheme,
19
21
  type LegendEntry,
20
22
  } from '../contract/theme';
23
+ import type { ColorScale } from '../geometry/types';
21
24
 
22
25
  /** Minimum tap target, per platform accessibility guidance (UX-003). */
23
26
  const MIN_TAP_TARGET = 44;
@@ -33,13 +36,15 @@ export function buildLegendEntries<M>(
33
36
  hidden: ReadonlySet<string>,
34
37
  toggleable: boolean,
35
38
  family?: ChartFamily,
39
+ /** The app's `legendLabel` formatter, when it has one. */
40
+ formatLabel: (series: SeriesRef) => string = series => series.label,
36
41
  ): LegendEntry[] {
37
42
  if (family === 'distribution') {
38
43
  const colors = resolveSegmentColors(datasource, theme);
39
44
  return datasource.series.flatMap(series =>
40
45
  series.points.map(point => {
41
46
  const single = series.points.length === 1;
42
- let label = series.label;
47
+ let label = formatLabel({ id: series.id, label: series.label });
43
48
  if (!single) {
44
49
  label = point.position.kind === 'category' ? point.position.category : point.id;
45
50
  }
@@ -58,7 +63,7 @@ export function buildLegendEntries<M>(
58
63
  }
59
64
  return datasource.series.map((series, index) => ({
60
65
  seriesId: series.id,
61
- label: series.label,
66
+ label: formatLabel({ id: series.id, label: series.label }),
62
67
  color: resolveSeriesColor(theme, index, series.color),
63
68
  pattern: series.pattern ?? 'solid',
64
69
  visible: !hidden.has(series.id),
@@ -178,7 +183,59 @@ export function ChartLegend({
178
183
  );
179
184
  }
180
185
 
186
+ /**
187
+ * The key to a colour ramp: its lowest and highest value around the colours
188
+ * between them. Without it a heatmap's shades meant nothing (CHT-15).
189
+ */
190
+ export function ColorScaleLegend({
191
+ scale,
192
+ theme,
193
+ formatValue,
194
+ strings = chartStrings.defaults,
195
+ }: {
196
+ readonly scale: ColorScale;
197
+ readonly theme: ChartTheme;
198
+ readonly formatValue: (value: number) => string;
199
+ readonly strings?: ChartStrings;
200
+ }) {
201
+ const min = formatValue(scale.min);
202
+ const max = formatValue(scale.max);
203
+ const text = {
204
+ color: theme.typography.legendLabel.color,
205
+ fontSize: theme.typography.legendLabel.fontSize,
206
+ };
207
+ return (
208
+ <View
209
+ style={styles.scale}
210
+ accessible
211
+ accessibilityRole="text"
212
+ accessibilityLabel={formatMessage(strings['chart.legend.scale'], { min, max })}
213
+ >
214
+ <Text style={text}>{min}</Text>
215
+ <View style={styles.scaleRamp}>
216
+ {scale.colors.map((color, index) => (
217
+ <View key={`${index}-${color}`} style={[styles.scaleStep, { backgroundColor: color }]} />
218
+ ))}
219
+ </View>
220
+ <Text style={text}>{max}</Text>
221
+ </View>
222
+ );
223
+ }
224
+
181
225
  const styles = StyleSheet.create({
226
+ scale: {
227
+ flexDirection: 'row',
228
+ alignItems: 'center',
229
+ marginTop: 4,
230
+ },
231
+ scaleRamp: {
232
+ flexDirection: 'row',
233
+ marginHorizontal: 8,
234
+ },
235
+ scaleStep: {
236
+ width: 16,
237
+ height: 8,
238
+ },
182
239
  container: {
183
240
  flexDirection: 'row',
184
241
  flexWrap: 'wrap',
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Everything the chart draws from its geometry.
3
+ *
4
+ * Memoized on the geometry: selecting a point re-renders the frame and moves
5
+ * the highlight, and this subtree — the long path strings — is skipped
6
+ * (CHT-08). It used to be reconciled again on every tap.
7
+ */
8
+ import React from 'react';
9
+
10
+ import type { ChartTheme } from '../contract/theme';
11
+ import type { ResolvedGeometry } from '../geometry/types';
12
+ import { Grid, XAxis, YAxis } from '../render/Axis';
13
+ import { GeometryLabels, MarkerLines, Marks, NonValueTextures } from '../render/Marks';
14
+
15
+ /** `labels` names rows and columns without drawing axis lines or a grid. */
16
+ export type ChartAxes = 'full' | 'labels' | 'none';
17
+
18
+ export const ChartPlot = React.memo(function ChartPlot({
19
+ geometry,
20
+ theme,
21
+ fontSize,
22
+ axes,
23
+ }: {
24
+ readonly geometry: ResolvedGeometry;
25
+ readonly theme: ChartTheme;
26
+ readonly fontSize: number;
27
+ readonly axes: ChartAxes;
28
+ }) {
29
+ const lines = axes === 'full';
30
+ return (
31
+ <>
32
+ <NonValueTextures theme={theme} />
33
+ {axes !== 'none' && (
34
+ <>
35
+ {lines && <Grid plot={geometry.plot} yTicks={geometry.yTicks} theme={theme} />}
36
+ <YAxis
37
+ plot={geometry.plot}
38
+ ticks={geometry.yTicks}
39
+ theme={theme}
40
+ fontSize={fontSize}
41
+ line={lines}
42
+ />
43
+ <XAxis
44
+ plot={geometry.plot}
45
+ ticks={geometry.xTicks}
46
+ theme={theme}
47
+ fontSize={fontSize}
48
+ line={lines}
49
+ />
50
+ </>
51
+ )}
52
+ <Marks paths={geometry.paths} />
53
+ <MarkerLines markers={geometry.markers} theme={theme} />
54
+ <GeometryLabels labels={geometry.labels} theme={theme} fontSize={fontSize} />
55
+ </>
56
+ );
57
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What a chart shows in place of data: the app's own content for the state,
3
+ * or a terse built-in line. Apps are expected to supply their own copy — the
4
+ * package must not impose product voice.
5
+ */
6
+ import React from 'react';
7
+ import { StyleSheet, Text, View } from 'react-native';
8
+
9
+ import type { ChartError } from '../contract/errors';
10
+ import type { ChartStatus } from '../contract/state';
11
+ import type { ChartStrings } from '../contract/strings';
12
+ import type { ChartTheme } from '../contract/theme';
13
+
14
+ function builtInStateText(
15
+ status: ChartStatus,
16
+ strings: ChartStrings,
17
+ error?: ChartError,
18
+ ): string | undefined {
19
+ switch (status) {
20
+ case 'loading':
21
+ return strings['chart.state.loading'];
22
+ case 'empty':
23
+ return strings['chart.state.empty'];
24
+ case 'error':
25
+ return error?.message ?? strings['chart.state.error'];
26
+ case 'noPermission':
27
+ return strings['chart.state.noPermission'];
28
+ default:
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ export function ChartStateOverlay({
34
+ status,
35
+ supplied,
36
+ strings,
37
+ theme,
38
+ error,
39
+ }: {
40
+ readonly status: ChartStatus;
41
+ /** The app's content for this state, when it gave any. */
42
+ readonly supplied?: React.ReactNode;
43
+ readonly strings: ChartStrings;
44
+ readonly theme: ChartTheme;
45
+ readonly error?: ChartError;
46
+ }) {
47
+ if (supplied !== undefined) {
48
+ return <View style={styles.overlay}>{supplied}</View>;
49
+ }
50
+ const fallback = builtInStateText(status, strings, error);
51
+ if (!fallback) {
52
+ return null;
53
+ }
54
+ return (
55
+ <View style={styles.overlay}>
56
+ <Text style={{ color: theme.muted, fontSize: theme.typography.valueLabel.fontSize }}>
57
+ {fallback}
58
+ </Text>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ /** The message a contained render failure shows where the plot was. */
64
+ export function ChartFailureOverlay({
65
+ supplied,
66
+ theme,
67
+ error,
68
+ }: {
69
+ readonly supplied?: React.ReactNode;
70
+ readonly theme: ChartTheme;
71
+ readonly error: ChartError;
72
+ }) {
73
+ return (
74
+ <View style={styles.overlay}>
75
+ {supplied ?? (
76
+ <Text style={{ color: theme.muted, fontSize: theme.typography.valueLabel.fontSize }}>
77
+ {error.message}
78
+ </Text>
79
+ )}
80
+ </View>
81
+ );
82
+ }
83
+
84
+ const styles = StyleSheet.create({
85
+ overlay: {
86
+ position: 'absolute',
87
+ top: 0,
88
+ left: 0,
89
+ right: 0,
90
+ bottom: 0,
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ },
94
+ });
@@ -11,6 +11,7 @@
11
11
  import React, { useCallback } from 'react';
12
12
 
13
13
  import type { ChartDatasource } from '../contract/datasource';
14
+ import type { DistributionValues } from '../contract/distribution';
14
15
  import type { BaseChartProps } from '../contract/props';
15
16
  import { buildBarGeometry, type BarGrouping } from '../geometry/bar';
16
17
  import { buildDistributionGeometry, type DistributionShape } from '../geometry/distribution';
@@ -20,15 +21,12 @@ import { buildTimeSeriesGeometry } from '../geometry/timeSeries';
20
21
  import type { GeometryInput } from '../geometry/types';
21
22
  import { ChartFrame } from './ChartFrame';
22
23
 
23
- export interface ProgressChartProps<M = unknown> extends BaseChartProps<M> {
24
- readonly orientation?: 'horizontal' | 'vertical';
25
- }
24
+ export type ProgressChartProps<M = unknown> = BaseChartProps<M>;
26
25
 
27
26
  function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
28
27
  const build = useCallback(
29
- (ds: ChartDatasource<M>, input: GeometryInput) =>
30
- buildProgressGeometry(ds, input, { orientation: props.orientation }),
31
- [props.orientation],
28
+ (ds: ChartDatasource<M>, input: GeometryInput) => buildProgressGeometry(ds, input),
29
+ [],
32
30
  );
33
31
 
34
32
  return (
@@ -36,7 +34,7 @@ function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
36
34
  {...props}
37
35
  family="progress"
38
36
  buildGeometry={build}
39
- showAxes={false}
37
+ axes="none"
40
38
  defaultLegend={false}
41
39
  />
42
40
  );
@@ -45,6 +43,13 @@ function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
45
43
  export interface DistributionChartProps<M = unknown> extends BaseChartProps<M> {
46
44
  readonly shape?: DistributionShape;
47
45
  readonly innerRadiusRatio?: number;
46
+ /**
47
+ * What the values mean. `proportions`: shares of a whole in 0..1, drawn short
48
+ * and marked incomplete when they do not reach 1. `absolute`: counts or
49
+ * amounts, normalised by their total. Inferred from the data when omitted;
50
+ * say it when small counts (1 and 1) could be read as shares.
51
+ */
52
+ readonly valuesAre?: DistributionValues;
48
53
  }
49
54
 
50
55
  function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
@@ -53,8 +58,9 @@ function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
53
58
  buildDistributionGeometry(ds, input, {
54
59
  shape: props.shape,
55
60
  innerRadiusRatio: props.innerRadiusRatio,
61
+ valuesAre: props.valuesAre,
56
62
  }),
57
- [props.shape, props.innerRadiusRatio],
63
+ [props.shape, props.innerRadiusRatio, props.valuesAre],
58
64
  );
59
65
 
60
66
  return (
@@ -62,7 +68,8 @@ function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
62
68
  {...props}
63
69
  family="distribution"
64
70
  buildGeometry={build}
65
- showAxes={false}
71
+ distributionValues={props.valuesAre}
72
+ axes="none"
66
73
  defaultLegend
67
74
  />
68
75
  );
@@ -74,10 +81,17 @@ export interface TimeSeriesChartProps<M = unknown> extends BaseChartProps<M> {
74
81
  }
75
82
 
76
83
  function TimeSeriesChartImpl<M>(props: TimeSeriesChartProps<M>) {
84
+ // By value: `range={{ from, to }}` written inline is a new object each render.
85
+ const from = props.range?.from;
86
+ const to = props.range?.to;
77
87
  const build = useCallback(
78
88
  (ds: ChartDatasource<M>, input: GeometryInput) =>
79
- buildTimeSeriesGeometry(ds, input, { range: props.range }),
80
- [props.range],
89
+ buildTimeSeriesGeometry(
90
+ ds,
91
+ input,
92
+ from === undefined || to === undefined ? {} : { range: { from, to } },
93
+ ),
94
+ [from, to],
81
95
  );
82
96
 
83
97
  return (
@@ -122,7 +136,8 @@ function HeatmapChartImpl<M>(props: HeatmapChartProps<M>) {
122
136
  {...props}
123
137
  family="heatmap"
124
138
  buildGeometry={build}
125
- showAxes={false}
139
+ // Rows and columns are named; there is no axis line or grid to draw.
140
+ axes="labels"
126
141
  defaultLegend={false}
127
142
  />
128
143
  );
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The event a selected point is reported with. One place builds it, for a tap
3
+ * and for a screen-reader step alike.
4
+ */
5
+ import type { ChartDatasource, DataPoint, Series } from '../contract/datasource';
6
+ import type { PointInteractionEvent } from '../contract/interaction';
7
+
8
+ export interface ResolvedPoint<M> {
9
+ readonly series: Series<M>;
10
+ readonly point: DataPoint<M>;
11
+ }
12
+
13
+ export function findPoint<M>(
14
+ datasource: ChartDatasource<M>,
15
+ seriesId: string,
16
+ pointId: string | undefined,
17
+ ): ResolvedPoint<M> | undefined {
18
+ const series = datasource.series.find(s => s.id === seriesId);
19
+ const point = series?.points.find(p => p.id === pointId);
20
+ return series && point ? { series, point } : undefined;
21
+ }
22
+
23
+ export function pointEventFor<M>(
24
+ datasource: ChartDatasource<M>,
25
+ seriesId: string,
26
+ pointId: string,
27
+ ): PointInteractionEvent<M> | undefined {
28
+ const found = findPoint(datasource, seriesId, pointId);
29
+ if (!found) {
30
+ return undefined;
31
+ }
32
+ return {
33
+ type: 'pointSelect',
34
+ seriesId: found.series.id,
35
+ pointId: found.point.id,
36
+ value: found.point.value,
37
+ position: found.point.position,
38
+ metadata: found.point.metadata,
39
+ };
40
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a screen reader gets: the composed summary, the announcement for the
3
+ * selected point, and increment/decrement to walk the points (FR-052, FR-067,
4
+ * FR-072).
5
+ */
6
+ import { useMemo } from 'react';
7
+
8
+ import { buildChartSummary, buildPointAnnouncement } from '../a11y/summary';
9
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
10
+ import type { ChartAccessibility } from '../contract/props';
11
+ import type { ChartStatus } from '../contract/state';
12
+ import type { ChartStrings } from '../contract/strings';
13
+ import type { HitRegion } from '../geometry/hitTest';
14
+ import { findPoint, pointEventFor } from './pointEvent';
15
+ import type { ResolvedFormatters } from './useChartFormatters';
16
+ import type { ChartSelectionApi } from './useChartSelection';
17
+
18
+ export interface UseChartA11yArgs<M> {
19
+ readonly datasource: ChartDatasource<M>;
20
+ readonly family: ChartFamily;
21
+ readonly status: ChartStatus;
22
+ readonly title?: string;
23
+ readonly accessibility?: ChartAccessibility<M>;
24
+ readonly interactive: boolean;
25
+ readonly incomplete: boolean;
26
+ readonly hitRegions: readonly HitRegion[];
27
+ readonly selection: ChartSelectionApi<M>;
28
+ readonly formatters: ResolvedFormatters;
29
+ readonly strings: ChartStrings;
30
+ }
31
+
32
+ export interface ChartA11y {
33
+ readonly summary: string;
34
+ readonly value: { readonly text: string } | undefined;
35
+ readonly actions: readonly { readonly name: string; readonly label: string }[] | undefined;
36
+ readonly onAction: ((event: { nativeEvent: { actionName: string } }) => void) | undefined;
37
+ }
38
+
39
+ export function useChartA11y<M>(args: UseChartA11yArgs<M>): ChartA11y {
40
+ const { datasource, family, status, title, incomplete, formatters, strings, selection } = args;
41
+ const label = args.accessibility?.label;
42
+ const summaryFormatter = args.accessibility?.summaryFormatter;
43
+
44
+ const summary = useMemo(() => {
45
+ if (label) {
46
+ return label;
47
+ }
48
+ // The app's own composer, when given.
49
+ if (summaryFormatter) {
50
+ return summaryFormatter(datasource, { slot: 'summary', hint: datasource.format });
51
+ }
52
+ return buildChartSummary({
53
+ datasource,
54
+ family,
55
+ status,
56
+ title,
57
+ formatValue: formatters.formatSummaryValue,
58
+ formatPosition: formatters.formatPosition,
59
+ incomplete,
60
+ strings,
61
+ });
62
+ }, [label, summaryFormatter, datasource, family, status, title, formatters, incomplete, strings]);
63
+
64
+ if (!args.interactive) {
65
+ return { summary, value: undefined, actions: undefined, onAction: undefined };
66
+ }
67
+
68
+ const selected = selection.resolved
69
+ ? findPoint(datasource, selection.resolved.seriesId, selection.resolved.pointId)
70
+ : undefined;
71
+
72
+ return {
73
+ summary,
74
+ value: selected
75
+ ? {
76
+ text: buildPointAnnouncement(
77
+ selected.series.label,
78
+ // The app's own wording for a position: a raw epoch number is
79
+ // not something to read aloud (CHT-07).
80
+ formatters.formatPosition(selected.point.position),
81
+ selected.point.value,
82
+ formatters.formatSummaryValue,
83
+ strings,
84
+ ),
85
+ }
86
+ : undefined,
87
+ actions: [
88
+ { name: 'increment', label: strings['chart.action.next'] },
89
+ { name: 'decrement', label: strings['chart.action.previous'] },
90
+ ],
91
+ onAction: event => {
92
+ const regions = args.hitRegions.filter(r => r.selectable);
93
+ if (regions.length === 0) {
94
+ return;
95
+ }
96
+ const step = event.nativeEvent.actionName === 'increment' ? 1 : -1;
97
+ const current = regions.findIndex(
98
+ r =>
99
+ r.seriesId === selection.selection?.seriesId &&
100
+ r.pointId === selection.selection?.pointId,
101
+ );
102
+ const next = regions[current < 0 ? 0 : (current + step + regions.length) % regions.length];
103
+ const pointEvent = pointEventFor(datasource, next.seriesId, next.pointId);
104
+ if (!pointEvent) {
105
+ return;
106
+ }
107
+ // Stepping moves the selection; it never clears it.
108
+ selection.select({ seriesId: next.seriesId, pointId: next.pointId }, pointEvent, {
109
+ toggle: false,
110
+ });
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The app's formatters, resolved once into the functions the chart calls.
3
+ *
4
+ * The memo depends on each formatter **function**, not on the `formatters`
5
+ * object that carries them. `formatters={{ value: formatMoney }}` written
6
+ * inline is a new object every render; depending on it rebuilt every scale and
7
+ * path each time the parent rendered (CHT-08, PERF-11). A formatter function
8
+ * that is itself created inline still counts as a change — it may format
9
+ * differently — so keep those stable (module scope, or `useCallback`).
10
+ */
11
+ import { useMemo } from 'react';
12
+
13
+ import type { Position } from '../contract/datasource';
14
+ import {
15
+ formatPositionDefault,
16
+ formatValueDefault,
17
+ safeFormat,
18
+ type ChartFormatters,
19
+ type FormatHint,
20
+ type SeriesRef,
21
+ } from '../contract/format';
22
+ import type { ValueState } from '../contract/value';
23
+
24
+ export interface GeometryFormatters {
25
+ readonly formatValue: (value: number) => string;
26
+ readonly formatCategory: (raw: string) => string;
27
+ readonly formatInstant: (at: number) => string;
28
+ }
29
+
30
+ export interface ResolvedFormatters {
31
+ /** A value as the chart draws it. */
32
+ readonly formatValue: (value: ValueState) => string;
33
+ /** A value as the chart speaks it; the app's `summary` formatter when given. */
34
+ readonly formatSummaryValue: (value: ValueState) => string;
35
+ /** A position as the chart speaks it; the app's `axisLabel` formatter when given. */
36
+ readonly formatPosition: (position: Position) => string;
37
+ /** Present only when the app supplies `legendLabel`. */
38
+ readonly legendLabel?: (series: SeriesRef) => string;
39
+ readonly geometry: GeometryFormatters;
40
+ }
41
+
42
+ export function useChartFormatters(
43
+ formatters: ChartFormatters | undefined,
44
+ hint: FormatHint | undefined,
45
+ instanceKey: string,
46
+ ): ResolvedFormatters {
47
+ const value = formatters?.value;
48
+ const axisLabel = formatters?.axisLabel;
49
+ const legend = formatters?.legendLabel;
50
+ const summary = formatters?.summary;
51
+
52
+ return useMemo(() => {
53
+ const formatValue = (v: ValueState): string =>
54
+ value
55
+ ? safeFormat(
56
+ () => value(v, { slot: 'value', hint }),
57
+ () => formatValueDefault(v, { slot: 'value', hint }),
58
+ 'value',
59
+ instanceKey,
60
+ )
61
+ : formatValueDefault(v, { slot: 'value', hint });
62
+
63
+ const formatSummaryValue = (v: ValueState): string =>
64
+ summary
65
+ ? safeFormat(
66
+ () => summary(v, { slot: 'summary', hint }),
67
+ () => formatValue(v),
68
+ 'summary',
69
+ instanceKey,
70
+ )
71
+ : formatValue(v);
72
+
73
+ const formatPosition = (position: Position): string =>
74
+ axisLabel
75
+ ? safeFormat(
76
+ () => axisLabel(position, { slot: 'axis' }),
77
+ () => formatPositionDefault(position),
78
+ 'axis',
79
+ instanceKey,
80
+ )
81
+ : formatPositionDefault(position);
82
+
83
+ return {
84
+ formatValue,
85
+ formatSummaryValue,
86
+ formatPosition,
87
+ ...(legend
88
+ ? {
89
+ legendLabel: (series: SeriesRef) =>
90
+ safeFormat(
91
+ () => legend(series, { slot: 'legend' }),
92
+ () => series.label,
93
+ 'legend',
94
+ instanceKey,
95
+ ),
96
+ }
97
+ : {}),
98
+ geometry: {
99
+ formatValue: (n: number) => formatValue({ kind: 'known', value: n }),
100
+ formatCategory: (raw: string) => raw,
101
+ formatInstant: (at: number) => formatPosition({ kind: 'instant', at }),
102
+ },
103
+ };
104
+ }, [value, axisLabel, legend, summary, hint, instanceKey]);
105
+ }
@@ -11,7 +11,7 @@
11
11
  * app models the correct pattern.
12
12
  */
13
13
  import { useCallback, useMemo, useState } from 'react';
14
- import { PixelRatio, type LayoutChangeEvent } from 'react-native';
14
+ import { useWindowDimensions, type LayoutChangeEvent } from 'react-native';
15
15
 
16
16
  import type { ChartDatasource, ChartFamily } from '../contract/datasource';
17
17
  import { resolveSegmentColors, resolveSeriesColor, type ChartTheme } from '../contract/theme';
@@ -53,7 +53,9 @@ export function useChartLayout(
53
53
  );
54
54
  }, []);
55
55
 
56
- const fontScale = PixelRatio.getFontScale();
56
+ // Reactive: a change of the system text size re-renders the chart. A value
57
+ // read once from PixelRatio kept the old size until something else rendered.
58
+ const { fontScale } = useWindowDimensions();
57
59
  const fontSize = effectiveFontSize(theme.typography.axisLabel.fontSize, fontScale);
58
60
 
59
61
  const measured = isMeasurable(size);
@@ -116,6 +118,7 @@ export function useGeometryInput<M>(
116
118
  family === 'distribution' ? resolveSegmentColors(datasource, theme) : undefined,
117
119
  nonValueTextures: theme.nonValue,
118
120
  heatmapRamp: theme.heatmapRamp,
121
+ radius: theme.radius,
119
122
  formatValue: formatters.formatValue,
120
123
  formatCategory: formatters.formatCategory,
121
124
  formatInstant: formatters.formatInstant,