Skip to content
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

Is there a way to overlay symbols on top of each other when collisions occur? #10002

Closed
windwardapps opened this issue Sep 24, 2020 · 10 comments
Closed

Comments

@windwardapps
Copy link

mapbox-gl-js version: 1.12.0

Question

In a symbol layer, when there's an icon/label collision, and when allowing overlaps, how can I get one icon/label to overlay "on top" of another?

In this screenshot you can see that instead of overlaying on top of each other (like a deck of cards IRL), all the collisions sort of blend together illegibly. How can I fix this?

Screen Shot 2020-09-24 at 7 29 28 AM

Links to related documentation

I read through https://docs.mapbox.com/help/troubleshooting/optimize-map-label-placement/. All the permutations of those 4 options don't help. Currently I have them all turned on:

layout: {
  'text-allow-overlap': true,
  'text-ignore-placement': true,
  'icon-allow-overlap': true,
  'icon-ignore-placement': true,
}

I was hoping to find some sort of { 'stack-collisions': true } layout/paint option in https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#symbol, but no luck.

Any ideas? Thanks!

@joedjc
Copy link

joedjc commented Sep 28, 2020

I'd be interested in this functionality too.

Ideally they could stack horizontally as well as vertically, or some combination of the two (perhaps in a grid).

Another use case might be where two stations are near each other but have different icons (e.g. subway and railway). In mapbox there are often single icons which combine the two, but it would be nice if these icons could automatically align next to each other if they overlap, with both icons centred around a point e.g.:

Screenshot 2020-09-28 at 16 31 50

To achieve this effect currently - we have to put the map into Illustrator to get it to look nice.

I suppose it's like clustering of points (which is possible) but with icons and allowing them all to be seen

@stevage
Copy link
Contributor

stevage commented Oct 10, 2020

@windwardapps What is the full style definition you're using for your labels? Is that one layer or two? An icon for the dark background, or a halo?

@windwardapps
Copy link
Author

Hi @stevage thank you for responding. It's one layer – some text styling and some icon styling. Here's the full definition:

{
  id: 'listing-price',
  type: 'symbol',
  source: 'listings',
  filter: ['!', ['has', 'point_count']],
  minzoom: 10,
  layout: {
    'text-field': ['get', 'label'],
    'text-font': ['Open Sans Bold'],
    'text-size': 12,
    'text-allow-overlap': true,
    'text-ignore-placement': true,
    'icon-allow-overlap': true,
    'icon-ignore-placement': true,
    'icon-image': ['get', 'marker']
  },
  paint: {
    'text-color': ['get', 'color'],
    'text-halo-color': ['get', 'backgroundColor']
  }
}

@fpassaniti
Copy link

fpassaniti commented Nov 17, 2020

+1
I don't find any function in mapbox-gl API to deal with symbol collision manually and set custom offsets when needed.

In my case, multiple POI (stores) with the same address
image

map.loadImage('/static/img/' + symbol + '.png', (error, image) => {
                    if (error) throw error;
                    map.addImage(symbol, image);
                    map.addLayer({
                        'id': layerID,
                        'type': 'symbol',
                        'cluster': false,
                        'source': 'data',
                        'layout': {
                            'icon-image': symbol,
                            'icon-size': 0.45,
                            'icon-allow-overlap': true,
                            'text-allow-overlap': true,
                            'text-variable-anchor': ["top", "bottom", "left"],
                            "text-field": "{name}",
                            "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
                            "text-size": 11,
                            "text-offset": [0, 1.2],
                            "text-anchor": "top"
                        },
                        'filter': ['==', 'type', symbol]
                    });
                    layers.push(layerID);
                });

If I set the text or the icon overlap to false, one of the POI will never be visible

@tsuz
Copy link
Contributor

tsuz commented Nov 16, 2021

If a developer has access to the original data (e.g. publishes via MTS), then the data points can be moved or the *-offset can be applied knowing two points are very close or on top of each other. If the developer doesn't have access data, for example, Mapbox Streets V8's poi-label, then there are POIs in a dense area that appears on the same lat/lng . For example,

z/lat/lng
20.91/35.7300383/139.7102539

Screen Shot 2021-11-15 at 15 27 23
Screen Shot 2021-11-15 at 15 27 19
Screen Shot 2021-11-15 at 15 27 15

In this case, there is no way to show multiple icons simultaneously without the icons overlapping on top of each other.

@entioentio
Copy link

Well there is a way. One can cluster the markers and delegate rendering the actual markers to the cluster itself. Using supercluster it's easily doable for the op's original question.

@tomskopek
Copy link

@entioentio could you please elaborate on the cluster/supercluster solution, or point to some relevant documentation? 🙏

@5andr0
Copy link

5andr0 commented Jun 13, 2024

@entioentio could you please elaborate on the cluster/supercluster solution, or point to some relevant documentation? 🙏

I needed this for a little hobby project. You can check out the full code here: https://github.com/5andr0/WildOceanGuide with a live version here: https://wildocean.guide/

Here are some code excerpts:

