@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,177 +1,169 @@
1
- /**
2
- * Theme contract (FR-060 to FR-065).
3
- *
4
- * A plain value type rather than a dependency. The package declares what it
5
- * needs and any design system can satisfy it — which is what keeps
6
- * `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package's
7
- * dependency list entirely. The demo app owns the mapping adapter.
8
- *
9
- * Non-colour encodings are first-class here, because meaning must never ride on
10
- * hue alone (FR-064).
11
- */
12
- import type { ValueKind } from './value';
13
-
14
- export interface TextStyleTokens {
15
- readonly fontSize: number;
16
- readonly fontWeight?: '400' | '500' | '600' | '700';
17
- readonly color: string;
18
- readonly letterSpacing?: number;
19
- }
20
-
21
- /** How the three non-value classes are drawn: texture, never a hue difference. */
22
- export type NonValueTexture = 'gap' | 'hatch' | 'lockedHatch';
23
-
24
- export interface ChartTheme {
25
- readonly colorScheme: 'light' | 'dark';
26
- /** Categorical fallback, at least 8 entries, distinguishable in greyscale. */
27
- readonly palette: readonly string[];
28
- readonly surface: string;
29
- readonly onSurface: string;
30
- readonly axis: string;
31
- readonly grid: string;
32
- readonly muted: string;
33
- readonly typography: {
34
- readonly axisLabel: TextStyleTokens;
35
- readonly legendLabel: TextStyleTokens;
36
- readonly valueLabel: TextStyleTokens;
37
- };
38
- readonly spacing: {
39
- readonly xs: number;
40
- readonly sm: number;
41
- readonly md: number;
42
- };
43
- readonly radius: {
44
- readonly bar: number;
45
- readonly cell: number;
46
- };
47
- readonly nonValue: Readonly<
48
- Record<Exclude<ValueKind, 'known'>, NonValueTexture>
49
- >;
50
- /** Ramp for heatmap intensity, low to high. */
51
- readonly heatmapRamp: readonly string[];
52
- }
53
-
54
- export interface LegendEntry {
55
- readonly seriesId: string;
56
- /** Set on distribution entries, which describe one segment rather than a series. */
57
- readonly pointId?: string;
58
- readonly label: string;
59
- readonly color: string;
60
- readonly pattern: 'solid' | 'dashed' | 'dotted' | 'dashDot';
61
- readonly visible: boolean;
62
- readonly toggleable: boolean;
63
- }
64
-
65
- /**
66
- * Eight categorical colours per scheme.
67
- *
68
- * Chosen for separation in hue *and* lightness, so they stay distinguishable
69
- * under both common colour-vision deficiencies and a greyscale review. Colour
70
- * is never the only carrier of meaning regardless — series also differ by
71
- * legend label and, for lines, by dash pattern.
72
- */
73
- const LIGHT_PALETTE: readonly string[] = [
74
- '#2563EB',
75
- '#D97706',
76
- '#059669',
77
- '#DB2777',
78
- '#7C3AED',
79
- '#0891B2',
80
- '#B45309',
81
- '#4B5563',
82
- ];
83
-
84
- const DARK_PALETTE: readonly string[] = [
85
- '#60A5FA',
86
- '#FBBF24',
87
- '#34D399',
88
- '#F472B6',
89
- '#A78BFA',
90
- '#22D3EE',
91
- '#FCD34D',
92
- '#9CA3AF',
93
- ];
94
-
95
- const NON_VALUE_TEXTURES = {
96
- unknown: 'gap',
97
- notApplicable: 'hatch',
98
- withheld: 'lockedHatch',
99
- } as const;
100
-
101
- export function defaultChartTheme(
102
- colorScheme: 'light' | 'dark' = 'light',
103
- ): ChartTheme {
104
- const dark = colorScheme === 'dark';
105
-
106
- const onSurface = dark ? '#F3F4F6' : '#111827';
107
- const muted = dark ? '#9CA3AF' : '#6B7280';
108
-
109
- return {
110
- colorScheme,
111
- palette: dark ? DARK_PALETTE : LIGHT_PALETTE,
112
- surface: dark ? '#111827' : '#FFFFFF',
113
- onSurface,
114
- axis: dark ? '#4B5563' : '#D1D5DB',
115
- grid: dark ? '#374151' : '#E5E7EB',
116
- muted,
117
- typography: {
118
- axisLabel: { fontSize: 11, fontWeight: '400', color: muted },
119
- legendLabel: { fontSize: 12, fontWeight: '500', color: onSurface },
120
- valueLabel: { fontSize: 13, fontWeight: '600', color: onSurface },
121
- },
122
- spacing: { xs: 4, sm: 8, md: 16 },
123
- radius: { bar: 3, cell: 2 },
124
- nonValue: NON_VALUE_TEXTURES,
125
- heatmapRamp: dark
126
- ? ['#1F2937', '#1E3A8A', '#1D4ED8', '#3B82F6', '#93C5FD']
127
- : ['#EFF6FF', '#BFDBFE', '#60A5FA', '#2563EB', '#1E3A8A'],
128
- };
129
- }
130
-
131
- /**
132
- * Assigns a colour to a series: explicit override wins, otherwise the palette
133
- * cycles by index so the remaining series stay distinguishable (FR-062, FR-063).
134
- */
135
- export function resolveSeriesColor(
136
- theme: ChartTheme,
137
- index: number,
138
- override?: string,
139
- ): string {
140
- return override ?? theme.palette[index % theme.palette.length];
141
- }
142
-
143
- /** Key of one distribution segment: point ids are only unique within a series. */
144
- export function segmentKey(seriesId: string, pointId: string): string {
145
- return `${seriesId}/${pointId}`;
146
- }
147
-
148
- /**
149
- * Colours for distribution segments, keyed by `segmentKey`. The single source
150
- * for both the arcs and the legend, so the two cannot disagree (CHT-02).
151
- *
152
- * Segments take palette entries in order across the whole datasource, so one
153
- * series of four points and four series of one point both give four colours.
154
- * A point's own `color` wins; a series with a single point is that segment, so
155
- * the series `color` applies to it.
156
- */
157
- export function resolveSegmentColors(
158
- datasource: {
159
- readonly series: readonly {
160
- readonly id: string;
161
- readonly color?: string;
162
- readonly points: readonly { readonly id: string; readonly color?: string }[];
163
- }[];
164
- },
165
- theme: ChartTheme,
166
- ): Record<string, string> {
167
- const colors: Record<string, string> = {};
168
- let index = 0;
169
- for (const series of datasource.series) {
170
- for (const point of series.points) {
171
- const override = point.color ?? (series.points.length === 1 ? series.color : undefined);
172
- colors[segmentKey(series.id, point.id)] = resolveSeriesColor(theme, index, override);
173
- index += 1;
174
- }
175
- }
176
- return colors;
177
- }
1
+ /**
2
+ * Theme contract (FR-060 to FR-065).
3
+ *
4
+ * A plain value type rather than a dependency. The package declares what it
5
+ * needs and any design system can satisfy it — which is what keeps
6
+ * `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package's
7
+ * dependency list entirely. The demo app owns the mapping adapter.
8
+ *
9
+ * Non-colour encodings are first-class here, because meaning must never ride on
10
+ * hue alone (FR-064).
11
+ */
12
+ import type { ValueKind } from './value';
13
+
14
+ export interface TextStyleTokens {
15
+ readonly fontSize: number;
16
+ readonly fontWeight?: '400' | '500' | '600' | '700';
17
+ readonly color: string;
18
+ readonly letterSpacing?: number;
19
+ }
20
+
21
+ /** How the three non-value classes are drawn: texture, never a hue difference. */
22
+ export type NonValueTexture = 'gap' | 'hatch' | 'lockedHatch';
23
+
24
+ export interface ChartTheme {
25
+ readonly colorScheme: 'light' | 'dark';
26
+ /** Categorical fallback, at least 8 entries, distinguishable in greyscale. */
27
+ readonly palette: readonly string[];
28
+ readonly surface: string;
29
+ readonly onSurface: string;
30
+ readonly axis: string;
31
+ readonly grid: string;
32
+ readonly muted: string;
33
+ readonly typography: {
34
+ readonly axisLabel: TextStyleTokens;
35
+ readonly legendLabel: TextStyleTokens;
36
+ readonly valueLabel: TextStyleTokens;
37
+ };
38
+ readonly spacing: {
39
+ readonly xs: number;
40
+ readonly sm: number;
41
+ readonly md: number;
42
+ };
43
+ readonly radius: {
44
+ readonly bar: number;
45
+ readonly cell: number;
46
+ };
47
+ readonly nonValue: Readonly<Record<Exclude<ValueKind, 'known'>, NonValueTexture>>;
48
+ /** Ramp for heatmap intensity, low to high. */
49
+ readonly heatmapRamp: readonly string[];
50
+ }
51
+
52
+ export interface LegendEntry {
53
+ readonly seriesId: string;
54
+ /** Set on distribution entries, which describe one segment rather than a series. */
55
+ readonly pointId?: string;
56
+ readonly label: string;
57
+ readonly color: string;
58
+ readonly pattern: 'solid' | 'dashed' | 'dotted' | 'dashDot';
59
+ readonly visible: boolean;
60
+ readonly toggleable: boolean;
61
+ }
62
+
63
+ /**
64
+ * Eight categorical colours per scheme.
65
+ *
66
+ * Chosen for separation in hue *and* lightness, so they stay distinguishable
67
+ * under both common colour-vision deficiencies and a greyscale review. Colour
68
+ * is never the only carrier of meaning regardless — series also differ by
69
+ * legend label and, for lines, by dash pattern.
70
+ */
71
+ const LIGHT_PALETTE: readonly string[] = [
72
+ '#2563EB',
73
+ '#D97706',
74
+ '#059669',
75
+ '#DB2777',
76
+ '#7C3AED',
77
+ '#0891B2',
78
+ '#B45309',
79
+ '#4B5563',
80
+ ];
81
+
82
+ const DARK_PALETTE: readonly string[] = [
83
+ '#60A5FA',
84
+ '#FBBF24',
85
+ '#34D399',
86
+ '#F472B6',
87
+ '#A78BFA',
88
+ '#22D3EE',
89
+ '#FCD34D',
90
+ '#9CA3AF',
91
+ ];
92
+
93
+ const NON_VALUE_TEXTURES = {
94
+ unknown: 'gap',
95
+ notApplicable: 'hatch',
96
+ withheld: 'lockedHatch',
97
+ } as const;
98
+
99
+ export function defaultChartTheme(colorScheme: 'light' | 'dark' = 'light'): ChartTheme {
100
+ const dark = colorScheme === 'dark';
101
+
102
+ const onSurface = dark ? '#F3F4F6' : '#111827';
103
+ const muted = dark ? '#9CA3AF' : '#6B7280';
104
+
105
+ return {
106
+ colorScheme,
107
+ palette: dark ? DARK_PALETTE : LIGHT_PALETTE,
108
+ surface: dark ? '#111827' : '#FFFFFF',
109
+ onSurface,
110
+ axis: dark ? '#4B5563' : '#D1D5DB',
111
+ grid: dark ? '#374151' : '#E5E7EB',
112
+ muted,
113
+ typography: {
114
+ axisLabel: { fontSize: 11, fontWeight: '400', color: muted },
115
+ legendLabel: { fontSize: 12, fontWeight: '500', color: onSurface },
116
+ valueLabel: { fontSize: 13, fontWeight: '600', color: onSurface },
117
+ },
118
+ spacing: { xs: 4, sm: 8, md: 16 },
119
+ radius: { bar: 3, cell: 2 },
120
+ nonValue: NON_VALUE_TEXTURES,
121
+ heatmapRamp: dark
122
+ ? ['#1F2937', '#1E3A8A', '#1D4ED8', '#3B82F6', '#93C5FD']
123
+ : ['#EFF6FF', '#BFDBFE', '#60A5FA', '#2563EB', '#1E3A8A'],
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Assigns a colour to a series: explicit override wins, otherwise the palette
129
+ * cycles by index so the remaining series stay distinguishable (FR-062, FR-063).
130
+ */
131
+ export function resolveSeriesColor(theme: ChartTheme, index: number, override?: string): string {
132
+ return override ?? theme.palette[index % theme.palette.length];
133
+ }
134
+
135
+ /** Key of one distribution segment: point ids are only unique within a series. */
136
+ export function segmentKey(seriesId: string, pointId: string): string {
137
+ return `${seriesId}/${pointId}`;
138
+ }
139
+
140
+ /**
141
+ * Colours for distribution segments, keyed by `segmentKey`. The single source
142
+ * for both the arcs and the legend, so the two cannot disagree (CHT-02).
143
+ *
144
+ * Segments take palette entries in order across the whole datasource, so one
145
+ * series of four points and four series of one point both give four colours.
146
+ * A point's own `color` wins; a series with a single point is that segment, so
147
+ * the series `color` applies to it.
148
+ */
149
+ export function resolveSegmentColors(
150
+ datasource: {
151
+ readonly series: readonly {
152
+ readonly id: string;
153
+ readonly color?: string;
154
+ readonly points: readonly { readonly id: string; readonly color?: string }[];
155
+ }[];
156
+ },
157
+ theme: ChartTheme,
158
+ ): Record<string, string> {
159
+ const colors: Record<string, string> = {};
160
+ let index = 0;
161
+ for (const series of datasource.series) {
162
+ for (const point of series.points) {
163
+ const override = point.color ?? (series.points.length === 1 ? series.color : undefined);
164
+ colors[segmentKey(series.id, point.id)] = resolveSeriesColor(theme, index, override);
165
+ index += 1;
166
+ }
167
+ }
168
+ return colors;
169
+ }
@@ -1,125 +1,121 @@
1
- /**
2
- * Value semantics for @chipmobilesdk/rn-chart.
3
- *
4
- * The foundation of the whole contract. Four value classes, carried as a
5
- * discriminated union with **no numeric field on the three non-value cases**.
6
- *
7
- * That shape is deliberate: with a nullable `number`, `value ?? 0` would
8
- * compile, and turning a gap into a zero is the single most damaging thing a
9
- * charting library can do to someone's data. Here there is simply no number to
10
- * read unless the value is known.
11
- *
12
- * See specs/007-rn-chart-package/data-model.md §1 (FR-011, FR-012).
13
- */
14
-
15
- /** A real observation, including a genuine `0`. */
16
- export interface KnownValue {
17
- readonly kind: 'known';
18
- readonly value: number;
19
- }
20
-
21
- /** We do not know this value. Never rendered as zero, never interpolated. */
22
- export interface UnknownValue {
23
- readonly kind: 'unknown';
24
- }
25
-
26
- /** This value does not apply here. Excluded from totals and proportions. */
27
- export interface NotApplicableValue {
28
- readonly kind: 'notApplicable';
29
- }
30
-
31
- /** Hidden from this viewer: permission, or not enough data to disclose. */
32
- export interface WithheldValue {
33
- readonly kind: 'withheld';
34
- }
35
-
36
- export type ValueState =
37
- | KnownValue
38
- | UnknownValue
39
- | NotApplicableValue
40
- | WithheldValue;
41
-
42
- export type ValueKind = ValueState['kind'];
43
-
44
- const UNKNOWN: UnknownValue = { kind: 'unknown' };
45
- const NOT_APPLICABLE: NotApplicableValue = { kind: 'notApplicable' };
46
- const WITHHELD: WithheldValue = { kind: 'withheld' };
47
-
48
- export function known(value: number): KnownValue {
49
- return { kind: 'known', value };
50
- }
51
-
52
- /**
53
- * Note: shadows the `unknown` *type* keyword in value position only. TypeScript
54
- * keeps type and value namespaces separate, so `x: unknown` still means the
55
- * type even in modules that import this helper.
56
- */
57
- export function unknown(): UnknownValue {
58
- return UNKNOWN;
59
- }
60
-
61
- export function notApplicable(): NotApplicableValue {
62
- return NOT_APPLICABLE;
63
- }
64
-
65
- export function withheld(): WithheldValue {
66
- return WITHHELD;
67
- }
68
-
69
- export function isKnown(v: ValueState): v is KnownValue {
70
- return v.kind === 'known';
71
- }
72
-
73
- /**
74
- * True when the value must never reach a rendering, a label, a summary, or an
75
- * interaction callback (FR-033, FR-053). Withheld values carry no number at
76
- * all, so this is about intent rather than data hiding.
77
- */
78
- export function isWithheld(v: ValueState): v is WithheldValue {
79
- return v.kind === 'withheld';
80
- }
81
-
82
- /**
83
- * True when the point takes part in totals, proportions, and axis domains.
84
- * `notApplicable` and `withheld` are excluded; excluding either makes a total
85
- * incomplete (FR-013).
86
- */
87
- export function participatesInTotals(v: ValueState): boolean {
88
- return v.kind === 'known';
89
- }
90
-
91
- export interface TotalResult {
92
- readonly total: number;
93
- /** True when any point was excluded, so the total is not the whole picture. */
94
- readonly incomplete: boolean;
95
- readonly countedPoints: number;
96
- readonly excludedPoints: number;
97
- }
98
-
99
- /**
100
- * Sums known values and reports whether anything was left out (FR-013).
101
- *
102
- * `unknown` also excludes and marks the total incomplete: we cannot claim a sum
103
- * is complete when part of the data is missing.
104
- */
105
- export function sumKnown(values: readonly ValueState[]): TotalResult {
106
- let total = 0;
107
- let counted = 0;
108
- let excluded = 0;
109
-
110
- for (const v of values) {
111
- if (v.kind === 'known') {
112
- total += v.value;
113
- counted += 1;
114
- } else {
115
- excluded += 1;
116
- }
117
- }
118
-
119
- return {
120
- total,
121
- incomplete: excluded > 0,
122
- countedPoints: counted,
123
- excludedPoints: excluded,
124
- };
125
- }
1
+ /**
2
+ * Value semantics for @chipmobilesdk/rn-chart.
3
+ *
4
+ * The foundation of the whole contract. Four value classes, carried as a
5
+ * discriminated union with **no numeric field on the three non-value cases**.
6
+ *
7
+ * That shape is deliberate: with a nullable `number`, `value ?? 0` would
8
+ * compile, and turning a gap into a zero is the single most damaging thing a
9
+ * charting library can do to someone's data. Here there is simply no number to
10
+ * read unless the value is known.
11
+ *
12
+ * See specs/007-rn-chart-package/data-model.md §1 (FR-011, FR-012).
13
+ */
14
+
15
+ /** A real observation, including a genuine `0`. */
16
+ export interface KnownValue {
17
+ readonly kind: 'known';
18
+ readonly value: number;
19
+ }
20
+
21
+ /** We do not know this value. Never rendered as zero, never interpolated. */
22
+ export interface UnknownValue {
23
+ readonly kind: 'unknown';
24
+ }
25
+
26
+ /** This value does not apply here. Excluded from totals and proportions. */
27
+ export interface NotApplicableValue {
28
+ readonly kind: 'notApplicable';
29
+ }
30
+
31
+ /** Hidden from this viewer: permission, or not enough data to disclose. */
32
+ export interface WithheldValue {
33
+ readonly kind: 'withheld';
34
+ }
35
+
36
+ export type ValueState = KnownValue | UnknownValue | NotApplicableValue | WithheldValue;
37
+
38
+ export type ValueKind = ValueState['kind'];
39
+
40
+ const UNKNOWN: UnknownValue = { kind: 'unknown' };
41
+ const NOT_APPLICABLE: NotApplicableValue = { kind: 'notApplicable' };
42
+ const WITHHELD: WithheldValue = { kind: 'withheld' };
43
+
44
+ export function known(value: number): KnownValue {
45
+ return { kind: 'known', value };
46
+ }
47
+
48
+ /**
49
+ * Note: shadows the `unknown` *type* keyword in value position only. TypeScript
50
+ * keeps type and value namespaces separate, so `x: unknown` still means the
51
+ * type even in modules that import this helper.
52
+ */
53
+ export function unknown(): UnknownValue {
54
+ return UNKNOWN;
55
+ }
56
+
57
+ export function notApplicable(): NotApplicableValue {
58
+ return NOT_APPLICABLE;
59
+ }
60
+
61
+ export function withheld(): WithheldValue {
62
+ return WITHHELD;
63
+ }
64
+
65
+ export function isKnown(v: ValueState): v is KnownValue {
66
+ return v.kind === 'known';
67
+ }
68
+
69
+ /**
70
+ * True when the value must never reach a rendering, a label, a summary, or an
71
+ * interaction callback (FR-033, FR-053). Withheld values carry no number at
72
+ * all, so this is about intent rather than data hiding.
73
+ */
74
+ export function isWithheld(v: ValueState): v is WithheldValue {
75
+ return v.kind === 'withheld';
76
+ }
77
+
78
+ /**
79
+ * True when the point takes part in totals, proportions, and axis domains.
80
+ * `notApplicable` and `withheld` are excluded; excluding either makes a total
81
+ * incomplete (FR-013).
82
+ */
83
+ export function participatesInTotals(v: ValueState): boolean {
84
+ return v.kind === 'known';
85
+ }
86
+
87
+ export interface TotalResult {
88
+ readonly total: number;
89
+ /** True when any point was excluded, so the total is not the whole picture. */
90
+ readonly incomplete: boolean;
91
+ readonly countedPoints: number;
92
+ readonly excludedPoints: number;
93
+ }
94
+
95
+ /**
96
+ * Sums known values and reports whether anything was left out (FR-013).
97
+ *
98
+ * `unknown` also excludes and marks the total incomplete: we cannot claim a sum
99
+ * is complete when part of the data is missing.
100
+ */
101
+ export function sumKnown(values: readonly ValueState[]): TotalResult {
102
+ let total = 0;
103
+ let counted = 0;
104
+ let excluded = 0;
105
+
106
+ for (const v of values) {
107
+ if (v.kind === 'known') {
108
+ total += v.value;
109
+ counted += 1;
110
+ } else {
111
+ excluded += 1;
112
+ }
113
+ }
114
+
115
+ return {
116
+ total,
117
+ incomplete: excluded > 0,
118
+ countedPoints: counted,
119
+ excludedPoints: excluded,
120
+ };
121
+ }