Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrating Mapbox's Supercluster with react-map-gl #507

Closed
winston-bosan opened this issue Apr 26, 2018 · 33 comments
Closed

Integrating Mapbox's Supercluster with react-map-gl #507

winston-bosan opened this issue Apr 26, 2018 · 33 comments

Comments

@winston-bosan
Copy link

Hello! As my title suggests, since mapbox-gl already supports supercluster internally with newly updated spiderfying and other cool jazz, I am wondering if there is a best practice or if anyone has had the experience implementing a clustering layer within react-map-gl wrapper. Or something similar to this?

@jamalx31
Copy link

jamalx31 commented Aug 8, 2018

@winston-bosan if still relevant I manage to do it. Maybe will send a PR here

@mlg87
Copy link

mlg87 commented Aug 13, 2018

@jamalx31 how did you go about implementing it?

@tstirrat15
Copy link
Contributor

@jamalx31 I'd also be curious. This seems like it would be something nice to have in this library. If you're not keen on PRing, can you at least share the implementation so that someone else could go about PRing it?

@jamalx31
Copy link

jamalx31 commented Sep 23, 2018

@mlg87 @tstirrat15 I will try to raise a PR this week if not I will share a code snippet maybe one of you could

@jamalx31
Copy link

Sorry a bit busy and will take me longer but her is the code for the Cluster component and how to use it:

import supercluster from 'supercluster';
import { point } from '@turf/helpers';
import { Children, PureComponent, createElement } from 'react';
import PropTypes from 'prop-types';
// import type { Node, Component } from 'react';

import { Marker } from 'react-map-gl';

const childrenKeys = children =>
  Children.toArray(children).map(child => child.key);

const shallowCompareChildren = (prevChildren, newChildren) => {
  if (Children.count(prevChildren) !== Children.count(newChildren)) {
    return false;
  }

  const prevKeys = childrenKeys(prevChildren);
  const newKeys = new Set(childrenKeys(newChildren));
  // console.log(prevKeys.length === newKeys.size && prevKeys.every(key => newKeys.has(key)), {prevChildren, newChildren})
  return (
    prevKeys.length === newKeys.size && prevKeys.every(key => newKeys.has(key))
  );
};

class Cluster extends PureComponent {
  static displayName = 'Cluster';

  static defaultProps = {
    minZoom: 0,
    maxZoom: 16,
    radius: 40,
    extent: 512,
    nodeSize: 64,
  };

  constructor(props) {
    super(props);

    this.state = {
      clusters: [],
    };
  }

  componentDidMount() {
    this.createCluster(this.props);
    this.recalculate();

    this.props.map.on('moveend', this.recalculate);
  }

  componentWillReceiveProps(newProps) {
    const shouldUpdate =
      newProps.minZoom !== this.props.minZoom ||
      newProps.maxZoom !== this.props.maxZoom ||
      newProps.radius !== this.props.radius ||
      newProps.extent !== this.props.extent ||
      newProps.nodeSize !== this.props.nodeSize ||
      !shallowCompareChildren(this.props.children, newProps.children);

    if (shouldUpdate) {
      this.createCluster(newProps);
      this.recalculate();
    }
  }

  createCluster = props => {
    const {
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize,
      children,
      innerRef,
    } = props;

    const cluster = supercluster({
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize,
    });

    const points = Children.map(children, child => {
      if (child)
        return point([child.props.longitude, child.props.latitude], child);
      return null;
    });

    cluster.load(points);
    this.cluster = cluster;
    if (innerRef) innerRef(this.cluster);
  };

  recalculate = () => {
    const zoom = this.props.map.getZoom();
    const bounds = this.props.map.getBounds().toArray();
    const bbox = bounds[0].concat(bounds[1]);

    const clusters = this.cluster.getClusters(bbox, Math.floor(zoom));
    this.setState(() => ({ clusters }));
  };

  render() {
    const clusters = this.state.clusters.map(cluster => {
      if (cluster.properties.cluster) {
        const [longitude, latitude] = cluster.geometry.coordinates;
        return createElement(Marker, {
          longitude,
          latitude,
          // TODO size
          offsetLeft: -28 / 2,
          offsetTop: -28,
          children: createElement(this.props.element, {
            cluster,
            superCluster: this.cluster,
          }),
          key: `cluster-${cluster.properties.cluster_id}`,
        });
      }
      const { type, key, props } = cluster.properties;
      return createElement(type, { key, ...props });
    });
    return clusters;
  }
}

