mapbox / mapbox-gl-js

Interactive, thoroughly customizable maps in the browser, powered by vector tiles and WebGL
https://docs.mapbox.com/mapbox-gl-js/
Other
11.16k stars 2.21k forks source link

Support clustering for HTML "Marker"s #4491

Closed D1M closed 5 years ago

D1M commented 7 years ago

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:

  1. var map = new mapboxgl.Map...
  2. map.addSource(...);
  3. map.addLayer(...);
  4. and here map.getLayer(...).addMarker(Marker). or marker.addto(Layer/Source) or ... As a result we should see a Cluster with custom markers.
mourner commented 5 years ago

I have an idea — how about implementing this externally with existing tools like this:

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?

Wykks commented 5 years ago

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.

mourner commented 5 years ago

@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.

Wykks commented 5 years ago

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:

clementmas commented 5 years ago

@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 👍

ryanhamley commented 5 years ago

@malwoodsantoro @mzdraper this could be a useful example to add to our docs

eduboxgithub commented 5 years ago

Any news on this feature?

arindammatrix commented 5 years ago

@mourner @Wykks Did you guys achieved the expected result? If yes, can you please do a jsfiddle for the help of the community.

rares-lupascu commented 5 years ago

+1

clementmas commented 5 years ago

Is anyone working on this? I don't know if I should keep waiting for this or look for another solution. Thanks!

rbrundritt commented 5 years ago

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.

matyushen commented 5 years ago

@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.

rbrundritt commented 5 years ago

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:

matyushen commented 5 years ago

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.

rbrundritt commented 5 years ago

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.

matyushen commented 5 years ago

@rbrundritt Thank you for all the info! It would be really cool if mapbox-gl supported clustering with html markers out of the box.

miloshevmitko commented 5 years ago

+1 I believe this feature (clustering of custom html markers) is long overdue.

matthiasott commented 5 years ago

+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.

rbrundritt commented 5 years ago

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.

jonaseberle commented 5 years ago

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)
            }
        )

grafik

Dear Mapbox, I suggest you add an example showcasing that.

rbrundritt commented 5 years ago

@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.

image

jonaseberle commented 5 years ago

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.

mourner commented 5 years ago

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!

mourner commented 5 years ago

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/

deathlock commented 5 years ago

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?

andrewharvey commented 5 years ago

@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
    });
  });
});
deathlock commented 5 years ago

@andrewharvey Thank you very much for your quick reply. I will try the same and update the final result here for future reference.

deathlock commented 5 years ago

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.

hotels-map

NeonCreativeStudios commented 5 years ago

@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 :)

image

    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. image

livthomas commented 5 years ago

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?

mvtenney commented 5 years ago

@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.

livthomas commented 5 years ago

@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.

Bjmn7 commented 2 years ago

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;
   }

Capture d’écran 2022-02-23 093724

thuanmb commented 2 years ago

@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;
Bjmn7 commented 2 years ago

@thuanmb Thank you very much, it works perfectly, great day to you.

Bjmn7 commented 2 years ago

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 ?

thuanmb commented 2 years ago

@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)

OlejarzRafal commented 1 year ago

@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 :)

obraz

    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. obraz

@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