@chipmobilesdk/rn-chart 0.2.0 → 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 -115
  2. package/README.md +381 -381
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -136
  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 -177
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -273
  16. package/src/geometry/distribution.ts +197 -203
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -173
  20. package/src/geometry/markers.ts +55 -57
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -230
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -168
  26. package/src/geometry/types.ts +94 -94
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -622
  30. package/src/react/ChartLegend.tsx +211 -215
  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,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
+ }