|
<!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> |