Skip to content

Commit af8bceb

Browse files
committed
[charts] Support multiple marker size in findClosestPoint
1 parent f8e4bf8 commit af8bceb

4 files changed

Lines changed: 111 additions & 22 deletions

File tree

packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxisRendering.selectors.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import {
5151
selectorChartXAxisExtrema,
5252
selectorChartYAxisExtrema,
5353
} from './useChartAxisExtrema.selectors';
54+
import { selectorChartZAxis } from '../useChartZAxis';
55+
import getMarkerSize, { type ScatterSizeGetter } from '../../../../ScatterChart/seriesConfig/getMarkerSize';
5456

5557
export const createZoomMap = (zoom: readonly ZoomData[]) => {
5658
const zoomItemMap = new Map<AxisId, ZoomData>();
@@ -524,7 +526,15 @@ export const selectorChartDefaultYAxisId = createSelector(
524526
(yAxes) => yAxes![0].id,
525527
);
526528

527-
const EMPTY_MAP = new Map<SeriesId, Flatbush>();
529+
export type ScatterFlatbushEntry = {
530+
flatbush: Flatbush;
531+
/** Per-point marker radius, in pixels. */
532+
getItemRadius: number | ((dataIndex: number) => number);
533+
/** Largest radius across all points in this series, in pixels. */
534+
maxItemRadius: number;
535+
};
536+
537+
const EMPTY_MAP = new Map<SeriesId, ScatterFlatbushEntry>();
528538
export const selectorChartSeriesEmptyFlatbushMap = () => EMPTY_MAP;
529539

530540
export const selectorChartSeriesFlatbushMap = createSelectorMemoized(
@@ -533,42 +543,61 @@ export const selectorChartSeriesFlatbushMap = createSelectorMemoized(
533543
selectorChartNormalizedYScales,
534544
selectorChartDefaultXAxisId,
535545
selectorChartDefaultYAxisId,
546+
selectorChartZAxis,
547+
selectorChartDrawingArea,
536548

537549
function selectChartSeriesFlatbushMap(
538550
allSeries,
539551
xAxesScaleMap,
540552
yAxesScaleMap,
541553
defaultXAxisId,
542554
defaultYAxisId,
555+
zAxisState,
543556
) {
544557
// FIXME: Do we want to support non-scatter series here?
545558
const validSeries = allSeries.scatter;
546-
const flatbushMap = new Map<SeriesId, Flatbush>();
559+
const flatbushMap = new Map<SeriesId, ScatterFlatbushEntry>();
547560

548561
if (!validSeries) {
549562
return flatbushMap;
550563
}
551564

565+
const zAxes = zAxisState?.axis ?? {};
566+
const zAxisIds = zAxisState?.axisIds ?? [];
567+
552568
validSeries.seriesOrder.forEach((seriesId) => {
553-
const {
554-
data,
555-
xAxisId = defaultXAxisId,
556-
yAxisId = defaultYAxisId,
557-
} = validSeries.series[seriesId];
569+
const series = validSeries.series[seriesId];
570+
const { data, xAxisId = defaultXAxisId, yAxisId = defaultYAxisId } = series;
571+
572+
if (data.length === 0) {
573+
return;
574+
}
558575

559576
const flatbush = new Flatbush(data.length);
560577

578+
const sizeAxis = zAxes[series.sizeAxisId ?? zAxisIds[0]];
579+
580+
const isFixedSize = !sizeAxis || !sizeAxis.sizeScale;
581+
const getItemRadius = isFixedSize
582+
? series.markerSize ?? 0
583+
: getMarkerSize(series, sizeAxis);
584+
585+
let maxItemRadius = isFixedSize ? getItemRadius as number : 0;
586+
561587
const originalXScale = xAxesScaleMap[xAxisId];
562588
const originalYScale = yAxesScaleMap[yAxisId];
563589

564-
for (const datum of data) {
590+
for (let i = 0; i < data.length; i += 1) {
591+
if (!isFixedSize) {
592+
maxItemRadius = Math.max(maxItemRadius, (getItemRadius as ScatterSizeGetter)(i));
593+
}
565594
// Add the points using a [0, 1] range so that we don't need to recreate the Flatbush structure when zooming.
566595
// This doesn't happen in practice, though, because currently the scales depend on the drawing area.
567-
flatbush.add(originalXScale(datum.x)!, originalYScale(datum.y)!);
596+
flatbush.add(originalXScale(data[i].x)!, originalYScale(data[i].y)!);
568597
}
569598

570599
flatbush.finish();
571-
flatbushMap.set(seriesId, flatbush);
600+
flatbushMap.set(seriesId, { flatbush, getItemRadius, maxItemRadius });
572601
});
573602

574603
return flatbushMap;

packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/findClosestPoints.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { type Flatbush } from '../../../Flatbush';
33
import { type D3Scale } from '../../../../models/axis';
44
import { isOrdinalScale } from '../../../scaleGuards';
55

6+
// Arbitrary large number to be sure we don't pull the entire dataset from flatbush when radius is not fixed.
7+
const LARGE_NUMBER = 50;
8+
69
export function findClosestPoints(
710
flatbush: Flatbush,
811
seriesData: readonly ScatterValueType[],
@@ -15,7 +18,8 @@ export function findClosestPoints(
1518
svgPointX: number,
1619
svgPointY: number,
1720
maxRadius: number = Infinity,
18-
maxResults = 1,
21+
maxResults: number = 1,
22+
getItemRadius: number | ((dataIndex: number) => number) = 0,
1923
) {
2024
const originalXScale = xScale.copy();
2125
const originalYScale = yScale.copy();
@@ -47,14 +51,56 @@ export function findClosestPoints(
4751
invertScale(yScale, svgPointY, (dataIndex) => seriesData[dataIndex]?.y),
4852
);
4953

50-
return flatbush.neighbors(
54+
if (pointX === undefined || pointY === undefined) {
55+
return [];
56+
}
57+
58+
const withFixRadius = typeof getItemRadius === 'number';
59+
const maxRadiusSq = Number.isFinite(maxRadius) ? maxRadius * maxRadius : Infinity;
60+
61+
// Pull every candidate whose lower-bound (box) distance is within the hit threshold.
62+
// Any unpulled point j has box-dist > maxRadius, hence center-dist ≥ box-dist > maxRadius,
63+
// so it cannot be a hit. We re-rank by true edge distance below.
64+
const candidates = flatbush.neighbors(
5165
pointX,
5266
pointY,
53-
maxResults,
54-
maxRadius != null ? maxRadius * maxRadius : Infinity,
67+
withFixRadius ? maxResults : LARGE_NUMBER,
68+
maxRadiusSq,
5569
excludeIfOutsideDrawingArea,
5670
sqDistFn,
5771
);
72+
73+
74+
if (withFixRadius) {
75+
// If radius is constant, we can skip the expensive edge-distance calculation and return candidates in box-distance order.
76+
return candidates;
77+
}
78+
79+
// Re-rank by true (signed) edge distance. Negative values mean the cursor is inside
80+
// the marker — those win over any outside marker, with deeper containment ranked first.
81+
let ranked: { index: number; edge: number; centerDistSq: number }[] = [];
82+
for (const i of candidates) {
83+
const cx = originalXScale(seriesData[i].x)!;
84+
const cy = originalYScale(seriesData[i].y)!;
85+
const centerDistSq = sqDistFn(cx - pointX, cy - pointY);
86+
// Preserve the existing hit-area semantics: hit means center distance ≤ maxRadius.
87+
if (centerDistSq > maxRadiusSq) {
88+
continue;
89+
}
90+
const edge = Math.sqrt(centerDistSq) - getItemRadius(i);
91+
ranked.push({ index: i, edge, centerDistSq });
92+
}
93+
ranked.sort((a, b) => a.edge - b.edge);
94+
95+
// The pointer is inside multiple marks, we sore them by distance to the center.
96+
const splitIndex = ranked.findIndex((d) => d.edge > 0);
97+
if (splitIndex !== -1) {
98+
ranked = [...ranked.slice(0, splitIndex).sort((a, b) => a.centerDistSq - b.centerDistSq), ...ranked.slice(splitIndex)];
99+
100+
}
101+
return ranked
102+
.slice(0, Math.min(ranked.length, maxResults))
103+
.map((d) => d.index);
58104
}
59105

60106
function invertScale<T>(scale: D3Scale, value: number, getDataPoint: (dataIndex: number) => T) {

packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,27 @@ export const useChartClosestPoint: ChartPlugin<UseChartClosestPointSignature> =
6565
return 'outside-chart';
6666
}
6767

68-
let closestPoint: { dataIndex: number; seriesId: SeriesId; distanceSq: number } | undefined =
69-
undefined;
68+
let closestPoint:
69+
| { dataIndex: number; seriesId: SeriesId; edgeDistance: number }
70+
| undefined = undefined;
7071

7172
for (const seriesId of seriesOrder ?? []) {
7273
const aSeries = (series ?? {})[seriesId];
73-
const flatbush = flatbushMap.get(seriesId);
74+
const entry = flatbushMap.get(seriesId);
7475

75-
if (!flatbush) {
76+
if (!entry) {
7677
continue;
7778
}
7879

80+
const { flatbush, getItemRadius, maxItemRadius } = entry;
81+
7982
const xAxisId = aSeries.xAxisId ?? defaultXAxisId;
8083
const yAxisId = aSeries.yAxisId ?? defaultYAxisId;
8184

8285
const xAxisZoom = selectorChartAxisZoomData(store.state, xAxisId);
8386
const yAxisZoom = selectorChartAxisZoomData(store.state, yAxisId);
8487
const maxRadius =
85-
resolvedHitAreaRadius === 'item' ? aSeries.markerSize : resolvedHitAreaRadius;
88+
resolvedHitAreaRadius === 'item' ? maxItemRadius : resolvedHitAreaRadius;
8689

8790
const xZoomStart = (xAxisZoom?.start ?? 0) / 100;
8891
const xZoomEnd = (xAxisZoom?.end ?? 100) / 100;
@@ -104,6 +107,8 @@ export const useChartClosestPoint: ChartPlugin<UseChartClosestPointSignature> =
104107
svgPoint.x,
105108
svgPoint.y,
106109
maxRadius,
110+
1,
111+
getItemRadius
107112
)[0];
108113

109114
if (closestPointIndex === undefined) {
@@ -114,13 +119,20 @@ export const useChartClosestPoint: ChartPlugin<UseChartClosestPointSignature> =
114119
const scaledX = xScale(point.x);
115120
const scaledY = yScale(point.y);
116121

117-
const distSq = (scaledX! - svgPoint.x) ** 2 + (scaledY! - svgPoint.y) ** 2;
122+
const centerDist = Math.hypot(scaledX! - svgPoint.x, scaledY! - svgPoint.y);
123+
const closestPointRadius = typeof getItemRadius === 'number' ? getItemRadius : getItemRadius(closestPointIndex);
124+
const edgeDistance = centerDist - closestPointRadius;
118125

119-
if (closestPoint === undefined || distSq < closestPoint.distanceSq) {
126+
if (edgeDistance > closestPointRadius && resolvedHitAreaRadius === 'item') {
127+
continue;
128+
}
129+
if (closestPoint === undefined || edgeDistance < closestPoint.edgeDistance ||
130+
(resolvedHitAreaRadius === 'item' && edgeDistance === closestPoint.edgeDistance)
131+
) {
120132
closestPoint = {
121133
dataIndex: closestPointIndex,
122134
seriesId,
123-
distanceSq: distSq,
135+
edgeDistance,
124136
};
125137
}
126138
}

packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type UseChartCartesianAxisSignature } from '../useChartCartesianAxis';
66
import { type UseChartHighlightSignature } from '../useChartHighlight';
77
import { type UseChartInteractionSignature } from '../useChartInteraction';
88
import { type UseChartTooltipSignature } from '../useChartTooltip';
9+
import { type UseChartZAxisSignature } from '../useChartZAxis';
910

1011
export interface UseChartVoronoiInstance {
1112
/**
@@ -64,5 +65,6 @@ export type UseChartClosestPointSignature<SeriesType extends ChartSeriesType = C
6465
UseChartInteractionSignature,
6566
UseChartHighlightSignature<SeriesType>,
6667
UseChartTooltipSignature,
68+
UseChartZAxisSignature
6769
];
6870
}>;

0 commit comments

Comments
 (0)