@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,195 +1,189 @@
1
- /**
2
- * Heatmap geometry: a matrix of cells, batched by intensity bucket.
3
- *
4
- * This is where batching pays off most. A thousand cells become roughly eight
5
- * paths — one per intensity bucket, plus one per non-value class — instead of a
6
- * thousand elements. Cell count no longer drives element count at all
7
- * (research.md R2).
8
- *
9
- * Empty, zero, and unavailable cells are distinct by construction (FR-027): a
10
- * genuine zero lands in the lowest ramp bucket, while unknown, not-applicable,
11
- * and withheld each get their own texture.
12
- *
13
- * PURE. No React, no SVG.
14
- */
15
- import type { ChartDatasource } from '../contract/datasource';
16
- import { isKnown, isWithheld } from '../contract/value';
17
- import type { HitRegion } from './hitTest';
18
- import { rectBatchPath, type RectSpec } from './path';
19
- import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
20
-
21
- export interface HeatmapOptions {
22
- /** Intensity buckets. More buckets means finer shading, more paths. */
23
- readonly buckets?: number;
24
- readonly cellGap?: number;
25
- readonly cellRadius?: number;
26
- }
27
-
28
- export function buildHeatmapGeometry<M>(
29
- datasource: ChartDatasource<M>,
30
- input: GeometryInput,
31
- options: HeatmapOptions = {},
32
- ): ResolvedGeometry {
33
- const { plot } = input;
34
-
35
- const rows: string[] = [];
36
- const columns: string[] = [];
37
- const rowSeen = new Set<string>();
38
- const colSeen = new Set<string>();
39
-
40
- for (const series of datasource.series) {
41
- for (const point of series.points) {
42
- if (point.position.kind !== 'matrix') {
43
- continue;
44
- }
45
- if (!rowSeen.has(point.position.row)) {
46
- rowSeen.add(point.position.row);
47
- rows.push(point.position.row);
48
- }
49
- if (!colSeen.has(point.position.column)) {
50
- colSeen.add(point.position.column);
51
- columns.push(point.position.column);
52
- }
53
- }
54
- }
55
-
56
- if (rows.length === 0 || columns.length === 0 || plot.width <= 0) {
57
- return {
58
- plot,
59
- paths: [],
60
- markers: [],
61
- labels: [],
62
- xTicks: [],
63
- yTicks: [],
64
- hitRegions: [],
65
- incomplete: false,
66
- };
67
- }
68
-
69
- const gap = options.cellGap ?? 2;
70
- const cellWidth = plot.width / columns.length;
71
- const cellHeight = plot.height / rows.length;
72
-
73
- const ramp = input.heatmapRamp;
74
- const bucketCount = Math.max(
75
- 2,
76
- Math.min(options.buckets ?? ramp.length, ramp.length),
77
- );
78
-
79
- let min = Infinity;
80
- let max = -Infinity;
81
- for (const series of datasource.series) {
82
- for (const point of series.points) {
83
- if (isKnown(point.value)) {
84
- min = Math.min(min, point.value.value);
85
- max = Math.max(max, point.value.value);
86
- }
87
- }
88
- }
89
- const hasKnown = Number.isFinite(min) && Number.isFinite(max);
90
- const span = max - min;
91
-
92
- // Rects grouped by the path they will join.
93
- const byBucket = new Map<number, RectSpec[]>();
94
- const byTexture = new Map<'unknown' | 'notApplicable' | 'withheld', RectSpec[]>();
95
- const hitRegions: HitRegion[] = [];
96
- let excluded = 0;
97
-
98
- for (const series of datasource.series) {
99
- if (series.hidden) {
100
- continue;
101
- }
102
-
103
- for (const point of series.points) {
104
- if (point.position.kind !== 'matrix') {
105
- continue;
106
- }
107
-
108
- const colIndex = columns.indexOf(point.position.column);
109
- const rowIndex = rows.indexOf(point.position.row);
110
- if (colIndex < 0 || rowIndex < 0) {
111
- continue;
112
- }
113
-
114
- const rect: RectSpec = {
115
- x: plot.x + colIndex * cellWidth + gap / 2,
116
- y: plot.y + rowIndex * cellHeight + gap / 2,
117
- width: Math.max(0, cellWidth - gap),
118
- height: Math.max(0, cellHeight - gap),
119
- radius: options.cellRadius,
120
- };
121
-
122
- if (isKnown(point.value)) {
123
- // A zero lands in bucket 0 rather than being treated as absent —
124
- // "logged nothing" and "we do not know" must not look alike.
125
- const t = hasKnown && span > 0 ? (point.value.value - min) / span : 0;
126
- const bucket = Math.min(
127
- bucketCount - 1,
128
- Math.floor(t * (bucketCount - 1) + 0.5),
129
- );
130
- const list = byBucket.get(bucket) ?? [];
131
- list.push(rect);
132
- byBucket.set(bucket, list);
133
- } else {
134
- excluded += 1;
135
- const kind = point.value.kind;
136
- const list = byTexture.get(kind) ?? [];
137
- list.push(rect);
138
- byTexture.set(kind, list);
139
- }
140
-
141
- hitRegions.push({
142
- seriesId: series.id,
143
- pointId: point.id,
144
- x: rect.x + rect.width / 2,
145
- y: rect.y + rect.height / 2,
146
- bounds: rect,
147
- selectable: !isWithheld(point.value),
148
- });
149
- }
150
- }
151
-
152
- const paths: MarkPath[] = [];
153
-
154
- for (const [bucket, rects] of byBucket) {
155
- paths.push({
156
- key: `cells-${bucket}`,
157
- seriesId: datasource.series[0]?.id ?? 'heatmap',
158
- d: rectBatchPath(rects),
159
- color: ramp[Math.min(bucket, ramp.length - 1)],
160
- pattern: 'solid',
161
- filled: true,
162
- });
163
- }
164
-
165
- for (const [kind, rects] of byTexture) {
166
- paths.push({
167
- key: `cells-${kind}`,
168
- seriesId: datasource.series[0]?.id ?? 'heatmap',
169
- d: rectBatchPath(rects),
170
- color: ramp[0],
171
- pattern: 'solid',
172
- filled: true,
173
- texture: input.nonValueTextures[kind],
174
- });
175
- }
176
-
177
- return {
178
- plot,
179
- paths,
180
- markers: [],
181
- labels: [],
182
- xTicks: columns.map((c, i) => ({
183
- value: i,
184
- position: plot.x + i * cellWidth + cellWidth / 2,
185
- label: input.formatCategory(c),
186
- })),
187
- yTicks: rows.map((r, i) => ({
188
- value: i,
189
- position: plot.y + i * cellHeight + cellHeight / 2,
190
- label: input.formatCategory(r),
191
- })),
192
- hitRegions,
193
- incomplete: excluded > 0,
194
- };
195
- }
1
+ /**
2
+ * Heatmap geometry: a matrix of cells, batched by intensity bucket.
3
+ *
4
+ * This is where batching pays off most. A thousand cells become roughly eight
5
+ * paths — one per intensity bucket, plus one per non-value class — instead of a
6
+ * thousand elements. Cell count no longer drives element count at all
7
+ * (research.md R2).
8
+ *
9
+ * Empty, zero, and unavailable cells are distinct by construction (FR-027): a
10
+ * genuine zero lands in the lowest ramp bucket, while unknown, not-applicable,
11
+ * and withheld each get their own texture.
12
+ *
13
+ * PURE. No React, no SVG.
14
+ */
15
+ import type { ChartDatasource } from '../contract/datasource';
16
+ import { isKnown, isWithheld } from '../contract/value';
17
+ import type { HitRegion } from './hitTest';
18
+ import { rectBatchPath, type RectSpec } from './path';
19
+ import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
20
+
21
+ export interface HeatmapOptions {
22
+ /** Intensity buckets. More buckets means finer shading, more paths. */
23
+ readonly buckets?: number;
24
+ readonly cellGap?: number;
25
+ readonly cellRadius?: number;
26
+ }
27
+
28
+ export function buildHeatmapGeometry<M>(
29
+ datasource: ChartDatasource<M>,
30
+ input: GeometryInput,
31
+ options: HeatmapOptions = {},
32
+ ): ResolvedGeometry {
33
+ const { plot } = input;
34
+
35
+ const rows: string[] = [];
36
+ const columns: string[] = [];
37
+ const rowSeen = new Set<string>();
38
+ const colSeen = new Set<string>();
39
+
40
+ for (const series of datasource.series) {
41
+ for (const point of series.points) {
42
+ if (point.position.kind !== 'matrix') {
43
+ continue;
44
+ }
45
+ if (!rowSeen.has(point.position.row)) {
46
+ rowSeen.add(point.position.row);
47
+ rows.push(point.position.row);
48
+ }
49
+ if (!colSeen.has(point.position.column)) {
50
+ colSeen.add(point.position.column);
51
+ columns.push(point.position.column);
52
+ }
53
+ }
54
+ }
55
+
56
+ if (rows.length === 0 || columns.length === 0 || plot.width <= 0) {
57
+ return {
58
+ plot,
59
+ paths: [],
60
+ markers: [],
61
+ labels: [],
62
+ xTicks: [],
63
+ yTicks: [],
64
+ hitRegions: [],
65
+ incomplete: false,
66
+ };
67
+ }
68
+
69
+ const gap = options.cellGap ?? 2;
70
+ const cellWidth = plot.width / columns.length;
71
+ const cellHeight = plot.height / rows.length;
72
+
73
+ const ramp = input.heatmapRamp;
74
+ const bucketCount = Math.max(2, Math.min(options.buckets ?? ramp.length, ramp.length));
75
+
76
+ let min = Infinity;
77
+ let max = -Infinity;
78
+ for (const series of datasource.series) {
79
+ for (const point of series.points) {
80
+ if (isKnown(point.value)) {
81
+ min = Math.min(min, point.value.value);
82
+ max = Math.max(max, point.value.value);
83
+ }
84
+ }
85
+ }
86
+ const hasKnown = Number.isFinite(min) && Number.isFinite(max);
87
+ const span = max - min;
88
+
89
+ // Rects grouped by the path they will join.
90
+ const byBucket = new Map<number, RectSpec[]>();
91
+ const byTexture = new Map<'unknown' | 'notApplicable' | 'withheld', RectSpec[]>();
92
+ const hitRegions: HitRegion[] = [];
93
+ let excluded = 0;
94
+
95
+ for (const series of datasource.series) {
96
+ if (series.hidden) {
97
+ continue;
98
+ }
99
+
100
+ for (const point of series.points) {
101
+ if (point.position.kind !== 'matrix') {
102
+ continue;
103
+ }
104
+
105
+ const colIndex = columns.indexOf(point.position.column);
106
+ const rowIndex = rows.indexOf(point.position.row);
107
+ if (colIndex < 0 || rowIndex < 0) {
108
+ continue;
109
+ }
110
+
111
+ const rect: RectSpec = {
112
+ x: plot.x + colIndex * cellWidth + gap / 2,
113
+ y: plot.y + rowIndex * cellHeight + gap / 2,
114
+ width: Math.max(0, cellWidth - gap),
115
+ height: Math.max(0, cellHeight - gap),
116
+ radius: options.cellRadius,
117
+ };
118
+
119
+ if (isKnown(point.value)) {
120
+ // A zero lands in bucket 0 rather than being treated as absent —
121
+ // "logged nothing" and "we do not know" must not look alike.
122
+ const t = hasKnown && span > 0 ? (point.value.value - min) / span : 0;
123
+ const bucket = Math.min(bucketCount - 1, Math.floor(t * (bucketCount - 1) + 0.5));
124
+ const list = byBucket.get(bucket) ?? [];
125
+ list.push(rect);
126
+ byBucket.set(bucket, list);
127
+ } else {
128
+ excluded += 1;
129
+ const kind = point.value.kind;
130
+ const list = byTexture.get(kind) ?? [];
131
+ list.push(rect);
132
+ byTexture.set(kind, list);
133
+ }
134
+
135
+ hitRegions.push({
136
+ seriesId: series.id,
137
+ pointId: point.id,
138
+ x: rect.x + rect.width / 2,
139
+ y: rect.y + rect.height / 2,
140
+ bounds: rect,
141
+ selectable: !isWithheld(point.value),
142
+ });
143
+ }
144
+ }
145
+
146
+ const paths: MarkPath[] = [];
147
+
148
+ for (const [bucket, rects] of byBucket) {
149
+ paths.push({
150
+ key: `cells-${bucket}`,
151
+ seriesId: datasource.series[0]?.id ?? 'heatmap',
152
+ d: rectBatchPath(rects),
153
+ color: ramp[Math.min(bucket, ramp.length - 1)],
154
+ pattern: 'solid',
155
+ filled: true,
156
+ });
157
+ }
158
+
159
+ for (const [kind, rects] of byTexture) {
160
+ paths.push({
161
+ key: `cells-${kind}`,
162
+ seriesId: datasource.series[0]?.id ?? 'heatmap',
163
+ d: rectBatchPath(rects),
164
+ color: ramp[0],
165
+ pattern: 'solid',
166
+ filled: true,
167
+ texture: input.nonValueTextures[kind],
168
+ });
169
+ }
170
+
171
+ return {
172
+ plot,
173
+ paths,
174
+ markers: [],
175
+ labels: [],
176
+ xTicks: columns.map((c, i) => ({
177
+ value: i,
178
+ position: plot.x + i * cellWidth + cellWidth / 2,
179
+ label: input.formatCategory(c),
180
+ })),
181
+ yTicks: rows.map((r, i) => ({
182
+ value: i,
183
+ position: plot.y + i * cellHeight + cellHeight / 2,
184
+ label: input.formatCategory(r),
185
+ })),
186
+ hitRegions,
187
+ incomplete: excluded > 0,
188
+ };
189
+ }
@@ -1,120 +1,117 @@
1
- /**
2
- * Hit-testing for batched marks.
3
- *
4
- * Because marks are batched into one path per series, there are no per-element
5
- * press handlers to rely on — a touch is resolved against geometry instead.
6
- * That work would be needed anyway (a mobile time-series chart needs
7
- * nearest-point search regardless of how it is drawn), and doing it in one
8
- * place means a single implementation serves every family.
9
- *
10
- * PURE. No React, no SVG.
11
- */
12
-
13
- export interface HitRegion {
14
- readonly seriesId: string;
15
- readonly pointId: string;
16
- /** Mark centre, used for nearest-point search and for highlight placement. */
17
- readonly x: number;
18
- readonly y: number;
19
- /** Optional bounds, for marks where containment beats proximity. */
20
- readonly bounds?: {
21
- readonly x: number;
22
- readonly y: number;
23
- readonly width: number;
24
- readonly height: number;
25
- };
26
- /**
27
- * False for points that must never be selectable: withheld values, and
28
- * anything the chart is not currently drawing (FR-053).
29
- */
30
- readonly selectable: boolean;
31
- }
32
-
33
- export interface HitResult {
34
- readonly seriesId: string;
35
- readonly pointId: string;
36
- readonly distance: number;
37
- }
38
-
39
- /** Touch slop: how far from a mark a tap still counts, in px. */
40
- export const DEFAULT_HIT_RADIUS = 44;
41
-
42
- function inBounds(region: HitRegion, x: number, y: number): boolean {
43
- const b = region.bounds;
44
- if (!b) {
45
- return false;
46
- }
47
- return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
48
- }
49
-
50
- /**
51
- * Resolves a touch to a mark.
52
- *
53
- * Containment wins over proximity where bounds exist — tapping inside a bar
54
- * should select that bar even if a neighbouring bar's centre happens to be
55
- * closer. Otherwise the nearest selectable mark within the hit radius wins.
56
- */
57
- export function hitTest(
58
- regions: readonly HitRegion[],
59
- x: number,
60
- y: number,
61
- hitRadius = DEFAULT_HIT_RADIUS,
62
- ): HitResult | null {
63
- let best: HitResult | null = null;
64
-
65
- for (const region of regions) {
66
- if (!region.selectable) {
67
- continue;
68
- }
69
-
70
- if (inBounds(region, x, y)) {
71
- return { seriesId: region.seriesId, pointId: region.pointId, distance: 0 };
72
- }
73
-
74
- const dx = region.x - x;
75
- const dy = region.y - y;
76
- const distance = Math.sqrt(dx * dx + dy * dy);
77
-
78
- if (distance <= hitRadius && (best === null || distance < best.distance)) {
79
- best = { seriesId: region.seriesId, pointId: region.pointId, distance };
80
- }
81
- }
82
-
83
- return best;
84
- }
85
-
86
- /**
87
- * Nearest mark by horizontal distance only.
88
- *
89
- * The right model for a time-series chart: a user aiming at "that Tuesday
90
- * spike" cares about the x position, and demanding vertical accuracy on a line
91
- * that may sit anywhere in the plot makes selection frustrating.
92
- */
93
- export function hitTestByX(
94
- regions: readonly HitRegion[],
95
- x: number,
96
- ): HitResult | null {
97
- let best: HitResult | null = null;
98
-
99
- for (const region of regions) {
100
- if (!region.selectable) {
101
- continue;
102
- }
103
- const distance = Math.abs(region.x - x);
104
- if (best === null || distance < best.distance) {
105
- best = { seriesId: region.seriesId, pointId: region.pointId, distance };
106
- }
107
- }
108
-
109
- return best;
110
- }
111
-
112
- export function findRegion(
113
- regions: readonly HitRegion[],
114
- seriesId: string,
115
- pointId?: string,
116
- ): HitRegion | undefined {
117
- return regions.find(
118
- r => r.seriesId === seriesId && (pointId === undefined || r.pointId === pointId),
119
- );
120
- }
1
+ /**
2
+ * Hit-testing for batched marks.
3
+ *
4
+ * Because marks are batched into one path per series, there are no per-element
5
+ * press handlers to rely on — a touch is resolved against geometry instead.
6
+ * That work would be needed anyway (a mobile time-series chart needs
7
+ * nearest-point search regardless of how it is drawn), and doing it in one
8
+ * place means a single implementation serves every family.
9
+ *
10
+ * PURE. No React, no SVG.
11
+ */
12
+
13
+ export interface HitRegion {
14
+ readonly seriesId: string;
15
+ readonly pointId: string;
16
+ /** Mark centre, used for nearest-point search and for highlight placement. */
17
+ readonly x: number;
18
+ readonly y: number;
19
+ /** Optional bounds, for marks where containment beats proximity. */
20
+ readonly bounds?: {
21
+ readonly x: number;
22
+ readonly y: number;
23
+ readonly width: number;
24
+ readonly height: number;
25
+ };
26
+ /**
27
+ * False for points that must never be selectable: withheld values, and
28
+ * anything the chart is not currently drawing (FR-053).
29
+ */
30
+ readonly selectable: boolean;
31
+ }
32
+
33
+ export interface HitResult {
34
+ readonly seriesId: string;
35
+ readonly pointId: string;
36
+ readonly distance: number;
37
+ }
38
+
39
+ /** Touch slop: how far from a mark a tap still counts, in px. */
40
+ export const DEFAULT_HIT_RADIUS = 44;
41
+
42
+ function inBounds(region: HitRegion, x: number, y: number): boolean {
43
+ const b = region.bounds;
44
+ if (!b) {
45
+ return false;
46
+ }
47
+ return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
48
+ }
49
+
50
+ /**
51
+ * Resolves a touch to a mark.
52
+ *
53
+ * Containment wins over proximity where bounds exist — tapping inside a bar
54
+ * should select that bar even if a neighbouring bar's centre happens to be
55
+ * closer. Otherwise the nearest selectable mark within the hit radius wins.
56
+ */
57
+ export function hitTest(
58
+ regions: readonly HitRegion[],
59
+ x: number,
60
+ y: number,
61
+ hitRadius = DEFAULT_HIT_RADIUS,
62
+ ): HitResult | null {
63
+ let best: HitResult | null = null;
64
+
65
+ for (const region of regions) {
66
+ if (!region.selectable) {
67
+ continue;
68
+ }
69
+
70
+ if (inBounds(region, x, y)) {
71
+ return { seriesId: region.seriesId, pointId: region.pointId, distance: 0 };
72
+ }
73
+
74
+ const dx = region.x - x;
75
+ const dy = region.y - y;
76
+ const distance = Math.sqrt(dx * dx + dy * dy);
77
+
78
+ if (distance <= hitRadius && (best === null || distance < best.distance)) {
79
+ best = { seriesId: region.seriesId, pointId: region.pointId, distance };
80
+ }
81
+ }
82
+
83
+ return best;
84
+ }
85
+
86
+ /**
87
+ * Nearest mark by horizontal distance only.
88
+ *
89
+ * The right model for a time-series chart: a user aiming at "that Tuesday
90
+ * spike" cares about the x position, and demanding vertical accuracy on a line
91
+ * that may sit anywhere in the plot makes selection frustrating.
92
+ */
93
+ export function hitTestByX(regions: readonly HitRegion[], x: number): HitResult | null {
94
+ let best: HitResult | null = null;
95
+
96
+ for (const region of regions) {
97
+ if (!region.selectable) {
98
+ continue;
99
+ }
100
+ const distance = Math.abs(region.x - x);
101
+ if (best === null || distance < best.distance) {
102
+ best = { seriesId: region.seriesId, pointId: region.pointId, distance };
103
+ }
104
+ }
105
+
106
+ return best;
107
+ }
108
+
109
+ export function findRegion(
110
+ regions: readonly HitRegion[],
111
+ seriesId: string,
112
+ pointId?: string,
113
+ ): HitRegion | undefined {
114
+ return regions.find(
115
+ r => r.seriesId === seriesId && (pointId === undefined || r.pointId === pointId),
116
+ );
117
+ }