Closed D1M closed 5 years ago
I have an idea — how about implementing this externally with existing tools like this:
querySourceFeatures
on every integer zoom change and synchronize the results with the DOM markers on the map. Since the number of cluster is usually relatively low, performance should be fine.I could try doing a proof of concept. Does this sound like something that would fit use cases here, or is there anything I'm not taking into account?
I already done this before, but if you do this, there a limitation : you cannot get the leaves of the clusters. And that's a big issue if you need that. So it's ok as long as you don't need theses informations do draw/interact with the cluster.
@Wykks why not? You can keep track of cluster ids in the DOM marker objects, and then use the new cluster API methods like getClusterChildren.
Oh well, when I played with this, the new api (https://github.com/mapbox/mapbox-gl-js/pull/6829) wasn't there :smile: That's cool, I should try to do this in ngx-mapbox-gl :+1:
@mourner that sounds good. It doesn't need to be in the Mapbox GL core but it'd be great to have an example to get us started 👍
@malwoodsantoro @mzdraper this could be a useful example to add to our docs
Any news on this feature?
@mourner @Wykks Did you guys achieved the expected result? If yes, can you please do a jsfiddle for the help of the community.
+1
Is anyone working on this? I don't know if I should keep waiting for this or look for another solution. Thanks!
I tried the solution someone mentioned that consists of adding a transparent circle layer on the map, enabling clustering on it, the generating html markers after the map finished moving. Works great. I made sure every circle had a unique ID and created a local cache for the markers which is indexed by the ID. Tested with 20,000 markers and worked great. Likely can handle a lot more. A good code sample or plug in might be sufficient.
@rbrundritt would you be able to share an example of your solution? Trying to accomplish something similar in a React project. Struggling with displaying only those HTML markers that are currently not in a cluster.
Unfortunately my sample is built into a heavily modified version of the Mapbox GL control and it would likely be more work understanding the modified version than the clustering logic. The basic approach is as follows:
Thanks for your detailed answer. Makes sense. Your advice to use moveend
event totally works after actually moving the map. But how do you get queryRenderedFeatures
from the layer on initial load? This is what I'm having trouble with.
What I've done is wait for the load event of the map to fire, then load my source and layer. Then following that call the same logic I have in the moveend event. If there is a decent amount of data in the source, the clustering logic would likely still be running and the initial query for rendered data will be blank. To address this, use the sourcechange event and have it run the same logic as moveend. This will fire when the source data updates which will address this issue and will also allow you to update the source data and have it automatically reflected in your HTML marker layer.
@rbrundritt Thank you for all the info! It would be really cool if mapbox-gl supported clustering with html markers out of the box.
+1 I believe this feature (clustering of custom html markers) is long overdue.
+1 for clustering of markers. Honestly, I was surprised to learn that this is not possible yet. It just makes so much sense and is a feature many people are used to these days.
I don't work for Mapbox, so guessing here, but suspect this was left out as the focus appears to be on native render (i.e. symbol layer) which supports much larger data sets. The native functionality supports clustering. HTML markers I think were added more so for those moving from legacy apps or who render a few points but need full HTML/CSS support.
From first reading this, it might seem to some it is not possible to have clusters/markers with custom icons/HTML popups on mapbox GL which absolutely is possible.
Don't let yourself be fooled! It is absolutely possible and not too hard. Here are some pointers. 1) use exclusively geoJSON. Add a property to hold your icon name. 2) make the layer with the unclustered items a symbol layer
type: 'symbol',
layout: {
'icon-image':'{yourIconPropertyName}'
}
3) add your custom icons with map.loadImage() 4) react to mouse clicks onto markers and create and open popups
map.on(
'click',
'unclustered-points',
function (e) {
var coordinates = e.features[0].geometry.coordinates.slice()
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360
}
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(
JSON.stringify(e.features[0].properties) // just an example ;)
)
.addTo(map)
}
)
Dear Mapbox, I suggest you add an example showcasing that.
@jonaseberle I think you missed the ask for this task. The ask is for the ability to cluster HTML markers. Clustering symbols and displaying popups with HTML content is already well known, and not what people are asking for here. Symbols are great for rendering large data set, but is not nearly as customizable as HTML markers where you have access to the full CSS stack. Here is an example where HTML markers are used to represent clusters. Each cluster marker is a pie chart where each slice of the pie is fully interactive.
Ah, I see. Thank you for the example @rbrundritt . Sorry to having derailed that issue. It is just that the first post mentions an example where the marker is just an image - which is exactly possible with a symbol geoJSON layer.
Hey, heads up that I'm working on an official example at the moment — will aim for something like on the screenshot above. Stay tuned in the nearest week!
The new example with HTML clusters and property aggregation is live — check it out! Thanks to everyone who participated in the thread, and let me know if there's anything we can improve / clarify. https://docs.mapbox.com/mapbox-gl-js/example/cluster-html/
Excellent work!! Although, I think the only thing missing is when we click on the marker it should zoom in the map. Is there any way I can achieve this functionality?
@smitraval The normal symbol clustering demo does this https://docs.mapbox.com/mapbox-gl-js/example/cluster/
// inspect a cluster on click
map.on('click', 'clusters', function (e) {
var features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
var clusterId = features[0].properties.cluster_id;
map.getSource('earthquakes').getClusterExpansionZoom(clusterId, function (err, zoom) {
if (err)
return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
@andrewharvey Thank you very much for your quick reply. I will try the same and update the final result here for future reference.
Hi @andrewharvey , Using your example I have successfully created marker clusters with images, However I am still stuck at a point where I need to replace un-clustered points with custom html div. Here is my code
`map.on('load', function () {
// Add a new source from our GeoJSON data and set the
// 'cluster' option to true. GL-JS will add the point_count property to your source data.
map.addSource("hotels", {
type: "geojson",
// Point to GeoJSON data. This example visualizes all M1.0+ earthquakes
// from 12/22/15 to 1/21/16 as logged by USGS' Earthquake hazards program.
data: hotelGeo,//"https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson",
cluster: true,
//clusterMaxZoom: 14, // Max zoom to cluster points on
//clusterRadius: 40 // Radius of each cluster when clustering points (defaults to 50)
});
map.addLayer({
id: "clusters",
type: "circle",
source: "hotels",
filter: ["has", "point_count"],
paint: {
// Use step expressions (https://docs.mapbox.com/mapbox-gl-js/style-spec/#expressions-step)
// with three steps to implement three types of circles:
// * Blue, 20px circles when point count is less than 100
// * Yellow, 30px circles when point count is between 100 and 750
// * Pink, 40px circles when point count is greater than or equal to 750
//"circle-color": "blue",
"circle-radius": 0
}
});
// map.addLayer({
// id: "cluster-count",
// type: "symbol",
// source: "hotels",
// filter: ["has", "point_count"],
// layout: {
// "text-field": "{point_count_abbreviated}",
// "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
// "text-size": 0
// }
// });
map.addLayer({
id: "unclustered-point",
type: "circle",
source: "hotels",
filter: ["!=", "cluster", true],
paint: {
"circle-color": "red",
"circle-radius": 4,
"circle-stroke-width": 1,
"circle-stroke-color": "#fff"
}
});
// objects for caching and keeping track of HTML marker objects (for performance)
var markers = {};
var markersOnScreen = {};
var coordinates = {};
function updateMarkers() {
var newMarkers = {};
var features = map.querySourceFeatures('hotels');
// for every cluster on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (var i = 0; i < features.length; i++) {
if (!features[i]) continue;
var coords = features[i].geometry.coordinates;
var props = features[i].properties;
if (!props.cluster) continue;
var id = props.cluster_id;
coordinates[id] = coords;
// console.log("check id", id)
var marker = markers[id];
if (!marker) {
/*** For cluster */
var el = document.createElement('div');
var features = map.queryRenderedFeatures({ layers: ['clusters'] });
if (!features[i]) continue;
var point_count = features[i].properties.point_count_abbreviated;
el.className = 'marker';
el.id = id;
el.style.backgroundImage = 'url("' + Marker + '")';
el.style.width = '41px';
el.style.height = '48px';
el.innerHTML = "<span style='width: 41px;line-height: 42px;text-align:center;display:block'>" + point_count + "</span>"
// console.log("check counts", point_count);
marker = markers[id] = new mapboxgl.Marker({ element: el }).setLngLat(coords);
el.addEventListener('click', () => {
var features = map.queryRenderedFeatures({ layers: ['clusters'] });
if (!features[0]) return;
var clusterId = features[0].properties.cluster_id;
map.getSource('hotels').getClusterExpansionZoom(clusterId, function (err, zoom) {
if (err)
return;
// map.easeTo({
// center: coordinates[el.id],
// zoom: zoom
// });
map.flyTo({
center: coordinates[el.id],
zoom: zoom,
speed: .75
});
//console.log("check coords", coordinates)
// for every marker we've added previously, remove those that are no longer visible
//map.off("zoom");
});
}
);
}
newMarkers[id] = marker;
if (!markersOnScreen[id])
marker.addTo(map);
}
//console.log("check coords", coordinates)
// for every marker we've added previously, remove those that are no longer visible
for (id in markersOnScreen) {
//console.log("in for loop", newMarkers[id])
if (!newMarkers[id])
markersOnScreen[id].remove();
}
markersOnScreen = newMarkers;
}
// after the GeoJSON data is loaded, update markers on the screen and do so on every map move/moveend
map.on('data', function (e) {
if (e.sourceId !== 'hotels' || !e.isSourceLoaded) return;
map.on('zoom', updateMarkers);
map.on('moveend', updateMarkers);
updateMarkers();
});
map.on('mouseenter', 'clusters', function () {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', function () {
map.getCanvas().style.cursor = '';
});
});`
See those red points in map? I need to change them with HTML div. Any help would be appreciated.
@smitraval I've simplified your code and it now works for custom markers and clusters. And at the end of the updateMarkers function, there's a simple loop that removes unused markers and clusters :)
map.addSource("addresses", {
type: "geojson",
data: geojson, //"https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson",
cluster: true,
clusterMaxZoom: 13, // Max zoom to cluster points on
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
});
map.addLayer({
id: "clusters",
type: "circle",
source: "addresses",
filter: ["has", "point_count"],
paint: {
"circle-radius": 0
}
});
// Store IDs and cluster/marker HTMLElements
const markers = new Map();
function updateMarkers(){
const features = map.querySourceFeatures('addresses');
const keepMarkers = [];
for (let i = 0; i < features.length; i++) {
const coords = features[ i ].geometry.coordinates;
const props = features[ i ].properties;
const featureID = features[ i ].id;
const clusterID = props.cluster_id || null;
if (props.cluster && markers.has('cluster_'+clusterID)) {
//Cluster marker is already on screen
keepMarkers.push('cluster_'+clusterID);
} else if (props.cluster) {
//This feature is clustered, create an icon for it and use props.point_count for its count
var el = document.createElement('div');
el.className = 'mapCluster';
el.style.width = '60px';
el.style.height = '60px';
el.style.textAlign = 'center';
el.style.color = 'white';
el.style.background = '#16d3f9';
el.style.borderRadius = '50%';
el.innerText = props.point_count;
const marker = new mapboxgl.Marker(el).setLngLat(coords);
marker.addTo(map);
keepMarkers.push('cluster_'+featureID);
markers.set('cluster_'+clusterID,el);
} else if (markers.has(featureID)) {
//Feature marker is already on screen
keepMarkers.push(featureID);
} else {
//Feature is not clustered and has not been created, create an icon for it
const el = new Image();
el.style.backgroundImage = 'url(https://placekitten.com/g/50/50)';
el.className = 'mapMarker';
el.style.width = '50px';
el.style.height = '50px';
el.style.borderRadius = '50%';
el.dataset.type = props.type;
const marker = new mapboxgl.Marker(el).setLngLat(coords);
marker.addTo(map);
keepMarkers.push(featureID);
markers.set(featureID,el);
}
}
//Let's clean-up any old markers. Loop through all markers
markers.forEach((value,key,map) => {
//If marker exists but is not in the keep array
if (keepMarkers.indexOf(key) === -1) {
console.log('deleting key: '+key);
//Remove it from the page
value.remove();
//Remove it from markers map
map.delete(key);
}
});
};`
map.on('data', function (e) {
if (e.sourceId !== 'addresses' || !e.isSourceLoaded) return;
map.on('moveend', updateMarkers); // moveend also fires on zoomend
updateMarkers();
});
Console output showing markers and clusters getting removed. They are removed when they go off screen, but also when they switch from a marker to a cluster and vice versa.
I have just read this thread and if I understand it correctly it is simply not possible to easily create a cluster of HTML markers. You either need to add them as a GeoJSON layer and render them using images. Or you can add them to a map directly as markers but you need to manage the hiding logic manually. Am I right or am I missing something?
I create my markers purely by using HTML and CSS. I want to add them to a cluster which would deal with the hiding logic and show the number of hidden markers (just like Leaflet plugins do it) without me having to implement anything. Is it possible or not?
@NeonCreativeStudios, thanks for your efforts! While running your example code, I am seeing the clustering work beautifully, but the max zoom seems to be ignored, and it is not unclustering into individual markers when zooming far enough.
@mvtenney I am not sure if you encountered the same problem as me but unclustering was very unreliable in my application so I had to add the following line to onMoveEnd
callback:
map.once('idle', () => updateMarkers());
The only problem is that it waits not only for the zooming animation to end but also for map tiles loading so markers will appear after some time. However, AFAIK there is no animationend
event in Mapbox GL JS and so I find this to be the only reliable solution.
Hello, first of all, I thank you for your work to improve mapbox-gl-js.
I have been using it for a few months now and I am very satisfied with it, both in terms of performance and ease of use. But for almost 2 weeks now, I’ve been dealing with a problem concerning the HTML clustering. I think I've identified where the problem comes from, from which function but I don't know how to solve it, so I'm calling you. Below, the result and the function that, for me, is responsible for this rendering.
My data can change with different filters, the state of the data but also a date. When I change the date, some clusters present at the previous date remain on the map, they are not deleted as they should be in the function. And strangely, the ones that remain seem to prevent some new clusters from appearing.
I thank you in advance for the time you will take to answer me.
const markers: any = {};
let markersOnScreen: any = {};
function clusterConstructor(features: any, map: any) {
const newMarkers: any = {};
for (const feature of features) {
const coords = feature.geometry.coordinates;
const props = feature.properties;
if (!props.cluster) continue;
const id: number = props.cluster_id;
let marker = markers[id];
if (!marker) {
const el = createDonutChart(props);
marker = markers[id] = new mapboxgl.Marker({ element: el }).setLngLat(coords);
}
newMarkers[id] = marker;
if (!markersOnScreen[id]) marker.addTo(map);
}
for (const id in markersOnScreen) {
if (!newMarkers[id]) markersOnScreen[id].remove();
}
markersOnScreen = newMarkers;
}
@Bjmn7 the issue should come from here:
let marker = markers[id];
if (!marker) {
const el = createDonutChart(props);
marker = markers[id] = new mapboxgl.Marker({ element: el }).setLngLat(coords);
}
newMarkers[id] = marker;
if (!markersOnScreen[id]) marker.addTo(map);
in above code, if the marker already in here, you don't update with the new content (data for another day). that why it's not get update. you should always re-create/update the content of the marker (donut chart) when the date changed:
let marker = markers[id];
if (marker) {
marker.remove()
}
const el = createDonutChart(props);
marker = markers[id] = new mapboxgl.Marker({ element: el }).setLngLat(coords);
marker.addTo(map);
newMarkers[id] = marker;
@thuanmb Thank you very much, it works perfectly, great day to you.
Hello, after multiple tests, it seems this solution slows down the map a lot when the function is fired by an idle event. If it is fired by a render event, it is even slower and the clusters move to the corner of the map at each render. Do you have any idea what could cause this @thuanmb ?
@Bjmn7 you can use some check to compare the props before re-rendering the clusters/markers. if the data (used to render the donut chart) has changed, we will re-render the chart. Otherwise keep the chart as is (by re-using previous mapboxgl.Marker
instance) or remove it from the map (using map.removeLayer
function)
@smitraval Uprościłem twój kod i teraz działa on dla niestandardowych znaczników i klastrów. A na końcu funkcji updateMarkers znajduje się prosta pętla, która usuwa nieużywane znaczniki i klastry :)
map.addSource("addresses", { type: "geojson", data: geojson, //"https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson", cluster: true, clusterMaxZoom: 13, // Max zoom to cluster points on clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50) }); map.addLayer({ id: "clusters", type: "circle", source: "addresses", filter: ["has", "point_count"], paint: { "circle-radius": 0 } }); // Store IDs and cluster/marker HTMLElements const markers = new Map(); function updateMarkers(){ const features = map.querySourceFeatures('addresses'); const keepMarkers = []; for (let i = 0; i < features.length; i++) { const coords = features[ i ].geometry.coordinates; const props = features[ i ].properties; const featureID = features[ i ].id; const clusterID = props.cluster_id || null; if (props.cluster && markers.has('cluster_'+clusterID)) { //Cluster marker is already on screen keepMarkers.push('cluster_'+clusterID); } else if (props.cluster) { //This feature is clustered, create an icon for it and use props.point_count for its count var el = document.createElement('div'); el.className = 'mapCluster'; el.style.width = '60px'; el.style.height = '60px'; el.style.textAlign = 'center'; el.style.color = 'white'; el.style.background = '#16d3f9'; el.style.borderRadius = '50%'; el.innerText = props.point_count; const marker = new mapboxgl.Marker(el).setLngLat(coords); marker.addTo(map); keepMarkers.push('cluster_'+featureID); markers.set('cluster_'+clusterID,el); } else if (markers.has(featureID)) { //Feature marker is already on screen keepMarkers.push(featureID); } else { //Feature is not clustered and has not been created, create an icon for it const el = new Image(); el.style.backgroundImage = 'url(https://placekitten.com/g/50/50)'; el.className = 'mapMarker'; el.style.width = '50px'; el.style.height = '50px'; el.style.borderRadius = '50%'; el.dataset.type = props.type; const marker = new mapboxgl.Marker(el).setLngLat(coords); marker.addTo(map); keepMarkers.push(featureID); markers.set(featureID,el); } } //Let's clean-up any old markers. Loop through all markers markers.forEach((value,key,map) => { //If marker exists but is not in the keep array if (keepMarkers.indexOf(key) === -1) { console.log('deleting key: '+key); //Remove it from the page value.remove(); //Remove it from markers map map.delete(key); } }); };` map.on('data', function (e) { if (e.sourceId !== 'addresses' || !e.isSourceLoaded) return; map.on('moveend', updateMarkers); // moveend also fires on zoomend updateMarkers(); });
Dane wyjściowe konsoli pokazujące usuwanie znaczników i klastrów. Są usuwane, gdy znikają z ekranu, ale także wtedy, gdy przechodzą ze znacznika do grupy i odwrotnie.
@NeonCreativeStudios @Bjmn7
i have a similar problem, when zooming the cluster, the markers are not visible. I tried to solve this problem but I couldn't. Can anyone help
Hello! As we can see here https://www.mapbox.com/mapbox-gl-js/example/custom-marker-icons/ we could create a custom Marker and add it to map. e.g. new mapboxgl.Marker(...).setLngLat(...).addTo(map);
I'm trying to find the way how to add marker to the layer and organize into the Cluster. Smth like https://www.mapbox.com/mapbox-gl-js/example/cluster/ but with custom Markers: