-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
Comments
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.: 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 |
@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? |
Hi @stevage thank you for responding. It's one layer – some text styling and some icon styling. Here's the full definition:
|
If a developer has access to the original data (e.g. publishes via MTS), then the data points can be moved or the z/lat/lng In this case, there is no way to show multiple icons simultaneously without the icons overlapping on top of each other. |
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. |
@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 If anyone builds this with d3-force, please post it! I didn't have the time for it 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 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
},
} |
Hi! Maybe this solution works for you. Being able to spiderfy and use clusters.
|
If you assign a unique identifier to all items of a dataset you can use |
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?
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:
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!
The text was updated successfully, but these errors were encountered: