Reputation: 465
I need to update the color of points in a mapbox map when hovering over the corresponding item in a list. This is simple enough using queryRenderedFeatures if the item in question is currently rendered. The problem is that we have a cluster layer, so querying rendered features by id will not work (to my knowledge). I pass the lat,lon and id of the item in the list on hover and I check using the id if the item is rendered, if it is update its feature state. If it is not, use queryRenderedFeatures with a bounding box constructed using the difference of the cluster layers cluster radius and the cluster layers rendered circle radius (these produce the square bounding boxes found when the point and cluster are at integer multiples of pi, like 12, 3, 6, and 9 on a clock). I would still sometimes get empty hits but it works in most cases. Relevant parts of code:
const cluster = {
maxZoom: 14,
radius: 20, // clustering radius
minPoints: 2,
circleRadius: 12, // rendered radius of cluster
circleStrokeWidth: 3 // border of cluster (does this come into play with queryRenderFeatures with a bounding box??
};
const queryRadius = cluster.radius - cluster.circleRadius; // produces largest hypotenuse for right triangle with one (or more sides) equal to cluster.radius - cluster.circleRadius
const resolveFeatureId = (map, id, lnglat) => {
let features = map.queryRenderedFeatures({ layers: ['points'], filter: ['==', 'id', id] })
let feature = features[0];
if (feature) {
return id;
} else {
const point = map.project(lnglat);
const radius = queryRadius;
console.log('radius', queryRadius);
const boundingBox = [
[point.x - radius, point.y - radius],
[point.x + radius, point.y + radius]
];
let hits = map.queryRenderedFeatures(boundingBox, { layers: ['clusters-layer'] });
console.log('hits', hits.length, hits);
let hit = hits[0];
console.log('hit', hit);
return hit?.id;
}
};
const onMouseEnterSearchResultHandler = (event) => {
const id = resolveFeatureId(event.map, event.event.id, event.event.coordinates);
//console.log('id', id);
if (!id) return;
event.map.setFeatureState(
{ source: 'searchResults', id },
{ selected: true }
);
};
// layer definitions
const clustersLayer = {
type: 'circle',
filter: ['has', 'point_count'],
paint: {
//'circle-color': '#DC5F13',
'circle-color': ['case',
['boolean', ['feature-state', 'selected'], false],
'#21BA45',
'#DC5F13'
],
'circle-radius': cluster.circleRadius,
'circle-stroke-width': cluster.circleStrokeWidth,
'circle-stroke-color': 'white'
}
};
const clusterCountLayer = {
type: 'symbol',
filter: ['has', 'point_count'],
paint: {
'text-color': 'white'
},
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
}
};
const pointsLayer = {
type: 'circle',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': ['case',
['boolean', ['feature-state', 'selected'], false],
'#21BA45',
'#DC5F13'
],
'circle-stroke-color': 'white',
'circle-stroke-width': 3,
'circle-radius': 10
}
};
Has anyone tried to do a similar thing with querying clusters based on a point? I am wondering if I should start with my initial queryRadius, and if I don't get hits, expand the search radius by an amount (if anyone has a clever value for this amount, I'm all ears :) )
My approach feels clunky and not the best way of solving it. Any advice would be welcome.
Thanks!
EDIT (other things I tried that did not end up working):
I updated the code to increase search radius (well bounding box width/height) if no hits were found and then take the hit nearest to the target point using turf's distance function (haversine implementation). But guess what. Sometimes the nearest cluster is not the correct cluster. Which makes me think it is a projected coords to screen vs lnglat projection issue. And possibly should be doing distance in pixels as the point should be in the cluster if the px distance is <= clustering radius. So going to try that to see if more accurate
const resolveFeatureId = (map, id, lnglat) => {
let features = map.queryRenderedFeatures({ layers: ['points'], filter: ['==', 'id', id] })
let feature = features[0];
if (feature) {
return id;
} else {
// const point = map.project(lnglat);
// const radius = queryRadius;
// console.log('radius', queryRadius);
// const boundingBox = [
// [point.x - radius, point.y - radius],
// [point.x + radius, point.y + radius]
// ];
// let hits = map.queryRenderedFeatures(boundingBox, { layers: ['clusters-layer'] });
// console.log('hits', hits.length, hits);
// let hit = hits[0];
// console.log('hit', hit);
// return hit?.id;
return queryByBoundingBox(map, lnglat, queryRadius);
}
};
const queryByBoundingBox = (map, lnglat, searchRadius) => {
const point = map.project(lnglat);
console.log('radius', searchRadius);
const boundingBox = [
[point.x - searchRadius, point.y - searchRadius],
[point.x + searchRadius, point.y + searchRadius]
];
const hits = map.queryRenderedFeatures(boundingBox, { layers: ['clusters-layer'] });
console.log('hits', hits.length, hits);
if (!hits.length) {
let newRadius = searchRadius + 1;
return queryByBoundingBox(map, lnglat, newRadius);
}
const distances = hits.map((feat, index) => {
return {
d: distance(lnglat, feat.geometry), // @turf/distance
ind: index
}
});
distances.sort((a, b) => a.d - b.d);
console.log('sorted distances', distances);
const hitIndex = distances[0];
const hit = hits[hitIndex.ind];
console.log('hit', hit);
return hit?.id;
};
Changed above to project to screen and then use just euclidean metric, closest cluster wasn't always cluster that was contained. I ended up finding a solution using source.getClusterLeaves
EDIT: my initial approach was wrong, radius needed to be bigger at angles close to but not equal to a multiple of pi. so i would disregard my initial approach entirely and just use getClusterLeaves
Upvotes: 0
Views: 1274
Reputation: 465
Ended up getting a working solution using source.getClusterLeaves
. Would look at hits in current search radius, check cluster leaves to see if target is in list. If no hits or hits dont contain target, recursively call search function with a slightly increased search radius (shouldnt call it a radius, more like width height for bounding box).
Also used promisified getClusterLeaves since the method takes a callback which was loco. https://github.com/mapbox/mapbox-gl-js/issues/9739
getClusterLeaves docs https://docs.mapbox.com/mapbox-gl-js/api/sources/#geojsonsource#getclusterleaves
Upvotes: 2