I wrote a simple algorithm to cluster up to 7 markers around a circle and center them

class clusterOffsets {
  static staticConstructor = (() => {
    clusterOffsets.scales = [];
    clusterOffsets.offsets = [];
    clusterOffsets.centers = [];

    for(let i = 0; i < 7; i++) {
      // Math.PI / 3 means 3 circles per semicircle
      // so we can display a total of 6 markers in a full circle + 1 in the center
      clusterOffsets.offsets[i] = {
        x: (i) ? Math.cos(i * Math.PI / 3) : 0,
        y: (i) ? Math.sin(i * Math.PI / 3) : 0
      };
      const x = clusterOffsets.offsets.map(pt => pt.x);
      const y = clusterOffsets.offsets.map(pt => pt.y);
      const max = {x: Math.max(...x), y: Math.max(...y)};
      const min = {x: Math.min(...x), y: Math.min(...y)};
      // scale = 1 / (max distance + size of 1 icon)
      clusterOffsets.scales[i] = 1 / (1 + Math.max(
        max.x - min.x,
        max.y - min.y,
      ));
      clusterOffsets.centers[i] = {
        x: (min.x + max.x) / 2,
        y: (min.y + max.y) / 2
      };
    }
  })()

  constructor(radius, count, scale = 1, shrinkFactor = 0) {
    this.radius = radius;
    this.count = count-1; // between 1 and 7
    this.scaleFactor = scale * (1 - (1 - clusterOffsets.scales[this.count]) * shrinkFactor);
  }

  get(index) {
    return [
      this.scaleFactor * this.radius * (clusterOffsets.offsets[index].x - clusterOffsets.centers[this.count].x),
      this.scaleFactor * this.radius * (clusterOffsets.offsets[index].y - clusterOffsets.centers[this.count].y)
    ];
  }

  scale = () => this.scaleFactor;
}

If you have different sized markers and want to display more than 7, you can use https://d3js.org/d3-force/center to simulate centering the markers and then retrieve their offsets. This works with svgs only, but you can also create svg collision boxes based on your icons dimensions. d3-force only works on dom svg elements. So you would have to add the icons to a cluster canvas with the size relative of your cluster radius or you just do the calculations on a fake dom like jsdom and use the resulting offsets to individually place the markers on the map. For a static calculation you have to call .stop() right away after creating the forceSimulation and then just simulate n amount of ticks at once with simulation.tick(n)

If anyone builds this with d3-force, please post it! I didn't have the time for it
Here's a sample on how the nytimes used d3-force: nytimes.com/convention-word-counts

You have to enable clustering in your source:

map.addSource(sourceId, {
      'type': 'geojson',
      'data': places,
      'cluster': true,
      'clusterRadius': options.map.clusterRadius,
    });
    const source = map.getSource(sourceId);

This is the render loop where you query visible source features to find out which markers got clustered. Then you combine, exclude and shrink them based on your cluster radius and re-add them to the map.

const markers = {};
let markersOnScreen = {};

async function updateMarkers() {
  const newMarkers = {};
  const features = map.querySourceFeatures(sourceId);
  
  function getClusterLeaves(cluster_id, limit, offset) {
    return new Promise((resolve, reject) => {
      source.getClusterLeaves(cluster_id, limit, offset, (err, features) => {
        // when source filter is updated, old clusters remain for 1 render cycle and throw errors that we have to ignore
        if (err) resolve([]);
        else resolve(features);
      });
    });
  };

  function addMarker(props, coords, scale = 1, offset = [0, 0]) {
    const id = props.id;
    let marker = markers[id];
    if (!marker) {
      const el = new Image();
      el.dataset.season = props.season; // you can use custom data if you have assigned it in the GeoJSON data
      el.dataset.species = id;
      applySeasonStyle(el, props.season);
      const imgPromise = new Promise(resolve => {
        el.onload = el.onerror = resolve;
        el.src = `./assets/${props.speciesId}.${options.icons.extension}`;
      });

      // mapbox keeps overriding opacity, so we have to put the img in a div to modify opacity
      const div = document.createElement('div');
      div.appendChild(el);

      marker = markers[id] = new mapboxgl.Marker({
        element: div
      });
      imgPromise.then(e => marker.setPopup(createPopup(props, (e.type == 'error') ? null : e.target)));
       // add popup
    }

    const el = marker
      .setLngLat(coords)
      .setOffset(offset)
      .getElement().firstChild;
    el.style.width = el.style.height = `${scale * options.icons.map.size}px`;
    applySeasonStyle(el, props.season);

    if (!markersOnScreen[props.id]) {
      marker.addTo(map);
    }

    return (newMarkers[id] = marker);
  }

  // min zoom can be negative
  const zoomFactor = (map.getZoom() - map.getMinZoom()) / (map.getMaxZoom() - map.getMinZoom());
  const zoomScale = options.scaling.interpolateFunction(zoomFactor);
  const mapScale = options.scaling.zoomFactor + (1-options.scaling.zoomFactor)*zoomScale;
  // clusterOffsets scales all cluster symbols to fit inside 
  // we want to negate this effect when zoomed in
  const shrinkFactor = options.scaling.shrinkFactor * (1 - mapScale);
  
  // 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 (const feature of features) {
    const coords = feature.geometry.coordinates;
    const props = feature.properties;
    if (!props.cluster) {
      // reset marker offset and scale if it was in a cluster before
      addMarker(props, coords);
      continue;
    }
    // only query max 7 features
    const clusterFeatures = await getClusterLeaves(props.cluster_id, 7, 0);
    if(!clusterFeatures) continue;
    const offsets = new clusterOffsets(options.icons.map.size + options.icons.map.padding, clusterFeatures.length, mapScale, shrinkFactor);
    for(const [i, {properties}] of clusterFeatures.entries()) {
      const off = offsets.get(i);
      addMarker(properties, coords, offsets.scale(), offsets.get(i));
    }
  }
  // for every marker we've added previously, remove those that are no longer visible
  for (const id in markersOnScreen) {
    if (!newMarkers[id]) {
      markersOnScreen[id].remove();
    }
  }
  markersOnScreen = newMarkers;
}

