@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,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
- 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
+ }