@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,146 +1,140 @@
1
- /**
2
- * Scales: domain values to pixel positions.
3
- *
4
- * PURE. No React, no SVG — enforced by the ESLint override in `.eslintrc.js`
5
- * and by geometryPurity.test.ts.
6
- */
7
-
8
- export interface Range {
9
- readonly min: number;
10
- readonly max: number;
11
- }
12
-
13
- export interface LinearScale {
14
- readonly domain: Range;
15
- readonly rangePx: Range;
16
- readonly project: (value: number) => number;
17
- readonly invert: (px: number) => number;
18
- }
19
-
20
- /**
21
- * Nudges a zero-width domain outward so an all-identical dataset still gets a
22
- * usable axis. Without this the projection divides by zero and every point
23
- * lands on the same pixel — or on NaN.
24
- */
25
- export function widenDegenerateDomain(domain: Range): Range {
26
- if (domain.max > domain.min) {
27
- return domain;
28
- }
29
- const value = domain.min;
30
- // Scale the padding to the magnitude so large values do not get a
31
- // meaninglessly tiny window, and zero still gets a sensible one.
32
- const pad = value === 0 ? 1 : Math.abs(value) * 0.5;
33
- return { min: value - pad, max: value + pad };
34
- }
35
-
36
- export function linearScale(domain: Range, rangePx: Range): LinearScale {
37
- const safeDomain = widenDegenerateDomain(domain);
38
- const domainSpan = safeDomain.max - safeDomain.min;
39
- const pxSpan = rangePx.max - rangePx.min;
40
-
41
- return {
42
- domain: safeDomain,
43
- rangePx,
44
- project: (value: number) =>
45
- rangePx.min + ((value - safeDomain.min) / domainSpan) * pxSpan,
46
- invert: (px: number) =>
47
- safeDomain.min + ((px - rangePx.min) / pxSpan) * domainSpan,
48
- };
49
- }
50
-
51
- export interface BandScale {
52
- readonly categories: readonly string[];
53
- readonly bandWidth: number;
54
- readonly step: number;
55
- readonly project: (category: string) => number | undefined;
56
- readonly centerOf: (category: string) => number | undefined;
57
- readonly invert: (px: number) => string | undefined;
58
- }
59
-
60
- /**
61
- * Discrete positions for category axes.
62
- *
63
- * `padding` is the fraction of each step left as gap, so 0.2 means bars occupy
64
- * 80% of their slot.
65
- */
66
- export function bandScale(
67
- categories: readonly string[],
68
- rangePx: Range,
69
- padding = 0.2,
70
- ): BandScale {
71
- const count = Math.max(categories.length, 1);
72
- const pxSpan = rangePx.max - rangePx.min;
73
- const step = pxSpan / count;
74
- const bandWidth = step * (1 - padding);
75
- const offset = (step - bandWidth) / 2;
76
-
77
- const indexOf = new Map<string, number>();
78
- categories.forEach((c, i) => {
79
- if (!indexOf.has(c)) {
80
- indexOf.set(c, i);
81
- }
82
- });
83
-
84
- const project = (category: string): number | undefined => {
85
- const i = indexOf.get(category);
86
- return i === undefined ? undefined : rangePx.min + i * step + offset;
87
- };
88
-
89
- return {
90
- categories,
91
- bandWidth,
92
- step,
93
- project,
94
- centerOf: (category: string) => {
95
- const start = project(category);
96
- return start === undefined ? undefined : start + bandWidth / 2;
97
- },
98
- invert: (px: number) => {
99
- const i = Math.floor((px - rangePx.min) / step);
100
- return i >= 0 && i < categories.length ? categories[i] : undefined;
101
- },
102
- };
103
- }
104
-
105
- /**
106
- * Time is just a linear scale over epoch milliseconds. Kept as a named
107
- * function so call sites read clearly and so tick generation can branch on it.
108
- */
109
- export function timeScale(domain: Range, rangePx: Range): LinearScale {
110
- return linearScale(domain, rangePx);
111
- }
112
-
113
- /**
114
- * Domain from known values, padded so marks do not touch the plot edges.
115
- *
116
- * `includeZero` matters for bar charts: a bar's length is only meaningful when
117
- * measured from zero. Time-series charts leave it off so a narrow band of
118
- * values stays legible.
119
- */
120
- export function domainFrom(
121
- values: readonly number[],
122
- options: { includeZero?: boolean; padFraction?: number } = {},
123
- ): Range {
124
- if (values.length === 0) {
125
- return { min: 0, max: 1 };
126
- }
127
-
128
- let min = values[0];
129
- let max = values[0];
130
- for (const v of values) {
131
- if (v < min) {
132
- min = v;
133
- }
134
- if (v > max) {
135
- max = v;
136
- }
137
- }
138
-
139
- if (options.includeZero) {
140
- min = Math.min(min, 0);
141
- max = Math.max(max, 0);
142
- }
143
-
144
- const pad = (max - min) * (options.padFraction ?? 0);
145
- return { min: min - pad, max: max + pad };
146
- }
1
+ /**
2
+ * Scales: domain values to pixel positions.
3
+ *
4
+ * PURE. No React, no SVG — enforced by the ESLint override in `.eslintrc.js`
5
+ * and by geometryPurity.test.ts.
6
+ */
7
+
8
+ export interface Range {
9
+ readonly min: number;
10
+ readonly max: number;
11
+ }
12
+
13
+ export interface LinearScale {
14
+ readonly domain: Range;
15
+ readonly rangePx: Range;
16
+ readonly project: (value: number) => number;
17
+ readonly invert: (px: number) => number;
18
+ }
19
+
20
+ /**
21
+ * Nudges a zero-width domain outward so an all-identical dataset still gets a
22
+ * usable axis. Without this the projection divides by zero and every point
23
+ * lands on the same pixel — or on NaN.
24
+ */
25
+ export function widenDegenerateDomain(domain: Range): Range {
26
+ if (domain.max > domain.min) {
27
+ return domain;
28
+ }
29
+ const value = domain.min;
30
+ // Scale the padding to the magnitude so large values do not get a
31
+ // meaninglessly tiny window, and zero still gets a sensible one.
32
+ const pad = value === 0 ? 1 : Math.abs(value) * 0.5;
33
+ return { min: value - pad, max: value + pad };
34
+ }
35
+
36
+ export function linearScale(domain: Range, rangePx: Range): LinearScale {
37
+ const safeDomain = widenDegenerateDomain(domain);
38
+ const domainSpan = safeDomain.max - safeDomain.min;
39
+ const pxSpan = rangePx.max - rangePx.min;
40
+
41
+ return {
42
+ domain: safeDomain,
43
+ rangePx,
44
+ project: (value: number) => rangePx.min + ((value - safeDomain.min) / domainSpan) * pxSpan,
45
+ invert: (px: number) => safeDomain.min + ((px - rangePx.min) / pxSpan) * domainSpan,
46
+ };
47
+ }
48
+
49
+ export interface BandScale {
50
+ readonly categories: readonly string[];
51
+ readonly bandWidth: number;
52
+ readonly step: number;
53
+ readonly project: (category: string) => number | undefined;
54
+ readonly centerOf: (category: string) => number | undefined;
55
+ readonly invert: (px: number) => string | undefined;
56
+ }
57
+
58
+ /**
59
+ * Discrete positions for category axes.
60
+ *
61
+ * `padding` is the fraction of each step left as gap, so 0.2 means bars occupy
62
+ * 80% of their slot.
63
+ */
64
+ export function bandScale(categories: readonly string[], rangePx: Range, padding = 0.2): BandScale {
65
+ const count = Math.max(categories.length, 1);
66
+ const pxSpan = rangePx.max - rangePx.min;
67
+ const step = pxSpan / count;
68
+ const bandWidth = step * (1 - padding);
69
+ const offset = (step - bandWidth) / 2;
70
+
71
+ const indexOf = new Map<string, number>();
72
+ categories.forEach((c, i) => {
73
+ if (!indexOf.has(c)) {
74
+ indexOf.set(c, i);
75
+ }
76
+ });
77
+
78
+ const project = (category: string): number | undefined => {
79
+ const i = indexOf.get(category);
80
+ return i === undefined ? undefined : rangePx.min + i * step + offset;
81
+ };
82
+
83
+ return {
84
+ categories,
85
+ bandWidth,
86
+ step,
87
+ project,
88
+ centerOf: (category: string) => {
89
+ const start = project(category);
90
+ return start === undefined ? undefined : start + bandWidth / 2;
91
+ },
92
+ invert: (px: number) => {
93
+ const i = Math.floor((px - rangePx.min) / step);
94
+ return i >= 0 && i < categories.length ? categories[i] : undefined;
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Time is just a linear scale over epoch milliseconds. Kept as a named
101
+ * function so call sites read clearly and so tick generation can branch on it.
102
+ */
103
+ export function timeScale(domain: Range, rangePx: Range): LinearScale {
104
+ return linearScale(domain, rangePx);
105
+ }
106
+
107
+ /**
108
+ * Domain from known values, padded so marks do not touch the plot edges.
109
+ *
110
+ * `includeZero` matters for bar charts: a bar's length is only meaningful when
111
+ * measured from zero. Time-series charts leave it off so a narrow band of
112
+ * values stays legible.
113
+ */
114
+ export function domainFrom(
115
+ values: readonly number[],
116
+ options: { includeZero?: boolean; padFraction?: number } = {},
117
+ ): Range {
118
+ if (values.length === 0) {
119
+ return { min: 0, max: 1 };
120
+ }
121
+
122
+ let min = values[0];
123
+ let max = values[0];
124
+ for (const v of values) {
125
+ if (v < min) {
126
+ min = v;
127
+ }
128
+ if (v > max) {
129
+ max = v;
130
+ }
131
+ }
132
+
133
+ if (options.includeZero) {
134
+ min = Math.min(min, 0);
135
+ max = Math.max(max, 0);
136
+ }
137
+
138
+ const pad = (max - min) * (options.padFraction ?? 0);
139
+ return { min: min - pad, max: max + pad };
140
+ }
@@ -1,153 +1,145 @@
1
- /**
2
- * Tick generation and label thinning (FR-070).
3
- *
4
- * React Native offers no synchronous text measurement, and an `onLayout`
5
- * round-trip per label would cost a second layout pass — enough to breach the
6
- * first-paint budget. So we estimate width from character count and thin ticks
7
- * to fit.
8
- *
9
- * The estimate is deliberately generous: over-estimating drops one tick too
10
- * many, which is legible. Under-estimating produces overlapping text, which is
11
- * the failure FR-070 exists to prevent. See research.md R6.
12
- *
13
- * PURE. No React, no SVG.
14
- */
15
- import type { Range } from './scale';
16
-
17
- /**
18
- * Average glyph advance as a fraction of font size, for the Latin digits and
19
- * letters that dominate axis labels. Rounded up from the ~0.5-0.55 typical of
20
- * the platform sans-serif faces.
21
- */
22
- export const AVERAGE_ADVANCE_RATIO = 0.62;
23
-
24
- /** Minimum breathing room between two adjacent labels, in px. */
25
- export const LABEL_GUTTER = 8;
26
-
27
- export function estimateTextWidth(text: string, fontSize: number): number {
28
- return text.length * fontSize * AVERAGE_ADVANCE_RATIO;
29
- }
30
-
31
- export interface Tick {
32
- /** Domain value this tick represents. */
33
- readonly value: number;
34
- /** Pixel position along the axis. */
35
- readonly position: number;
36
- readonly label: string;
37
- }
38
-
39
- /**
40
- * "Nice" step sizes: 1, 2, 5 and their powers of ten. Standard axis practice —
41
- * these are the intervals people read fluently.
42
- */
43
- function niceStep(rawStep: number): number {
44
- if (rawStep <= 0 || !Number.isFinite(rawStep)) {
45
- return 1;
46
- }
47
- const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
48
- const normalized = rawStep / magnitude;
49
-
50
- if (normalized <= 1) {
51
- return magnitude;
52
- }
53
- if (normalized <= 2) {
54
- return 2 * magnitude;
55
- }
56
- if (normalized <= 5) {
57
- return 5 * magnitude;
58
- }
59
- return 10 * magnitude;
60
- }
61
-
62
- export function niceTickValues(domain: Range, targetCount: number): number[] {
63
- const span = domain.max - domain.min;
64
- if (span <= 0 || targetCount < 1) {
65
- return [domain.min];
66
- }
67
-
68
- const step = niceStep(span / targetCount);
69
- const start = Math.ceil(domain.min / step) * step;
70
-
71
- const values: number[] = [];
72
- // Guard against a pathological step producing an unbounded loop.
73
- for (let v = start, guard = 0; v <= domain.max && guard < 1000; v += step) {
74
- // Re-round to kill floating-point drift such as 0.30000000000000004.
75
- values.push(Number((Math.round(v / step) * step).toFixed(10)));
76
- guard += 1;
77
- }
78
- return values.length > 0 ? values : [domain.min];
79
- }
80
-
81
- /**
82
- * Drops ticks until no two labels can overlap.
83
- *
84
- * Thinning is preferred over rotation as the default: rotated labels are harder
85
- * to read on a phone and cost vertical space a mobile chart cannot spare.
86
- * Rotation stays available as an opt-in for dense category axes.
87
- *
88
- * The first tick is always kept, so an axis never renders empty.
89
- */
90
- export function thinTicks(
91
- ticks: readonly Tick[],
92
- fontSize: number,
93
- axisLengthPx: number,
94
- ): Tick[] {
95
- if (ticks.length <= 1) {
96
- return [...ticks];
97
- }
98
-
99
- let widest = 0;
100
- for (const t of ticks) {
101
- widest = Math.max(widest, estimateTextWidth(t.label, fontSize));
102
- }
103
-
104
- const slot = widest + LABEL_GUTTER;
105
- const maxLabels = Math.max(1, Math.floor(axisLengthPx / slot));
106
- if (ticks.length <= maxLabels) {
107
- return [...ticks];
108
- }
109
-
110
- const stride = Math.ceil(ticks.length / maxLabels);
111
- return ticks.filter((_, i) => i % stride === 0);
112
- }
113
-
114
- /**
115
- * How much space an axis must reserve for its labels, at the current system
116
- * font scale (FR-071).
117
- */
118
- export function reserveAxisSpace(
119
- labels: readonly string[],
120
- fontSize: number,
121
- orientation: 'horizontal' | 'vertical',
122
- ): number {
123
- if (labels.length === 0) {
124
- return 0;
125
- }
126
- if (orientation === 'horizontal') {
127
- // One line of text plus a gutter.
128
- return fontSize * 1.4 + LABEL_GUTTER;
129
- }
130
-
131
- let widest = 0;
132
- for (const label of labels) {
133
- widest = Math.max(widest, estimateTextWidth(label, fontSize));
134
- }
135
- return widest + LABEL_GUTTER;
136
- }
137
-
138
- /**
139
- * Shortens a label that cannot fit, signalling the truncation with an ellipsis.
140
- * Callers must expose the full value through an accessible label (FR-070).
141
- */
142
- export function truncateToWidth(
143
- text: string,
144
- fontSize: number,
145
- maxWidthPx: number,
146
- ): string {
147
- if (estimateTextWidth(text, fontSize) <= maxWidthPx) {
148
- return text;
149
- }
150
- const perChar = fontSize * AVERAGE_ADVANCE_RATIO;
151
- const fits = Math.max(1, Math.floor(maxWidthPx / perChar) - 1);
152
- return `${text.slice(0, fits)}…`;
153
- }
1
+ /**
2
+ * Tick generation and label thinning (FR-070).
3
+ *
4
+ * React Native offers no synchronous text measurement, and an `onLayout`
5
+ * round-trip per label would cost a second layout pass — enough to breach the
6
+ * first-paint budget. So we estimate width from character count and thin ticks
7
+ * to fit.
8
+ *
9
+ * The estimate is deliberately generous: over-estimating drops one tick too
10
+ * many, which is legible. Under-estimating produces overlapping text, which is
11
+ * the failure FR-070 exists to prevent. See research.md R6.
12
+ *
13
+ * PURE. No React, no SVG.
14
+ */
15
+ import type { Range } from './scale';
16
+
17
+ /**
18
+ * Average glyph advance as a fraction of font size, for the Latin digits and
19
+ * letters that dominate axis labels. Rounded up from the ~0.5-0.55 typical of
20
+ * the platform sans-serif faces.
21
+ */
22
+ export const AVERAGE_ADVANCE_RATIO = 0.62;
23
+
24
+ /** Minimum breathing room between two adjacent labels, in px. */
25
+ export const LABEL_GUTTER = 8;
26
+
27
+ export function estimateTextWidth(text: string, fontSize: number): number {
28
+ return text.length * fontSize * AVERAGE_ADVANCE_RATIO;
29
+ }
30
+
31
+ export interface Tick {
32
+ /** Domain value this tick represents. */
33
+ readonly value: number;
34
+ /** Pixel position along the axis. */
35
+ readonly position: number;
36
+ readonly label: string;
37
+ }
38
+
39
+ /**
40
+ * "Nice" step sizes: 1, 2, 5 and their powers of ten. Standard axis practice —
41
+ * these are the intervals people read fluently.
42
+ */
43
+ function niceStep(rawStep: number): number {
44
+ if (rawStep <= 0 || !Number.isFinite(rawStep)) {
45
+ return 1;
46
+ }
47
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
48
+ const normalized = rawStep / magnitude;
49
+
50
+ if (normalized <= 1) {
51
+ return magnitude;
52
+ }
53
+ if (normalized <= 2) {
54
+ return 2 * magnitude;
55
+ }
56
+ if (normalized <= 5) {
57
+ return 5 * magnitude;
58
+ }
59
+ return 10 * magnitude;
60
+ }
61
+
62
+ export function niceTickValues(domain: Range, targetCount: number): number[] {
63
+ const span = domain.max - domain.min;
64
+ if (span <= 0 || targetCount < 1) {
65
+ return [domain.min];
66
+ }
67
+
68
+ const step = niceStep(span / targetCount);
69
+ const start = Math.ceil(domain.min / step) * step;
70
+
71
+ const values: number[] = [];
72
+ // Guard against a pathological step producing an unbounded loop.
73
+ for (let v = start, guard = 0; v <= domain.max && guard < 1000; v += step) {
74
+ // Re-round to kill floating-point drift such as 0.30000000000000004.
75
+ values.push(Number((Math.round(v / step) * step).toFixed(10)));
76
+ guard += 1;
77
+ }
78
+ return values.length > 0 ? values : [domain.min];
79
+ }
80
+
81
+ /**
82
+ * Drops ticks until no two labels can overlap.
83
+ *
84
+ * Thinning is preferred over rotation as the default: rotated labels are harder
85
+ * to read on a phone and cost vertical space a mobile chart cannot spare.
86
+ * Rotation stays available as an opt-in for dense category axes.
87
+ *
88
+ * The first tick is always kept, so an axis never renders empty.
89
+ */
90
+ export function thinTicks(ticks: readonly Tick[], fontSize: number, axisLengthPx: number): Tick[] {
91
+ if (ticks.length <= 1) {
92
+ return [...ticks];
93
+ }
94
+
95
+ let widest = 0;
96
+ for (const t of ticks) {
97
+ widest = Math.max(widest, estimateTextWidth(t.label, fontSize));
98
+ }
99
+
100
+ const slot = widest + LABEL_GUTTER;
101
+ const maxLabels = Math.max(1, Math.floor(axisLengthPx / slot));
102
+ if (ticks.length <= maxLabels) {
103
+ return [...ticks];
104
+ }
105
+
106
+ const stride = Math.ceil(ticks.length / maxLabels);
107
+ return ticks.filter((_, i) => i % stride === 0);
108
+ }
109
+
110
+ /**
111
+ * How much space an axis must reserve for its labels, at the current system
112
+ * font scale (FR-071).
113
+ */
114
+ export function reserveAxisSpace(
115
+ labels: readonly string[],
116
+ fontSize: number,
117
+ orientation: 'horizontal' | 'vertical',
118
+ ): number {
119
+ if (labels.length === 0) {
120
+ return 0;
121
+ }
122
+ if (orientation === 'horizontal') {
123
+ // One line of text plus a gutter.
124
+ return fontSize * 1.4 + LABEL_GUTTER;
125
+ }
126
+
127
+ let widest = 0;
128
+ for (const label of labels) {
129
+ widest = Math.max(widest, estimateTextWidth(label, fontSize));
130
+ }
131
+ return widest + LABEL_GUTTER;
132
+ }
133
+
134
+ /**
135
+ * Shortens a label that cannot fit, signalling the truncation with an ellipsis.
136
+ * Callers must expose the full value through an accessible label (FR-070).
137
+ */
138
+ export function truncateToWidth(text: string, fontSize: number, maxWidthPx: number): string {
139
+ if (estimateTextWidth(text, fontSize) <= maxWidthPx) {
140
+ return text;
141
+ }
142
+ const perChar = fontSize * AVERAGE_ADVANCE_RATIO;
143
+ const fits = Math.max(1, Math.floor(maxWidthPx / perChar) - 1);
144
+ return `${text.slice(0, fits)}…`;
145
+ }