map.on('render', () => {
  updateMarkers();
});

The render loop will not wait for getClusterLeaves' callback function. So you have to make sure to wait for it!
Clustering only works on querySourceFeatures which will ignore all layer filters.
If you want to filter things, you will have to build source filters. There's no source.setFilter function yet, but it's possible to use the internal _updateWorkerData function to update source filters.
When updating source filters, old clusters remain for 1 render cycle. This will throw errors when querying them via getClusterLeaves, which you will have to ignore.
Note: If you want to work with your features properties, beware that querySourceFeatures stringifies property objects, but getClusterLeaves doesn't! I wrote a helper function to query property objects from both datasets:
const parsePropertyObject = obj => (typeof obj === 'string') ? JSON.parse(obj) : obj;

Below you can see the class I wrote to filter my source features.

const Filter = new class {
  constructor() {
    this.speciesFilter = ["any"]
    this.locationTypeFilter =  ["any"]
  }
  _update() {
    source.workerOptions.filter = ['all',
      (this.speciesFilter.length > 1) ? this.speciesFilter : true,
      (this.locationTypeFilter.length > 1) ? this.locationTypeFilter : true,

    ];
    // waiting for source.setFilter to get merged: https://github.com/mapbox/mapbox-gl-js/issues/10722
    source._updateWorkerData();
  }

  species(species, set) {
    this.speciesFilter.remove(f => Array.isArray(f) ? f[2] == species : false);
    if (set) {
      this.speciesFilter.push(['==', ['get', 'speciesId'], species]);
    }
    this._update();
  }

  locationType(type, set) {
    this.locationTypeFilter.remove(f => Array.isArray(f) ? f[1] == type : false);
    if (set) {
      this.locationTypeFilter.push(['has', type]);
    }
    this._update();
  }
}();

I added some options to scale the icons based on the cluster size and map zoom levels

const options = { 
  // Symbols
  icons: {
    map: { // number in px
      size: 50,
      padding: 5,
    },
    extension: "svg",
  },
  // Scaling
  scaling: {
    shrinkFactor: 0.2, // 0-1   higher value => smaller clusters
    interpolateFunction: // https://easings.net/
      function easeInOutSine(x) {
        return -(Math.cos(Math.PI * x) - 1) / 2;
      }
      // function easeInOutQuad(x) {
      //   return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
      // },
      ,
    zoomFactor: 0.7, // 0-1 minimum scale at max zoom - smaller value => smaller symbols when zooming out
  },
}

@tomytubert
Copy link

Hi! Maybe this solution works for you. Being able to spiderfy and use clusters.
Let me know!
https://medium.com/@tomytubert/mapbox-spiderfy-clusters-without-library-8936d4f45347

mapbox-gl-js version: 1.12.0

Question

In a symbol layer, when there's an icon/label collision, and when allowing overlaps, how can I get one icon/label to overlay "on top" of another?

In this screenshot you can see that instead of overlaying on top of each other (like a deck of cards IRL), all the collisions sort of blend together illegibly. How can I fix this?

Screen Shot 2020-09-24 at 7 29 28 AM

Links to related documentation

I read through https://docs.mapbox.com/help/troubleshooting/optimize-map-label-placement/. All the permutations of those 4 options don't help. Currently I have them all turned on:

layout: {
  'text-allow-overlap': true,
  'text-ignore-placement': true,
  'icon-allow-overlap': true,
  'icon-ignore-placement': true,
}

I was hoping to find some sort of { 'stack-collisions': true } layout/paint option in https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#symbol, but no luck.

Any ideas? Thanks!

@underoot
Copy link
Member

If you assign a unique identifier to all items of a dataset you can use 'symbol-sort-key': ['get', 'id_prop'] and it will be used to sort symbols (both icons and texts) to avoid cluttering of the texts as it presented in the issue. This behavior was introduced in v3.3.0. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests