Skip to content

Instantly share code, notes, and snippets.

@gmaclennan
Last active June 21, 2019 14:57
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gmaclennan/36a86f67f364bde59108092f4bdf37d6 to your computer and use it in GitHub Desktop.
Save gmaclennan/36a86f67f364bde59108092f4bdf37d6 to your computer and use it in GitHub Desktop.
Marker Dispersion

Uses d3-force to disperse overlapping markers on a mapbox-gl map.

Markers are tethered to their actual location, but have a collision force based on the radius of the icon. An additional force pulls icons towards their previous location (locations are calculated each time you move the map) to avoid markers jumping around excessively as they settle into different local minima on each re-draw.

<!DOCTYPE html>
<meta charset="utf-8">
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v0.36.0/mapbox-gl.js'></script>
<script src='https://cdn.jsdelivr.net/lodash/4.17.4/lodash.min.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v0.36.0/mapbox-gl.css' rel='stylesheet' />
<style>
html, body {
padding: 0;
margin: 0;
}
#map {
position: absolute;
width: 100%;
top: 0;
bottom: 0;
}
</style>
<div id="map" />
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
var pointLayers = ['poi']
var epsilon = 0.1
mapboxgl.accessToken = 'pk.eyJ1IjoiZ21hY2xlbm5hbiIsImEiOiJSaWVtd2lRIn0.ASYMZE2HhwkAw4Vt7SavEg';
var map = new mapboxgl.Map({
container: 'map', // container id
style: 'mapbox://styles/mapbox/streets-v8', //stylesheet location
}).on('load', onLoad)
function onLoad () {
var prevOffsets = {}
var origTextOffsets = pointLayers.reduce(function (acc, layerId) {
acc[layerId] = map.getLayoutProperty(layerId, 'text-offset')
return acc
}, {})
var simulation = d3.forceSimulation()
.force('anchorX', d3.forceX(function (d) {return d.anchorX}).strength(0.3))
.force('anchorY', d3.forceY(function (d) {return d.anchorY}).strength(0.3))
.force('prevPosX', forcePrevX(function (d) {return d.prevX}).strength(0.1))
.force('prevPosY', forcePrevY(function (d) {return d.prevY}).strength(0.1))
.force('collide', d3.forceCollide(function (d) {return getRadius(map, d.f)}).strength(1))
.stop()
map.on('moveend', updateNodes)
updateNodes()
function updateNodes () {
var anchorLinks = []
var prevPosLinks = []
var nodes = getFeatures(map, pointLayers).map(function (f, i) {
var prevOffset = prevOffsets[f.properties.osm_id]
var point = f.point = map.project(f.geometry.coordinates)
var node = {
f: f,
x: point.x + (prevOffset ? prevOffset[0] : 0),
y: point.y + (prevOffset ? prevOffset[1] : 0),
anchorX: point.x,
anchorY: point.y,
}
if (prevOffset) {
node.prevX = node.x
node.prevY = node.y
}
return node
})
simulation
.nodes(nodes)
.alpha(1)
for (var i = 0; i < 120; i++) simulation.tick()
render()
function render () {
var offsets = {}
nodes
.filter(function (n) {
return !n.fx
})
.sort(function (a, b) {
return a.f.properties.osm_id - b.f.properties.osm_id
})
.forEach(function (node) {
var feature = node.f
var layerId = feature.layer.id
var featureId = feature.properties.osm_id
var dx = node.x - feature.point.x
var dy = node.y - feature.point.y
// console.log(dx, dy)
if (Math.abs(dx) < epsilon && Math.abs(dy) < epsilon) {
prevOffsets[featureId] = null
return
}
// If the marker is moved, we set up a soft force to prefer keeping
// it where it was in the previous iteration
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
prevOffsets[featureId] = [dx, dy]
}
if (!offsets[layerId]) {
offsets[layerId] = {
type: 'categorical',
property: 'osm_id',
stops: []
}
}
offsets[layerId].stops.push([featureId, [dx, dy]])
})
pointLayers.forEach(function (layerId) {
var offset = offsets[layerId]
if (!offset) {
map.setLayoutProperty(layerId, 'icon-offset', [0, 0])
map.setLayoutProperty(layerId, 'text-offset', origTextOffsets[layerId])
return
}
// icon-offset is a strange unit, pixels * icon-size
map.setLayoutProperty(layerId, 'icon-offset', getIconOffset(map, layerId, offset))
var textField = map.getLayoutProperty(layerId, 'text-field')
// Also offset the text label
if (textField) {
// Text offset is in em units, not pixels, so we need to convert
map.setLayoutProperty(layerId, 'text-offset', getTextOffset(map, layerId, offset, origTextOffsets[layerId]))
}
})
}
}
}
// Return all features in the current map view that need layout
function getFeatures (map, pointLayers) {
var features = map.queryRenderedFeatures({layers: pointLayers})
// qRF can return duplicate features if points are near tile boundaries
return _.uniqWith(features, cmpFeatures)
function cmpFeatures (f1, f2) {
return f1.properties.osm_id === f2.properties.osm_id
}
}
// For a given feature, get a radius to be used for collision avoidance
// We use a circle with diameter of the max dimension of the rendered
// icon bounding rectangle as an approximation.
// NB: Square icons will overlap at the corners
function getRadius (map, f) {
var layer = map.getLayer(f.layer.id)
var iconName = layer.getLayoutValue('icon-image', {zoom: map.getZoom()}, f.properties)
iconName = iconName.replace(/{([^{}]+)}/g, function (match, p1) {
return f.properties[p1]
})
var iconPos = map.style.sprite.getSpritePosition(iconName)
var iconSize = layer.getLayoutValue('icon-size', {zoom: map.getZoom()})
if (!iconPos || !iconSize) return 0
var r = Math.max(iconPos.width, iconPos.height) * iconSize / 2 / (iconPos.pixelRatio || 1)
return r
}
// Convert an offset in pixels to an offset in em units
function getTextOffset (map, layerId, offset, origTextOffset) {
var size = map.getLayer(layerId).getLayoutValue('text-size', {zoom: map.getZoom()})
var textOffset = Object.assign({}, offset, {
stops: offset.stops.map(function (s) {
return [s[0], [s[1][0] / size + origTextOffset[0], s[1][1] / size + origTextOffset[1]]]
})
})
return textOffset
}
// Convert an offset in pixels to an offset in em units
function getIconOffset (map, layerId, offset) {
var size = map.getLayer(layerId).getLayoutValue('icon-size', {zoom: map.getZoom()})
var iconOffset = Object.assign({}, offset, {
stops: offset.stops.map(function (s) {
return [s[0], [s[1][0] / size, s[1][1] / size]]
})
})
return iconOffset
}
function forcePrevX (x) {
var force = d3.forceX(x)
var initializeOrig = force.initialize
force.initialize = function (_) {
var withPrevX = _.filter(function (d) { return ('prevX' in d) })
initializeOrig(withPrevX)
}
return force
}
function forcePrevY (y) {
var force = d3.forceY(y)
var initializeOrig = force.initialize
force.initialize = function (_) {
var withPrevY = _.filter(function (d) { return ('prevY' in d) })
initializeOrig(withPrevY)
}
return force
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment