@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
@@ -1,215 +1,216 @@
1
- /**
2
- * Legend (FR-066).
3
- *
4
- * On by default for multi-series and multi-segment charts, disableable, and its
5
- * model is exported so an app can render its own layout instead. Every entry
6
- * carries a dash pattern as well as a colour, so series stay distinguishable
7
- * without colour perception.
8
- */
9
- import React from 'react';
10
- import { Pressable, StyleSheet, Text, View } from 'react-native';
11
-
12
- import type { ChartDatasource, ChartFamily } from '../contract/datasource';
13
- import {
14
- resolveSegmentColors,
15
- resolveSeriesColor,
16
- segmentKey,
17
- type ChartTheme,
18
- type LegendEntry,
19
- } from '../contract/theme';
20
-
21
- /** Minimum tap target, per platform accessibility guidance (UX-003). */
22
- const MIN_TAP_TARGET = 44;
23
-
24
- /**
25
- * Legend model. A distribution chart gets one entry per segment, in the colour
26
- * its arc is drawn in; it used to get one per series, so a one-series donut of
27
- * four segments showed a single entry (CHT-02).
28
- */
29
- export function buildLegendEntries<M>(
30
- datasource: ChartDatasource<M>,
31
- theme: ChartTheme,
32
- hidden: ReadonlySet<string>,
33
- toggleable: boolean,
34
- family?: ChartFamily,
35
- ): LegendEntry[] {
36
- if (family === 'distribution') {
37
- const colors = resolveSegmentColors(datasource, theme);
38
- return datasource.series.flatMap(series =>
39
- series.points.map(point => {
40
- const single = series.points.length === 1;
41
- let label = series.label;
42
- if (!single) {
43
- label = point.position.kind === 'category' ? point.position.category : point.id;
44
- }
45
- return {
46
- seriesId: series.id,
47
- pointId: point.id,
48
- label,
49
- color: colors[segmentKey(series.id, point.id)],
50
- pattern: 'solid' as const,
51
- visible: !hidden.has(series.id),
52
- // Visibility is per series, so only a one-segment series toggles.
53
- toggleable: toggleable && single,
54
- };
55
- }),
56
- );
57
- }
58
- return datasource.series.map((series, index) => ({
59
- seriesId: series.id,
60
- label: series.label,
61
- color: resolveSeriesColor(theme, index, series.color),
62
- pattern: series.pattern ?? 'solid',
63
- visible: !hidden.has(series.id),
64
- toggleable,
65
- }));
66
- }
67
-
68
- function keyOf(entry: LegendEntry): string {
69
- return entry.pointId === undefined ? entry.seriesId : segmentKey(entry.seriesId, entry.pointId);
70
- }
71
-
72
- function segmentCountFor(pattern: LegendEntry['pattern']): number {
73
- switch (pattern) {
74
- case 'dotted':
75
- return 4;
76
- case 'dashed':
77
- case 'dashDot':
78
- return 2;
79
- default:
80
- return 1;
81
- }
82
- }
83
-
84
- function PatternSwatch({
85
- entry,
86
- theme,
87
- }: {
88
- readonly entry: LegendEntry;
89
- readonly theme: ChartTheme;
90
- }) {
91
- // Dashes are drawn as gapped segments so the pattern reads at swatch size —
92
- // this is what gives a series an identity beyond its colour (FR-064).
93
- const segments = segmentCountFor(entry.pattern);
94
- const gap = segments > 1 ? 1 : 0;
95
-
96
- return (
97
- <View style={styles.swatchRow}>
98
- {Array.from({ length: segments }, (_, i) => (
99
- <View
100
- key={i}
101
- style={[
102
- styles.swatchSegment,
103
- entry.visible ? styles.swatchVisible : styles.swatchHidden,
104
- {
105
- width: 12 / segments - gap,
106
- marginRight: gap,
107
- backgroundColor: entry.visible ? entry.color : theme.muted,
108
- },
109
- ]}
110
- />
111
- ))}
112
- </View>
113
- );
114
- }
115
-
116
- export function ChartLegend({
117
- entries,
118
- theme,
119
- onToggle,
120
- }: {
121
- readonly entries: readonly LegendEntry[];
122
- readonly theme: ChartTheme;
123
- readonly onToggle?: (seriesId: string) => void;
124
- }) {
125
- if (entries.length === 0) {
126
- return null;
127
- }
128
-
129
- return (
130
- <View style={styles.container} accessibilityRole="list">
131
- {entries.map(entry => {
132
- const content = (
133
- <>
134
- <PatternSwatch entry={entry} theme={theme} />
135
- <Text
136
- style={[
137
- styles.label,
138
- {
139
- color: entry.visible
140
- ? theme.typography.legendLabel.color
141
- : theme.muted,
142
- fontSize: theme.typography.legendLabel.fontSize,
143
- },
144
- ]}
145
- numberOfLines={1}
146
- >
147
- {entry.label}
148
- </Text>
149
- </>
150
- );
151
-
152
- if (!entry.toggleable || !onToggle) {
153
- return (
154
- <View key={keyOf(entry)} style={styles.entry} accessibilityRole="text">
155
- {content}
156
- </View>
157
- );
158
- }
159
-
160
- return (
161
- <Pressable
162
- key={keyOf(entry)}
163
- onPress={() => onToggle(entry.seriesId)}
164
- accessibilityRole="switch"
165
- accessibilityState={{ checked: entry.visible }}
166
- accessibilityLabel={entry.label}
167
- accessibilityHint={
168
- entry.visible ? 'Hides this series' : 'Shows this series'
169
- }
170
- style={[styles.entry, styles.tappable]}
171
- >
172
- {content}
173
- </Pressable>
174
- );
175
- })}
176
- </View>
177
- );
178
- }
179
-
180
- const styles = StyleSheet.create({
181
- container: {
182
- flexDirection: 'row',
183
- flexWrap: 'wrap',
184
- alignItems: 'center',
185
- },
186
- entry: {
187
- flexDirection: 'row',
188
- alignItems: 'center',
189
- marginRight: 12,
190
- marginTop: 4,
191
- },
192
- tappable: {
193
- minHeight: MIN_TAP_TARGET,
194
- paddingVertical: 4,
195
- },
196
- swatchRow: {
197
- flexDirection: 'row',
198
- alignItems: 'center',
199
- width: 12,
200
- marginRight: 6,
201
- },
202
- swatchSegment: {
203
- height: 3,
204
- borderRadius: 2,
205
- },
206
- swatchVisible: {
207
- opacity: 1,
208
- },
209
- swatchHidden: {
210
- opacity: 0.4,
211
- },
212
- label: {
213
- flexShrink: 1,
214
- },
215
- });
1
+ /**
2
+ * Legend (FR-066).
3
+ *
4
+ * On by default for multi-series and multi-segment charts, disableable, and its
5
+ * model is exported so an app can render its own layout instead. Every entry
6
+ * carries a dash pattern as well as a colour, so series stay distinguishable
7
+ * without colour perception.
8
+ */
9
+ import { chartStrings, type ChartStrings } from '../contract/strings';
10
+ import React from 'react';
11
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
12
+
13
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
14
+ import {
15
+ resolveSegmentColors,
16
+ resolveSeriesColor,
17
+ segmentKey,
18
+ type ChartTheme,
19
+ type LegendEntry,
20
+ } from '../contract/theme';
21
+
22
+ /** Minimum tap target, per platform accessibility guidance (UX-003). */
23
+ const MIN_TAP_TARGET = 44;
24
+
25
+ /**
26
+ * Legend model. A distribution chart gets one entry per segment, in the colour
27
+ * its arc is drawn in; it used to get one per series, so a one-series donut of
28
+ * four segments showed a single entry (CHT-02).
29
+ */
30
+ export function buildLegendEntries<M>(
31
+ datasource: ChartDatasource<M>,
32
+ theme: ChartTheme,
33
+ hidden: ReadonlySet<string>,
34
+ toggleable: boolean,
35
+ family?: ChartFamily,
36
+ ): LegendEntry[] {
37
+ if (family === 'distribution') {
38
+ const colors = resolveSegmentColors(datasource, theme);
39
+ return datasource.series.flatMap(series =>
40
+ series.points.map(point => {
41
+ const single = series.points.length === 1;
42
+ let label = series.label;
43
+ if (!single) {
44
+ label = point.position.kind === 'category' ? point.position.category : point.id;
45
+ }
46
+ return {
47
+ seriesId: series.id,
48
+ pointId: point.id,
49
+ label,
50
+ color: colors[segmentKey(series.id, point.id)],
51
+ pattern: 'solid' as const,
52
+ visible: !hidden.has(series.id),
53
+ // Visibility is per series, so only a one-segment series toggles.
54
+ toggleable: toggleable && single,
55
+ };
56
+ }),
57
+ );
58
+ }
59
+ return datasource.series.map((series, index) => ({
60
+ seriesId: series.id,
61
+ label: series.label,
62
+ color: resolveSeriesColor(theme, index, series.color),
63
+ pattern: series.pattern ?? 'solid',
64
+ visible: !hidden.has(series.id),
65
+ toggleable,
66
+ }));
67
+ }
68
+
69
+ function keyOf(entry: LegendEntry): string {
70
+ return entry.pointId === undefined ? entry.seriesId : segmentKey(entry.seriesId, entry.pointId);
71
+ }
72
+
73
+ function segmentCountFor(pattern: LegendEntry['pattern']): number {
74
+ switch (pattern) {
75
+ case 'dotted':
76
+ return 4;
77
+ case 'dashed':
78
+ case 'dashDot':
79
+ return 2;
80
+ default:
81
+ return 1;
82
+ }
83
+ }
84
+
85
+ function PatternSwatch({
86
+ entry,
87
+ theme,
88
+ }: {
89
+ readonly entry: LegendEntry;
90
+ readonly theme: ChartTheme;
91
+ }) {
92
+ // Dashes are drawn as gapped segments so the pattern reads at swatch size —
93
+ // this is what gives a series an identity beyond its colour (FR-064).
94
+ const segments = segmentCountFor(entry.pattern);
95
+ const gap = segments > 1 ? 1 : 0;
96
+
97
+ return (
98
+ <View style={styles.swatchRow}>
99
+ {Array.from({ length: segments }, (_, i) => (
100
+ <View
101
+ key={i}
102
+ style={[
103
+ styles.swatchSegment,
104
+ entry.visible ? styles.swatchVisible : styles.swatchHidden,
105
+ {
106
+ width: 12 / segments - gap,
107
+ marginRight: gap,
108
+ backgroundColor: entry.visible ? entry.color : theme.muted,
109
+ },
110
+ ]}
111
+ />
112
+ ))}
113
+ </View>
114
+ );
115
+ }
116
+
117
+ export function ChartLegend({
118
+ entries,
119
+ theme,
120
+ onToggle,
121
+ strings = chartStrings.defaults,
122
+ }: {
123
+ readonly entries: readonly LegendEntry[];
124
+ readonly theme: ChartTheme;
125
+ readonly onToggle?: (seriesId: string) => void;
126
+ readonly strings?: ChartStrings;
127
+ }) {
128
+ if (entries.length === 0) {
129
+ return null;
130
+ }
131
+
132
+ return (
133
+ <View style={styles.container} accessibilityRole="list">
134
+ {entries.map(entry => {
135
+ const content = (
136
+ <>
137
+ <PatternSwatch entry={entry} theme={theme} />
138
+ <Text
139
+ style={[
140
+ styles.label,
141
+ {
142
+ color: entry.visible ? theme.typography.legendLabel.color : theme.muted,
143
+ fontSize: theme.typography.legendLabel.fontSize,
144
+ },
145
+ ]}
146
+ numberOfLines={1}
147
+ >
148
+ {entry.label}
149
+ </Text>
150
+ </>
151
+ );
152
+
153
+ if (!entry.toggleable || !onToggle) {
154
+ return (
155
+ <View key={keyOf(entry)} style={styles.entry} accessibilityRole="text">
156
+ {content}
157
+ </View>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <Pressable
163
+ key={keyOf(entry)}
164
+ onPress={() => onToggle(entry.seriesId)}
165
+ accessibilityRole="switch"
166
+ accessibilityState={{ checked: entry.visible }}
167
+ accessibilityLabel={entry.label}
168
+ accessibilityHint={
169
+ entry.visible ? strings['chart.legend.hide'] : strings['chart.legend.show']
170
+ }
171
+ style={[styles.entry, styles.tappable]}
172
+ >
173
+ {content}
174
+ </Pressable>
175
+ );
176
+ })}
177
+ </View>
178
+ );
179
+ }
180
+
181
+ const styles = StyleSheet.create({
182
+ container: {
183
+ flexDirection: 'row',
184
+ flexWrap: 'wrap',
185
+ alignItems: 'center',
186
+ },
187
+ entry: {
188
+ flexDirection: 'row',
189
+ alignItems: 'center',
190
+ marginRight: 12,
191
+ marginTop: 4,
192
+ },
193
+ tappable: {
194
+ minHeight: MIN_TAP_TARGET,
195
+ paddingVertical: 4,
196
+ },
197
+ swatchRow: {
198
+ flexDirection: 'row',
199
+ alignItems: 'center',
200
+ width: 12,
201
+ marginRight: 6,
202
+ },
203
+ swatchSegment: {
204
+ height: 3,
205
+ borderRadius: 2,
206
+ },
207
+ swatchVisible: {
208
+ opacity: 1,
209
+ },
210
+ swatchHidden: {
211
+ opacity: 0.4,
212
+ },
213
+ label: {
214
+ flexShrink: 1,
215
+ },
216
+ });
@@ -1,48 +1,44 @@
1
- /**
2
- * Theme resolution: prop -> context -> built-in default keyed on the OS scheme.
3
- *
4
- * The dependency is inverted on purpose. The package declares a `ChartTheme`
5
- * value type and any design system can satisfy it, which is what keeps
6
- * `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package
7
- * entirely (FR-065, LIB-004).
8
- */
9
- import React, { createContext, useContext, useMemo } from 'react';
10
- import { useColorScheme } from 'react-native';
11
-
12
- import { defaultChartTheme, type ChartTheme } from '../contract/theme';
13
-
14
- const ChartThemeContext = createContext<ChartTheme | undefined>(undefined);
15
-
16
- export function ChartThemeProvider({
17
- theme,
18
- children,
19
- }: {
20
- readonly theme: ChartTheme;
21
- readonly children: React.ReactNode;
22
- }) {
23
- return (
24
- <ChartThemeContext.Provider value={theme}>
25
- {children}
26
- </ChartThemeContext.Provider>
27
- );
28
- }
29
-
30
- /**
31
- * An explicit prop wins, then context, then the built-in default for the
32
- * current OS colour scheme — so a chart renders correctly with no theme
33
- * configuration at all.
34
- */
35
- export function useChartTheme(explicit?: ChartTheme): ChartTheme {
36
- const fromContext = useContext(ChartThemeContext);
37
- const scheme = useColorScheme();
38
-
39
- return useMemo(() => {
40
- if (explicit) {
41
- return explicit;
42
- }
43
- if (fromContext) {
44
- return fromContext;
45
- }
46
- return defaultChartTheme(scheme === 'dark' ? 'dark' : 'light');
47
- }, [explicit, fromContext, scheme]);
48
- }
1
+ /**
2
+ * Theme resolution: prop -> context -> built-in default keyed on the OS scheme.
3
+ *
4
+ * The dependency is inverted on purpose. The package declares a `ChartTheme`
5
+ * value type and any design system can satisfy it, which is what keeps
6
+ * `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package
7
+ * entirely (FR-065, LIB-004).
8
+ */
9
+ import React, { createContext, useContext, useMemo } from 'react';
10
+ import { useColorScheme } from 'react-native';
11
+
12
+ import { defaultChartTheme, type ChartTheme } from '../contract/theme';
13
+
14
+ const ChartThemeContext = createContext<ChartTheme | undefined>(undefined);
15
+
16
+ export function ChartThemeProvider({
17
+ theme,
18
+ children,
19
+ }: {
20
+ readonly theme: ChartTheme;
21
+ readonly children: React.ReactNode;
22
+ }) {
23
+ return <ChartThemeContext.Provider value={theme}>{children}</ChartThemeContext.Provider>;
24
+ }
25
+
26
+ /**
27
+ * An explicit prop wins, then context, then the built-in default for the
28
+ * current OS colour scheme — so a chart renders correctly with no theme
29
+ * configuration at all.
30
+ */
31
+ export function useChartTheme(explicit?: ChartTheme): ChartTheme {
32
+ const fromContext = useContext(ChartThemeContext);
33
+ const scheme = useColorScheme();
34
+
35
+ return useMemo(() => {
36
+ if (explicit) {
37
+ return explicit;
38
+ }
39
+ if (fromContext) {
40
+ return fromContext;
41
+ }
42
+ return defaultChartTheme(scheme === 'dark' ? 'dark' : 'light');
43
+ }, [explicit, fromContext, scheme]);
44
+ }