Cluster.propTypes = {
  /** Mapbox map object */
  map: PropTypes.object,

  /** Minimum zoom level at which clusters are generated */
  minZoom: PropTypes.number,

  /** Maximum zoom level at which clusters are generated */
  maxZoom: PropTypes.number,

  /** Cluster radius, in pixels */
  radius: PropTypes.number,

  /** (Tiles) Tile extent. Radius is calculated relative to this value */
  extent: PropTypes.number,

  /** Size of the KD-tree leaf node. Affects performance */
  nodeSize: PropTypes.number,

  /** ReactDOM element to use as a marker */
  element: PropTypes.func,

  /**
   * Callback that is called with the supercluster instance as an argument
   * after componentDidMount
   */
  /* eslint-disable react/no-unused-prop-types */
  innerRef: PropTypes.func,
  /* eslint-enable react/no-unused-prop-types */

  /** Markers as children */
  children: PropTypes.node,
};

export default Cluster;

and use it like this:

      <ReactMapGL
        {...viewport}
        // eslint-disable-next-line no-return-assign
        ref={ref => (this.mapRef = ref)}
        onLoad={() => this.setState({ map: this.mapRef.getMap() })}
        mapStyle="mapbox://styles/mapbox/dark-v9"
        mapboxApiAccessToken={TOKEN}
        onViewportChange={onViewportChange}
      >
        {map && (
          <Cluster
            map={map}
            radius={20}
            extent={512}
            nodeSize={40}
            element={clusterProps => (
              <PinGroup onViewportChange={onViewportChange} {...clusterProps} />
            )}
          >
            {/* every item should has a 
            uniqe key other wise cluster will not rerender on change */}
            {/* points.map((point, i) => (
              <Marker
                key={i}
                longitude={point.longitude}
                latitude={point.latitude}
              >
                <div style={MarkerStyle} />
              </Marker>
            )) */}
          </Cluster>
        )}

        <MapPopup />
   </ReactMapGL>

@tstirrat15
Copy link
Contributor

@jamalx31 this is awesome. Thank you!

@tstirrat15
Copy link
Contributor

Hmm... It's interesting to me that this doesn't use the underlying cluster functionality exposed by mapbox-gl-js. This is putting clusters on top of markers, more or less, where it seems like the more direct way to go would be to add layers via the mapStyle attribute, a la this example, though using something more like the cluster example on the mapbox docs.

@JasonLunsford
Copy link

Has this feature, as described by @tstirrat15 , made it into react-map-gl?

@BenBach
Copy link

BenBach commented Apr 11, 2019

@JasonLunsford Did you find a solution for this? Would appreciate any help

@tomcolaa
Copy link

tomcolaa commented Jul 9, 2019

For everybody getting errors -> supercluster was updated you now have to call new Supercluster instead of supercluster.

import Supercluster from 'supercluster';

const cluster = new Supercluster({
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize,
    });

Also, it might be good for some people to know that PinGroup is a component you have to build and that it will be the replacement for the other pins when the clustering is happening. Just to make this clear as it wasn't clear to me from the beginning.

@Snorlite
Copy link

For everybody getting errors -> supercluster was updated you now have to call new Supercluster instead of supercluster.

import Supercluster from 'supercluster';

const cluster = new Supercluster({
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize,
    });

Also, it might be good for some people to know that PinGroup is a component you have to build and that it will be the replacement for the other pins when the clustering is happening. Just to make this clear as it wasn't clear to me from the beginning.

Hi, thank you: could you please give me an example of a PinGroup component?

@Snorlite
Copy link

Snorlite commented Jul 25, 2019

Using @jamalx31 's code as reference:

  componentDidMount() {
    this.createCluster(this.props);
    this.recalculate();

    this.props.map.on('moveend', this.recalculate);
  }

The this.props.map.on('moveend', this.recalculate); gives me a warning:
Warning: Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state.

