@chipmobilesdk/rn-chart 0.1.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.
- package/CHANGELOG.md +74 -0
- package/README.md +358 -0
- package/package.json +48 -0
- package/src/a11y/summary.ts +177 -0
- package/src/contract/datasource.ts +134 -0
- package/src/contract/errors.ts +133 -0
- package/src/contract/format.ts +168 -0
- package/src/contract/interaction.ts +71 -0
- package/src/contract/marker.ts +75 -0
- package/src/contract/props.ts +60 -0
- package/src/contract/state.ts +94 -0
- package/src/contract/theme.ts +139 -0
- package/src/contract/value.ts +125 -0
- package/src/geometry/bar.ts +256 -0
- package/src/geometry/distribution.ts +200 -0
- package/src/geometry/heatmap.ts +195 -0
- package/src/geometry/hitTest.ts +120 -0
- package/src/geometry/layout.ts +121 -0
- package/src/geometry/path.ts +199 -0
- package/src/geometry/progress.ts +204 -0
- package/src/geometry/scale.ts +146 -0
- package/src/geometry/ticks.ts +153 -0
- package/src/geometry/timeSeries.ts +158 -0
- package/src/geometry/types.ts +88 -0
- package/src/index.ts +176 -0
- package/src/react/ChartBoundary.tsx +96 -0
- package/src/react/ChartFrame.tsx +620 -0
- package/src/react/ChartLegend.tsx +177 -0
- package/src/react/ChartThemeProvider.tsx +48 -0
- package/src/react/charts.tsx +161 -0
- package/src/react/useChartLayout.ts +124 -0
- package/src/react/useChartSelection.ts +170 -0
- package/src/render/Axis.tsx +122 -0
- package/src/render/Marks.tsx +205 -0
- package/src/validation/budgets.ts +110 -0
- package/src/validation/validateDatasource.ts +174 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distribution geometry: proportions between groups.
|
|
3
|
+
*
|
|
4
|
+
* Segments that do not sum to the whole are **not** rescaled to force a full
|
|
5
|
+
* circle (FR-024). The arc stops short and the geometry reports `incomplete`,
|
|
6
|
+
* so the chart can say the data is partial rather than quietly implying it is
|
|
7
|
+
* complete.
|
|
8
|
+
*
|
|
9
|
+
* PURE. No React, no SVG.
|
|
10
|
+
*/
|
|
11
|
+
import type { ChartDatasource } from '../contract/datasource';
|
|
12
|
+
import { isKnown, isWithheld, sumKnown } from '../contract/value';
|
|
13
|
+
import type { HitRegion } from './hitTest';
|
|
14
|
+
import { arcPath, rectSubpath } from './path';
|
|
15
|
+
import type {
|
|
16
|
+
GeometryInput,
|
|
17
|
+
GeometryLabel,
|
|
18
|
+
MarkPath,
|
|
19
|
+
ResolvedGeometry,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
export type DistributionShape = 'donut' | 'pie' | 'segmentedBar';
|
|
23
|
+
|
|
24
|
+
export interface DistributionOptions {
|
|
25
|
+
readonly shape?: DistributionShape;
|
|
26
|
+
/** Donut hole as a fraction of the outer radius. */
|
|
27
|
+
readonly innerRadiusRatio?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TAU = Math.PI * 2;
|
|
31
|
+
|
|
32
|
+
export function buildDistributionGeometry<M>(
|
|
33
|
+
datasource: ChartDatasource<M>,
|
|
34
|
+
input: GeometryInput,
|
|
35
|
+
options: DistributionOptions = {},
|
|
36
|
+
): ResolvedGeometry {
|
|
37
|
+
const { plot } = input;
|
|
38
|
+
const shape = options.shape ?? 'donut';
|
|
39
|
+
|
|
40
|
+
const segments = datasource.series.flatMap(series =>
|
|
41
|
+
series.points.map(point => ({ series, point })),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (segments.length === 0 || plot.width <= 0 || plot.height <= 0) {
|
|
45
|
+
return {
|
|
46
|
+
plot,
|
|
47
|
+
paths: [],
|
|
48
|
+
markers: [],
|
|
49
|
+
labels: [],
|
|
50
|
+
xTicks: [],
|
|
51
|
+
yTicks: [],
|
|
52
|
+
hitRegions: [],
|
|
53
|
+
incomplete: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const total = sumKnown(segments.map(s => s.point.value));
|
|
58
|
+
// Nothing known: there is no distribution to draw, and drawing a full grey
|
|
59
|
+
// circle would imply a shape the data does not support.
|
|
60
|
+
if (total.countedPoints === 0 || total.total <= 0) {
|
|
61
|
+
return {
|
|
62
|
+
plot,
|
|
63
|
+
paths: [],
|
|
64
|
+
markers: [],
|
|
65
|
+
labels: [],
|
|
66
|
+
xTicks: [],
|
|
67
|
+
yTicks: [],
|
|
68
|
+
hitRegions: [],
|
|
69
|
+
incomplete: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Proportions supplied directly (each in 0..1, summing to ~1) are honoured
|
|
74
|
+
// as given; absolute values are normalised by their own total. Either way we
|
|
75
|
+
// never stretch a short sum to fill the circle.
|
|
76
|
+
const suppliedAsProportions = segments.every(
|
|
77
|
+
s => !isKnown(s.point.value) || (s.point.value.value >= 0 && s.point.value.value <= 1),
|
|
78
|
+
);
|
|
79
|
+
const denominator = suppliedAsProportions ? 1 : total.total;
|
|
80
|
+
const coverage = total.total / denominator;
|
|
81
|
+
const incomplete = total.incomplete || coverage < 0.999;
|
|
82
|
+
|
|
83
|
+
const paths: MarkPath[] = [];
|
|
84
|
+
const labels: GeometryLabel[] = [];
|
|
85
|
+
const hitRegions: HitRegion[] = [];
|
|
86
|
+
|
|
87
|
+
if (shape === 'segmentedBar') {
|
|
88
|
+
const barHeight = Math.min(plot.height, 28);
|
|
89
|
+
const barY = plot.y + (plot.height - barHeight) / 2;
|
|
90
|
+
let cursor = plot.x;
|
|
91
|
+
|
|
92
|
+
for (const { series, point } of segments) {
|
|
93
|
+
if (!isKnown(point.value)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const fraction = point.value.value / denominator;
|
|
97
|
+
const width = plot.width * fraction;
|
|
98
|
+
|
|
99
|
+
paths.push({
|
|
100
|
+
key: `seg-${series.id}-${point.id}`,
|
|
101
|
+
seriesId: series.id,
|
|
102
|
+
d: rectSubpath({ x: cursor, y: barY, width, height: barHeight }),
|
|
103
|
+
color: input.seriesColors[point.id] ?? input.seriesColors[series.id],
|
|
104
|
+
pattern: 'solid',
|
|
105
|
+
filled: true,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
hitRegions.push({
|
|
109
|
+
seriesId: series.id,
|
|
110
|
+
pointId: point.id,
|
|
111
|
+
x: cursor + width / 2,
|
|
112
|
+
y: barY + barHeight / 2,
|
|
113
|
+
bounds: { x: cursor, y: barY, width, height: barHeight },
|
|
114
|
+
selectable: !isWithheld(point.value),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
cursor += width;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
plot,
|
|
122
|
+
paths,
|
|
123
|
+
markers: [],
|
|
124
|
+
labels,
|
|
125
|
+
xTicks: [],
|
|
126
|
+
yTicks: [],
|
|
127
|
+
hitRegions,
|
|
128
|
+
incomplete,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cx = plot.x + plot.width / 2;
|
|
133
|
+
const cy = plot.y + plot.height / 2;
|
|
134
|
+
const outerRadius = Math.min(plot.width, plot.height) / 2;
|
|
135
|
+
const innerRadius =
|
|
136
|
+
shape === 'pie' ? 0 : outerRadius * (options.innerRadiusRatio ?? 0.6);
|
|
137
|
+
|
|
138
|
+
let angle = 0;
|
|
139
|
+
|
|
140
|
+
for (const { series, point } of segments) {
|
|
141
|
+
if (!isKnown(point.value)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fraction = point.value.value / denominator;
|
|
146
|
+
const sweep = fraction * TAU;
|
|
147
|
+
const start = angle;
|
|
148
|
+
const end = angle + sweep;
|
|
149
|
+
angle = end;
|
|
150
|
+
|
|
151
|
+
paths.push({
|
|
152
|
+
key: `arc-${series.id}-${point.id}`,
|
|
153
|
+
seriesId: series.id,
|
|
154
|
+
d: arcPath({
|
|
155
|
+
cx,
|
|
156
|
+
cy,
|
|
157
|
+
outerRadius,
|
|
158
|
+
innerRadius,
|
|
159
|
+
startAngle: start,
|
|
160
|
+
endAngle: end,
|
|
161
|
+
}),
|
|
162
|
+
color: input.seriesColors[point.id] ?? input.seriesColors[series.id],
|
|
163
|
+
pattern: 'solid',
|
|
164
|
+
filled: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Label at the segment's midpoint, on the ring between the two radii.
|
|
168
|
+
const mid = start + sweep / 2 - Math.PI / 2;
|
|
169
|
+
const labelRadius = (outerRadius + innerRadius) / 2;
|
|
170
|
+
|
|
171
|
+
labels.push({
|
|
172
|
+
key: `arc-label-${series.id}-${point.id}`,
|
|
173
|
+
text: `${Math.round(fraction * 100)}%`,
|
|
174
|
+
x: cx + labelRadius * Math.cos(mid),
|
|
175
|
+
y: cy + labelRadius * Math.sin(mid),
|
|
176
|
+
anchor: 'middle',
|
|
177
|
+
// Hide labels on slivers where the text would not fit anyway.
|
|
178
|
+
muted: fraction < 0.05,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
hitRegions.push({
|
|
182
|
+
seriesId: series.id,
|
|
183
|
+
pointId: point.id,
|
|
184
|
+
x: cx + labelRadius * Math.cos(mid),
|
|
185
|
+
y: cy + labelRadius * Math.sin(mid),
|
|
186
|
+
selectable: !isWithheld(point.value),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
plot,
|
|
192
|
+
paths,
|
|
193
|
+
markers: [],
|
|
194
|
+
labels,
|
|
195
|
+
xTicks: [],
|
|
196
|
+
yTicks: [],
|
|
197
|
+
hitRegions,
|
|
198
|
+
incomplete,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,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(
|
|
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
|
+
}
|
|
@@ -0,0 +1,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(
|
|
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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plot layout: turning a container size into a drawable plot rectangle.
|
|
3
|
+
*
|
|
4
|
+
* Font scale arrives as a parameter rather than being read from `PixelRatio`
|
|
5
|
+
* here, so this module stays pure and testable at any scale without mocking a
|
|
6
|
+
* platform API. The React layer reads the real value and passes it in.
|
|
7
|
+
*
|
|
8
|
+
* PURE. No React, no SVG.
|
|
9
|
+
*/
|
|
10
|
+
import { reserveAxisSpace } from './ticks';
|
|
11
|
+
|
|
12
|
+
export interface PlotRect {
|
|
13
|
+
readonly x: number;
|
|
14
|
+
readonly y: number;
|
|
15
|
+
readonly width: number;
|
|
16
|
+
readonly height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ContainerSize {
|
|
20
|
+
readonly width: number;
|
|
21
|
+
readonly height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LayoutOptions {
|
|
25
|
+
readonly container: ContainerSize;
|
|
26
|
+
/** Axis label text, used to reserve exactly as much space as is needed. */
|
|
27
|
+
readonly xLabels?: readonly string[];
|
|
28
|
+
readonly yLabels?: readonly string[];
|
|
29
|
+
readonly baseFontSize: number;
|
|
30
|
+
/** System text-size multiplier, from `PixelRatio.getFontScale()` (FR-071). */
|
|
31
|
+
readonly fontScale: number;
|
|
32
|
+
readonly padding?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ceiling on how far system text scaling may inflate chart labels.
|
|
37
|
+
*
|
|
38
|
+
* Beyond this the plot area collapses faster than the labels become useful, so
|
|
39
|
+
* we cap the growth rather than let the chart squeeze itself out of existence.
|
|
40
|
+
* The cap is documented in the README as the supported dynamic-type range.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_FONT_SCALE = 1.6;
|
|
43
|
+
|
|
44
|
+
export function effectiveFontSize(
|
|
45
|
+
baseFontSize: number,
|
|
46
|
+
fontScale: number,
|
|
47
|
+
): number {
|
|
48
|
+
const clamped = Math.max(1, Math.min(fontScale, MAX_FONT_SCALE));
|
|
49
|
+
return Math.round(baseFontSize * clamped);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A container that has not been measured yet, or has collapsed (FR-078). */
|
|
53
|
+
export function isMeasurable(size: ContainerSize): boolean {
|
|
54
|
+
return (
|
|
55
|
+
Number.isFinite(size.width) &&
|
|
56
|
+
Number.isFinite(size.height) &&
|
|
57
|
+
size.width > 0 &&
|
|
58
|
+
size.height > 0
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const EMPTY_PLOT: PlotRect = { x: 0, y: 0, width: 0, height: 0 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reserves axis space from estimated label widths, then hands back what is
|
|
66
|
+
* left for the marks.
|
|
67
|
+
*
|
|
68
|
+
* If the reservations would leave nothing to draw on, they are scaled back:
|
|
69
|
+
* a cramped axis beats a zero-area plot.
|
|
70
|
+
*/
|
|
71
|
+
export function resolvePlotRect(options: LayoutOptions): PlotRect {
|
|
72
|
+
if (!isMeasurable(options.container)) {
|
|
73
|
+
return EMPTY_PLOT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pad = options.padding ?? 0;
|
|
77
|
+
const fontSize = effectiveFontSize(options.baseFontSize, options.fontScale);
|
|
78
|
+
|
|
79
|
+
let left = pad;
|
|
80
|
+
let bottom = pad;
|
|
81
|
+
|
|
82
|
+
if (options.yLabels && options.yLabels.length > 0) {
|
|
83
|
+
left += reserveAxisSpace(options.yLabels, fontSize, 'vertical');
|
|
84
|
+
}
|
|
85
|
+
if (options.xLabels && options.xLabels.length > 0) {
|
|
86
|
+
bottom += reserveAxisSpace(options.xLabels, fontSize, 'horizontal');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const availableWidth = options.container.width - pad;
|
|
90
|
+
const availableHeight = options.container.height - pad;
|
|
91
|
+
|
|
92
|
+
// Never let axis reservations consume more than 40% of either dimension.
|
|
93
|
+
const maxLeft = availableWidth * 0.4;
|
|
94
|
+
const maxBottom = availableHeight * 0.4;
|
|
95
|
+
left = Math.min(left, maxLeft);
|
|
96
|
+
bottom = Math.min(bottom, maxBottom);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
x: left,
|
|
100
|
+
y: pad,
|
|
101
|
+
width: Math.max(0, options.container.width - left - pad),
|
|
102
|
+
height: Math.max(0, options.container.height - bottom - pad),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function plotBottom(plot: PlotRect): number {
|
|
107
|
+
return plot.y + plot.height;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function plotRight(plot: PlotRect): number {
|
|
111
|
+
return plot.x + plot.width;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function containsPoint(plot: PlotRect, x: number, y: number): boolean {
|
|
115
|
+
return (
|
|
116
|
+
x >= plot.x &&
|
|
117
|
+
x <= plotRight(plot) &&
|
|
118
|
+
y >= plot.y &&
|
|
119
|
+
y <= plotBottom(plot)
|
|
120
|
+
);
|
|
121
|
+
}
|