@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.
- package/CHANGELOG.md +126 -115
- package/README.md +381 -381
- package/package.json +46 -49
- package/skills/sdk-rn-chart/SKILL.md +68 -69
- package/src/a11y/summary.ts +170 -177
- package/src/contract/datasource.ts +126 -136
- package/src/contract/errors.ts +129 -133
- package/src/contract/format.ts +162 -168
- package/src/contract/interaction.ts +70 -71
- package/src/contract/marker.ts +75 -75
- package/src/contract/props.ts +57 -60
- package/src/contract/state.ts +88 -94
- package/src/contract/theme.ts +169 -177
- package/src/contract/value.ts +121 -125
- package/src/geometry/bar.ts +271 -273
- package/src/geometry/distribution.ts +197 -203
- package/src/geometry/heatmap.ts +189 -195
- package/src/geometry/hitTest.ts +117 -120
- package/src/geometry/layout.ts +162 -173
- package/src/geometry/markers.ts +55 -57
- package/src/geometry/path.ts +193 -199
- package/src/geometry/progress.ts +223 -230
- package/src/geometry/scale.ts +140 -146
- package/src/geometry/ticks.ts +145 -153
- package/src/geometry/timeSeries.ts +168 -168
- package/src/geometry/types.ts +94 -94
- package/src/index.ts +158 -176
- package/src/react/ChartBoundary.tsx +96 -96
- package/src/react/ChartFrame.tsx +583 -622
- package/src/react/ChartLegend.tsx +211 -215
- package/src/react/ChartThemeProvider.tsx +44 -48
- package/src/react/charts.tsx +154 -161
- package/src/react/useChartLayout.ts +124 -124
- package/src/react/useChartSelection.ts +152 -170
- package/src/render/Axis.tsx +120 -122
- package/src/render/Marks.tsx +173 -205
- package/src/validation/budgets.ts +103 -110
- package/src/validation/validateDatasource.ts +174 -174
package/src/geometry/heatmap.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const list =
|
|
131
|
-
list.push(rect);
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
package/src/geometry/hitTest.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
|
|
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
|
+
}
|