What should I change to fix it but having the same result?

@Girgetto
Copy link

Girgetto commented Aug 5, 2019

For everybody getting errors -> supercluster was updated you now have to call new Supercluster instead of supercluster.

import Supercluster from 'supercluster';

const cluster = new Supercluster({
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize,
    });

Also, it might be good for some people to know that PinGroup is a component you have to build and that it will be the replacement for the other pins when the clustering is happening. Just to make this clear as it wasn't clear to me from the beginning.

Hi, thank you: could you please give me an example of a PinGroup component?

import React, { Component } from 'react'

class PinGroups extends Component {
    constructor(props) {
        super(props)
    }
    render() {
        const { cluster } = this.props;
        return(
                <div>
                    {cluster.properties.point_count}
                </div>
        )
    }
}

export default PinGroups;

Super basic PinGroup component with the number of markers in the cluster

@derwaldgeist
Copy link

Is there an official support for clusters now? This is a must-have feature for any map library IMHO.

@reinvanimschoot
Copy link

Any update on this? I was kind of surprised that this isn't officially supported yet...

@arnaud-zg
Copy link

arnaud-zg commented Oct 23, 2019

Should we create a pull request and add the cluster component of @jamalx31 ? 🤔

@derwaldgeist
Copy link

Bump.

I am considering going to raw mapboxgl.js now because of the lack of cluster support. We have a lot of data, spread across the whole world. Not being able to cluster it efficiently, just doesn't make sense in our use-case.

@Pessimistress
Copy link
Collaborator

@howdyhyber
Copy link

@Pessimistress , https://uber.github.io/react-map-gl/#/Examples/clusters -> in this example, it clusters Points layer. How about clustering custom markers? It is possible to execute it using this package?

@NickCarducci
Copy link

@Pessimistress @jamalx31 This isn't working code, just what I spent several hours on. A potentially-workable concept to help react-map-gl provide "cluster" support in your source, unless there is no way to access the marker with DOM... then forget this

@howdyhyber If by custom markers you mean image markers, I have custom markers as well. Since clustering only works on layers in latter example (and the former example in this thread uses mapbox-js-gl) I tried to use refs & also tried document.getElementByID, to see if markers overlap, then hide all overlaps or re-render when viewport changes from last this.setState({lastViewport: this.state.viewport}) (probably not very performant but didn't get it working anyway). It seems we don't have DOM access to the Markers once rendered to do so.

Here is inside componentDidUpdate

if (
  this.state.mounted &&
  this.props.data &&
  this.state.lastViewport !== this.state.viewport
) {
  this.setState({ lastViewport: this.state.viewport });
  this.props.data.map(d => {
    console.log("level1");
    // Div 1 data
    var d1_offset = document.getElementById("x" + d.id).offset(); //fails here
    var d1_height = document.getElementById("x" + d.id).outerHeight(true);
    var d1_width = document.getElementById("x" + d.id).outerWidth(true);
    var d1_distance_from_top = d1_offset.top + d1_height;
    var d1_distance_from_left = d1_offset.left + d1_width;
    this.props.data.map(g => {
      console.log("level2");
      // Div 2 data
      var d2_offset = document.getElementById("x" + g.id).offset();
      var d2_height = document.getElementById("x" + g.id).outerHeight(true);
      var d2_width = document.getElementById("x" + g.id).outerWidth(true);
      var d2_distance_from_top = d2_offset.top + d2_height;
      var d2_distance_from_left = d2_offset.left + d2_width;

      var not_colliding =
        d1_distance_from_top < d2_offset.top ||
        d1_offset.top > d2_distance_from_top ||
        d1_distance_from_left < d2_offset.left ||
        d1_offset.left > d2_distance_from_left;
      console.log("ran here");
      // Return whether it IS colliding
      let x = 0;
      if (!not_colliding) {
        x++;
      }
      if (x > 1) {
        console.log("overlapping");

        ReactDOM.findDOMNode(g).style.display = "none";
        d.next.addEventListener(
          "click",
          () => {
            this.props.zoomIn(); //This re-renders my query && zooms in by 3...
          },
          false
        );
      }
    })
  })
    }
  }
}

