@chipmobilesdk/rn-chart 0.1.1 → 0.2.1

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 (38) hide show
  1. package/CHANGELOG.md +126 -74
  2. package/README.md +337 -314
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +1 -2
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -134
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +162 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +57 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/theme.ts +169 -139
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -256
  16. package/src/geometry/distribution.ts +197 -200
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -121
  20. package/src/geometry/markers.ts +55 -0
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -204
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -158
  26. package/src/geometry/types.ts +94 -88
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -620
  30. package/src/react/ChartLegend.tsx +211 -177
  31. package/src/react/ChartThemeProvider.tsx +44 -48
  32. package/src/react/charts.tsx +154 -161
  33. package/src/react/useChartLayout.ts +124 -124
  34. package/src/react/useChartSelection.ts +152 -170
  35. package/src/render/Axis.tsx +120 -122
  36. package/src/render/Marks.tsx +173 -205
  37. package/src/validation/budgets.ts +103 -110
  38. package/src/validation/validateDatasource.ts +174 -174
@@ -1,177 +1,211 @@
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 } from '../contract/datasource';
13
- import { resolveSeriesColor, type ChartTheme, type LegendEntry } from '../contract/theme';
14
-
15
- /** Minimum tap target, per platform accessibility guidance (UX-003). */
16
- const MIN_TAP_TARGET = 44;
17
-
18
- export function buildLegendEntries<M>(
19
- datasource: ChartDatasource<M>,
20
- theme: ChartTheme,
21
- hidden: ReadonlySet<string>,
22
- toggleable: boolean,
23
- ): LegendEntry[] {
24
- return datasource.series.map((series, index) => ({
25
- seriesId: series.id,
26
- label: series.label,
27
- color: resolveSeriesColor(theme, index, series.color),
28
- pattern: series.pattern ?? 'solid',
29
- visible: !hidden.has(series.id),
30
- toggleable,
31
- }));
32
- }
33
-
34
- function segmentCountFor(pattern: LegendEntry['pattern']): number {
35
- switch (pattern) {
36
- case 'dotted':
37
- return 4;
38
- case 'dashed':
39
- case 'dashDot':
40
- return 2;
41
- default:
42
- return 1;
43
- }
44
- }
45
-
46
- function PatternSwatch({
47
- entry,
48
- theme,
49
- }: {
50
- readonly entry: LegendEntry;
51
- readonly theme: ChartTheme;
52
- }) {
53
- // Dashes are drawn as gapped segments so the pattern reads at swatch size —
54
- // this is what gives a series an identity beyond its colour (FR-064).
55
- const segments = segmentCountFor(entry.pattern);
56
- const gap = segments > 1 ? 1 : 0;
57
-
58
- return (
59
- <View style={styles.swatchRow}>
60
- {Array.from({ length: segments }, (_, i) => (
61
- <View
62
- key={i}
63
- style={[
64
- styles.swatchSegment,
65
- entry.visible ? styles.swatchVisible : styles.swatchHidden,
66
- {
67
- width: 12 / segments - gap,
68
- marginRight: gap,
69
- backgroundColor: entry.visible ? entry.color : theme.muted,
70
- },
71
- ]}
72
- />
73
- ))}
74
- </View>
75
- );
76
- }
77
-
78
- export function ChartLegend({
79
- entries,
80
- theme,
81
- onToggle,
82
- }: {
83
- readonly entries: readonly LegendEntry[];
84
- readonly theme: ChartTheme;
85
- readonly onToggle?: (seriesId: string) => void;
86
- }) {
87
- if (entries.length === 0) {
88
- return null;
89
- }
90
-
91
- return (
92
- <View style={styles.container} accessibilityRole="list">
93
- {entries.map(entry => {
94
- const content = (
95
- <>
96
- <PatternSwatch entry={entry} theme={theme} />
97
- <Text
98
- style={[
99
- styles.label,
100
- {
101
- color: entry.visible
102
- ? theme.typography.legendLabel.color
103
- : theme.muted,
104
- fontSize: theme.typography.legendLabel.fontSize,
105
- },
106
- ]}
107
- numberOfLines={1}
108
- >
109
- {entry.label}
110
- </Text>
111
- </>
112
- );
113
-
114
- if (!entry.toggleable || !onToggle) {
115
- return (
116
- <View key={entry.seriesId} style={styles.entry} accessibilityRole="text">
117
- {content}
118
- </View>
119
- );
120
- }
121
-
122
- return (
123
- <Pressable
124
- key={entry.seriesId}
125
- onPress={() => onToggle(entry.seriesId)}
126
- accessibilityRole="switch"
127
- accessibilityState={{ checked: entry.visible }}
128
- accessibilityLabel={entry.label}
129
- accessibilityHint={
130
- entry.visible ? 'Hides this series' : 'Shows this series'
131
- }
132
- style={[styles.entry, styles.tappable]}
133
- >
134
- {content}
135
- </Pressable>
136
- );
137
- })}
138
- </View>
139
- );
140
- }
141
-
142
- const styles = StyleSheet.create({
143
- container: {
144
- flexDirection: 'row',
145
- flexWrap: 'wrap',
146
- alignItems: 'center',
147
- },
148
- entry: {
149
- flexDirection: 'row',
150
- alignItems: 'center',
151
- marginRight: 12,
152
- marginTop: 4,
153
- },
154
- tappable: {
155
- minHeight: MIN_TAP_TARGET,
156
- paddingVertical: 4,
157
- },
158
- swatchRow: {
159
- flexDirection: 'row',
160
- alignItems: 'center',
161
- width: 12,
162
- marginRight: 6,
163
- },
164
- swatchSegment: {
165
- height: 3,
166
- borderRadius: 2,
167
- },
168
- swatchVisible: {
169
- opacity: 1,
170
- },
171
- swatchHidden: {
172
- opacity: 0.4,
173
- },
174
- label: {
175
- flexShrink: 1,
176
- },
177
- });
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 ? theme.typography.legendLabel.color : theme.muted,
140
+ fontSize: theme.typography.legendLabel.fontSize,
141
+ },
142
+ ]}
143
+ numberOfLines={1}
144
+ >
145
+ {entry.label}
146
+ </Text>
147
+ </>
148
+ );
149
+
150
+ if (!entry.toggleable || !onToggle) {
151
+ return (
152
+ <View key={keyOf(entry)} style={styles.entry} accessibilityRole="text">
153
+ {content}
154
+ </View>
155
+ );
156
+ }
157
+
158
+ return (
159
+ <Pressable
160
+ key={keyOf(entry)}
161
+ onPress={() => onToggle(entry.seriesId)}
162
+ accessibilityRole="switch"
163
+ accessibilityState={{ checked: entry.visible }}
164
+ accessibilityLabel={entry.label}
165
+ accessibilityHint={entry.visible ? 'Hides this series' : 'Shows this series'}
166
+ style={[styles.entry, styles.tappable]}
167
+ >
168
+ {content}
169
+ </Pressable>
170
+ );
171
+ })}
172
+ </View>
173
+ );
174
+ }
175
+
176
+ const styles = StyleSheet.create({
177
+ container: {
178
+ flexDirection: 'row',
179
+ flexWrap: 'wrap',
180
+ alignItems: 'center',
181
+ },
182
+ entry: {
183
+ flexDirection: 'row',
184
+ alignItems: 'center',
185
+ marginRight: 12,
186
+ marginTop: 4,
187
+ },
188
+ tappable: {
189
+ minHeight: MIN_TAP_TARGET,
190
+ paddingVertical: 4,
191
+ },
192
+ swatchRow: {
193
+ flexDirection: 'row',
194
+ alignItems: 'center',
195
+ width: 12,
196
+ marginRight: 6,
197
+ },
198
+ swatchSegment: {
199
+ height: 3,
200
+ borderRadius: 2,
201
+ },
202
+ swatchVisible: {
203
+ opacity: 1,
204
+ },
205
+ swatchHidden: {
206
+ opacity: 0.4,
207
+ },
208
+ label: {
209
+ flexShrink: 1,
210
+ },
211
+ });
@@ -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
+ }