Please let me know if I'm wrong and it is possible to use refs or getElementById on markers, but I just read a similar question of Leaflet which say it is NOT possible for them

@jamalx31
Copy link

@NickCarducci sorry I didn't work on this project for over 2 years. I'm surprised this issue is still open.
in the next 2 days I will create a repo with a working example how to use supercluster with custom markers. and if anyone wants to help we can create a PR to be merged here

@NickCarducci
Copy link

NickCarducci commented Feb 21, 2020

@jamalx31 happy to help apply it if you give hint to my example. quickly to start

<ReactMapGL>
  data.map(x => {
    <React.Component from BaseControl //shorthand:import as its own component
      latitude={Number(x.location[0])}
      longitude={Number(x.location[1])}
    >
      render(){return(<div><img/></div>)}
    </React.BaseControl>
  })
<ReactMapGL>

@jamalx31
Copy link

@NickCarducci, everyone. here's a working example of the supercluster with custom markers for leaves.
to run the example:

  • set your mapbox token in Map.js
  • run yarn && yarn start

https://github.com/jamalx31/mapbox-supercluster-example
Please check it out and then we can discuss a PR
cheers

@NickCarducci
Copy link

NickCarducci commented Feb 22, 2020

@jamalx31 in Group component's props I had an extra point_count_abbreviated so I
const count = this.props.cluster.properties.point_count_abbreviated - 1;
here is result, is this expected?
Screen Shot 2020-02-22 at 1 05 42 PM

If so, can I show image/pin then (count === 1), by editing Cluster component? wavepoint.la

@jamalx31
Copy link

@NickCarducci I'm not sure I understand your question. can you explain a bit more what you need to do and what is the issue

@NickCarducci
Copy link

@jamalx31 the image/pin should show when there is only one image/pin. this is caused by either one of two problems: (1) , as you see on the bottom right the lone event still has the Group/Cluster Component instead of the image.. I will look into these supercluster settings for my first time too, at first glance it looks like the following in Cluster.js is how to change sensitivity to rendering a Group/Cluster instead of an image/pin.

  static defaultProps = {
    minZoom: 0,
    maxZoom: 16,
    radius: 70,
    extent: 512,
    nodeSize: 64
  };

(2) point_count_abbreviated when counting correctly (I am sure in my data only one event is at that location at the bottom-right) and I dont have to point_count_abbreviated - 1 makes (1) a non-issue

@jamalx31
Copy link

jamalx31 commented Feb 23, 2020

@NickCarducci the supercluster uses those values to decide when to merge leaves into a group (numbers in your case) and when to show individual leaves (photo in your case). but it should never create a group of one individual leave. I think the issue is in the 2nd point you mentioned. Im still not sure why you do point_count_abbreviated - 1? did you see the the Group component in my example here

@NickCarducci
Copy link

@jamalx31 I have two datasets inside Group, data1.map=>x.React.BaseControl data2.map=>x.React.BaseControl

React sometimes renders multiple times onMount (though each item has their own key >:/). Maybe reactMapGL catches it (more than once). I don't think I'll have my solution soon, but my users can get by with a nice onClick on the marker to zoom into the maximum to-cluster that is allowed by aforementioned "those values". I'll reply or edit this comment with anything conclusive but as of now consider this solved in my book... thank you gj

@gajus
Copy link

gajus commented Mar 4, 2020

What is the difference between supercluster and https://uber.github.io/react-map-gl/examples/clusters ?

@jamalx31
Copy link

jamalx31 commented Mar 9, 2020

@gajus depends on your use case. The Cluster API seems to be so limited, e.g you can't build your custom markers. With supercluster you have more flexibility

@derwaldgeist
Copy link

derwaldgeist commented Mar 31, 2020

I have switched to https://github.com/urbica/react-map-gl now because of this limitation. The Urbica variant has a very similar API, provides a cluster integration that works nicely and thus allowed me to boost the performance of my map drastically. Eventually, I will need server-side clustering, too, but for now it serves my purpose.

@gajus
Copy link

gajus commented Mar 31, 2020

I have switched to https://github.com/urbica/react-map-gl now because of this limitation. The Urbica variant has a very similar API, provides a cluster integration that works nicely and thus allowed me to boost the performance of my map drastically. Eventually, I will need server-side clustering, too, but for now it serves my purpose.

I have done the same.

@NickCarducci
Copy link

NickCarducci commented Jan 12, 2021

After some more time with react, today I was able to get same functionality as @jamalx31 using his class but with static getDerivedStateFromProps, componentDidUpdate instead of UNSAFE_componentWillReceiveProps and createCluster and recalculate inside render and no state change in render warnings

import Supercluster from "supercluster";
import { point } from "@turf/helpers";
import { Children, PureComponent, createElement } from "react";
import PropTypes from "prop-types";
// import type { Node, Component } from 'react';

import { Marker } from "react-map-gl";

class MappCluster extends PureComponent {
  static displayName = "MappCluster";

  constructor(props) {
    super(props);

    this.state = {
      minZoom: 0,
      maxZoom: 20,
      radius: 20,
      extent: 512,
      nodeSize: 10,
      clusters: [],
      children: props.children
    };
  }
  componentDidUpdate = (prevProps) => {
    if (prevProps.children !== this.props.children) {
      this.setState({
        children: this.props.children
      });
    }
  };
  static getDerivedStateFromProps(nextProps, prevState) {
    const childrenKeys = (children) =>
      Children.toArray(children).map((child) => child.key);
    const prevKeys = childrenKeys(prevState.children);
    const newKeys = new Set(childrenKeys(nextProps.children));
    if (
      Children.count(nextProps.children) !==
        Children.count(prevState.children) ||
      prevKeys.length !== newKeys.size ||
      !prevKeys.every((key) => newKeys.has(key))
    ) {
      return { children: nextProps.children };
    } else return null;
  }
  render() {
    const { children } = this.state;
    const points = Children.map(
      children,
      (child) =>
        child && point([child.props.longitude, child.props.latitude], child)
    );
    const { minZoom, maxZoom, radius, extent, nodeSize } = this.state;
    this._cluster = new Supercluster({
      minZoom,
      maxZoom,
      radius,
      extent,
      nodeSize
    }).load(points);
    if (this.props.innerRef) {
      this.props.innerRef(this._cluster);
    }
    const zoom = this.props.map.getZoom();
    const bounds = this.props.map.getBounds().toArray();
    const bbox = bounds[0].concat(bounds[1]);
    return this._cluster.getClusters(bbox, Math.floor(zoom)).map((cluster) => {
      const [longitude, latitude] = cluster.geometry.coordinates;
      if (cluster.properties.cluster) {
        return createElement(Marker, {
          longitude,
          latitude,
          // TODO size
          offsetLeft: -28 / 2,
          offsetTop: -28,
          children: createElement(this.props.element, {
            cluster,
            superCluster: this._cluster
          }),
          key: `cluster-${cluster.properties.cluster_id}`
        });
      }
      const { type, key, props } = cluster.properties;
      return createElement(type, { key, ...props });
    });
  }
}

MappCluster.propTypes = {
  /** Mapbox map object */
  map: PropTypes.object,

  /** Minimum zoom level at which clusters are generated */
  minZoom: PropTypes.number,

  /** Maximum zoom level at which clusters are generated */
  maxZoom: PropTypes.number,

  /** MappCluster radius, in pixels */
  radius: PropTypes.number,

  /** (Tiles) Tile extent. Radius is calculated relative to this value */
  extent: PropTypes.number,

  /** Size of the KD-tree leaf node. Affects performance */
  nodeSize: PropTypes.number,

  /** ReactDOM element to use as a marker */
  element: PropTypes.func,

  /**
   * Callback that is called with the supercluster instance as an argument
   * after componentDidMount
   */
  /* eslint-disable react/no-unused-prop-types */
  innerRef: PropTypes.func,
  /* eslint-enable react/no-unused-prop-types */

  /** Markers as children */
  children: PropTypes.node
};

export default MappCluster;

Thanks so much! p.s. I have it working without the urbica package, also please notice the only props triggering a change for this component was children; their keys, element and innerRef named ref from popper.js are still passed as props. So I put minZoom, maxZoom, radius, extent, nodeSize in state of this child

@visgl visgl locked and limited conversation to collaborators Feb 27, 2021

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests