Dynamically add data, remove markers outside of bounds, add markers inside of bounds - mapbox-gl-js

I've been struggling with this feature for weeks, but am starting to think it's not possible. Hopefully someone here can prove me wrong! :)
Using Mapbox GL. On page load, map renders with markers within the given bounds. I'm trying to mimic functionality where the user drags the map, and based on the new bounds, new markers are drawn and old ones are removed. Data for the new markers are dynamic based on an API request. I managed to find a function after much Googling to test if a point is in bounds of a map and that works, but given how the function works to add/remove the marker, dynamic data doesn't seem to fit in.
I've created a fiddle here and hard-coded a new "feature" but it's not getting drawn. There is most likely a second part of this issue, but maybe i can figure it out on my own once this is deemed feasible
Any advice would be greatly appreciated. Thanks in advance!
geojson.features.forEach(function (marker) {
// create a DOM element for the marker
var el = document.createElement('div');
el.className = 'marker';
el.style.backgroundImage = 'url(https://placekitten.com/g/' + marker.properties.iconSize.join('/') + '/)';
el.style.width = marker.properties.iconSize[0] + 'px';
el.style.height = marker.properties.iconSize[1] + 'px';
el.addEventListener('click', function () {
window.alert(marker.properties.message);
});
// add marker to map
var point = new mapboxgl.Marker(el)
.setLngLat(marker.geometry.coordinates);
map.on("dragend", function() {
if ( inBounds(marker.geometry.coordinates, map.getBounds()) == false ) {
point.remove();
} else {
geojson.features.push({
"type": "Feature",
"properties": {
"message": "Lurman",
"iconSize": [20, 20]
},
"geometry": {
"type": "Point",
"coordinates": [
-59.29223632812499,
-17.28151823530889
]
}
})
point.addTo(map);
}
})
});
function inBounds(point, bounds) {
var lng = (point[0] - bounds._ne.lng) * (point[0] - bounds._sw.lng) < 0;
var lat = (point[1] - bounds._ne.lat) * (point[1] - bounds._sw.lat) < 0;
return lng && lat;
}

I was able to resolve this by using layers and updating it on dragend.

Related

Leaflet + Supercluster: L.marker's autoPan not working

I'm using the mapbox supercluster library to cluster a fairly large amount of point data and plotting it with leaflet inside a shiny app. At maxZoom the points are shown and are getting draggable for the user in case the location is wrong. Mapbox supercluster uses a static kdbush spatial index, so each time a L.marker is moved, the cluster will be reloaded (not a problem in my case, because supercluster is extremely fast).
However, it is only possible to drag an L.marker used with supercluster inside the visible map bounds. This makes sense, because the L.marker's are generated on the fly for only the visible map bounds. If the L.marker are defined with options {draggable:true,autoPan:true} an error occurs in case L.marker is dragged outside of the visible map extent:
Uncaught TypeError: t is null
leaflet 1.3.3/dist/leaflet.js:5
leaflet 1.3.3/dist/leaflet.js:5
at https://unpkg.com/leaflet#1.3.3/dist/leaflet.js:5
_adjustPan https://unpkg.com/leaflet#1.3.3/dist/leaflet.js:5
_adjustPan self-hosted:891
_adjustPan self-hosted:844
or in my shiny app I get the following error:
Uncaught TypeError: Cannot set property '_leaflet_pos' of null
at Object.Lt [as setPosition] (eval at <anonymous> (jquery.min.js:2), <anonymous>:5:9959)
at e._adjustPan (eval at <anonymous> (jquery.min.js:2), <anonymous>:5:71149)
It would be really cool to have some sort of autopan for L.marker's used with supercluster. I know the user could just zoom out to drag the point to the intended location. This is not really an option in my case, because there are quickly too many points in the visible map extent which will slow down map panning (that's why I use the clustering in the first place).
I tried to center the leaflet map during the drag event using panTo
layer.on('drag',function(e){
//console.log('marker dragstart event');
var position=e.target.getLatLng();
map.panTo(new L.latLng(position.lat,position.lng));
});
but it seems that this somehow stops the drag event.
My question is: Is there a way to get an autopan for L.marker used with supercluster?
I set up a minimal reproducible example below. This example plots a single point (var marker) as well as three points (var markers) with supercluster. The single point can be dragged outside the visible map extent whereas the cluster points cannot.
var map = L.map('map').setView([51.505, -1.09], 9);
L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
var marker = new L.marker([51.505, -1.09],{
draggable: true,
autoPan:true
}).bindPopup('autopan working').addTo(map);
// Empty Layer Group that will receive the clusters data on the fly.
var markers = L.geoJson(null, {
pointToLayer: createClusterIcon,
onEachFeature: onEachFeature
}).addTo(map);
var clusterData = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties":{
"id":0
},
"geometry": {
"type": "Point",
"coordinates": [-1.0,51.5]
}
},
{
"type": "Feature",
"properties":{
"id":1
},
"geometry": {
"type": "Point",
"coordinates": [-1.1,51.6]
}
},
{
"type": "Feature",
"properties":{
"id":2
},
"geometry": {
"type": "Point",
"coordinates": [-0.9,51.4]
}
}
]
}
function onEachFeature(f, layer) {
//add drag event
layer.on('drag',function(e){
//console.log('marker dragstart event');
//var position=e.target.getLatLng();
//map.panTo(new L.latLng(position.lat,position.lng));
});
layer.on('dragend',function(e){
//console.log('marker dragend event');
var changedPos = e.target.getLatLng();
//console.log('new location '+changedPos);
//load cluster again
clusterData.features[f.properties.id].geometry.coordinates[1]=changedPos.lat;
clusterData.features[f.properties.id].geometry.coordinates[0]=changedPos.lng;
index.load(clusterData.features);
update();
});
}
// Update the displayed clusters after user pan / zoom.
map.on('moveend', update);
function update() {
if (!ready) return;
var bounds = map.getBounds();
var bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
var zoom = map.getZoom();
var clusters = index.getClusters(bbox, zoom);
//console.log('clusters '+clusters);
markers.clearLayers();
markers.addData(clusters);
}
// Zoom to expand the cluster clicked by user.
markers.on('click', function(e) {
//console.log('check data' + e.layer.feature.properties.cluster_id);
var clusterId = e.layer.feature.properties.cluster_id;
var center = e.latlng;
var expansionZoom;
if (clusterId) {
expansionZoom = index.getClusterExpansionZoom(clusterId);
map.flyTo(center, expansionZoom);
}
});
var ready = false;
//load data
const index = new Supercluster({
radius: 150,
maxZoom:10
});
index.load(clusterData.features);
ready= true;
update();
function createClusterIcon(feature, latlng) {
if (!feature.properties.cluster){
return L.marker(latlng,{draggable:true,autoPan:true}); //add autoPan:true
}
var count = feature.properties.point_count;
var size =
count < 100 ? 'small' :
count < 1000 ? 'medium' : 'large';
var icon = L.divIcon({
html: '<div><span>' + feature.properties.point_count_abbreviated + '</span></div>',
className: 'marker-cluster marker-cluster-' + size,
iconSize: L.point(40, 40)
});
return L.marker(latlng, {
icon: icon
});
}
#map {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
<script src="https://unpkg.com/supercluster#7.1.0/dist/supercluster.min.js"></script>
<link href="https://cdn.rawgit.com/mapbox/supercluster/v4.0.1/demo/cluster.css" rel="stylesheet"/>
<script src="https://unpkg.com/leaflet#1.3.3/dist/leaflet.js"></script>
<link href="https://unpkg.com/leaflet#1.3.3/dist/leaflet.css" rel="stylesheet"/>
<div id="map"></div>

How do I display multiple geojson properties in a popup using Mapbox-GL?

I am trying to display two propeties from a geojson file in a popup on a map with points.
Here is an example of one item from the geojson file:
{ "type": "Feature", "properties": { "LONG": 144.760809, "LAT": -37.866606, "STOP_ID": 19924, "STOP_NAME": "Aircraft ", "2016_17_PATRONAGE": 294702, "2016_17_PATRONAGE_DAILY": 805.2, "METRO": "Yes" }, "geometry": { "type": "Point", "coordinates": [ 144.760809, -37.866606 ] } },
I am trying to display both the STOP_NAME and 2016_17_PATRONAGE_DAILY attributes in the same popup.
I have tried looking at this example and this example but neither approach seems to work.
This is my code so far (that works):
map.on('click', 'layername', function (e) {
var coordinates = e.features[0].geometry.coordinates.slice();
var name = e.features[0].properties.STOP_NAME;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(name)
.addTo(map);
});
I tried this code using the examples above:
map.on('click', 'layername', function (e) {
var coordinates = e.features[0].geometry.coordinates.slice();
var name = e.features[0].properties.STOP_NAME;
var patronage = e.features[0].properties.2016_17_PATRONAGE;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(name) + ' (' + properties.2016_17_PATRONAGE + ')')
.addTo(map);
});
This didn't work and I got a message: "SyntaxError: identifier starts immediately after numeric literal"
I'm fairly new to Mapbox-GL-JS so would appreciate any assistance.
You're so close. Just change that setHTML line to include an expression that references both properties:
.setHTML(e.features[0].properties.STOP_NAME + ' (' + e.feature[0].properties.2016_17_PATRONAGE + ')')
For convenience, I tend to do:
var p = e.features[0].properties;
So this would be:
.setHTML(p.STOP_NAME + ' (' + p.2016_17_PATRONAGE + ')')

Mapbox No Source With This ID

I want to remove sources and layers on Mapbox map. I managed to remove every sources and layers except for the first source and layer that I have added to Mapbox map.
Note that I am not good in using jQuery $.post.
Here is how I add all the sources and layers.
$.post('ajax/marker.php', function(data)
{
var firstSplit = data.split(",");
for(i=0;i<firstSplit.length-1;i++)
{
var secondSplit = firstSplit[i].split("|");
var id = secondSplit[0];
var lat = secondSplit[1];
var lng = secondSplit[2];
var point = {
"type": "Point",
"coordinates": [lat, lng]
};
map.addSource(id, { type: 'geojson', data: point });
map.addLayer({
"id": id,
"type": "symbol",
"source": id,
"layout": {
"icon-image": "airport-15"
}
});
}
});
Remember that, I managed to view all the sources and layers on Mapbox map.
Its just that I am not able to remove only the first source and layers that I have added to the map. I hope someone out there has some ideas regarding this problem. Thanks.
I used the two statements below in a loop to remove sources and layers.
map.removeSource(id);
map.removeLayer(id);
I did a test to remove the first source and layers manually as below but it did not work.
map.removeSource('1612280004A');
map.removeLayer('1612280004A');
However, it works on the next sources and layers.
My best guess from what you've posted is that you can't remove the layer while there are still sources attached to it. Try reversing the order of your two statements:
map.removeLayer('1612280004A');
map.removeSource('1612280004A');
My GeoJson shared the same ID. Removing both layer and source fixed this issue
function RemoveMapLayer() {
var mpLayer = map.getLayer("points");
if (typeof mpLayer === 'undefined') {
// No Layer
} else {
map.removeLayer("points");
}
var mpSource = map.getSource("points");
if (typeof mpSource === 'undefined') {
alert("no source");
} else {
map.removeSource("points");
}
}
Before deleting, check if the source and layer are present, for example:
if (map.getLayer('points')){
map.removeLayer('points');
}
if (map.getSource('point')){
map.removeSource('point');
}

How do I get the bounding box of a mapboxgl.GeoJSONSource object?

I'm setting up a Mapbox GL JS map like this:
mapboxgl.accessToken = 'pk.my_token';
var cityBoundaries = new mapboxgl.GeoJSONSource({ data: 'http://domain.com/city_name.geojson' } );
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v8',
center: [cityLongitude,cityLatitude],
zoom: 13
});
Then I'm loading that GeoJSON data onto the map after it loads like this:
map.on('style.load', function(){
map.addSource('city', cityBoundaries);
map.addLayer({
'id': 'city',
'type': 'line',
'source': 'city',
'paint': {
'line-color': 'blue',
'line-width': 3
}
});
});
At this point, I have a map that's centered at the location I specified in new mapboxgl.Map, and it's at zoom level 13. So, only a piece of the GeoJSON data is visible on the map. I'd like to re-center and re-zoom the map so that the entire GeoJSON data is visible.
In Mapbox JS, I would do this by loading the GeoJSON data into a featureLayer and then fitting the map to its bounds with:
map.fitBounds(featureLayer.getBounds());
The fitBounds documentation for Mapbox GL JS indicates that it wants the bounds in the format of [[minLng, minLat], [maxLng, maxLat]].
Is there a way to determine the mix/max latitude & longitude values of this GeoJSON layer?
Based on the 'Obtaining a bounding box' section of this post, I've come up with this process...
map.on('style.load', function(){
$.getJSON('http://citystrides.dev/city_name.geojson', function(response){
var boundingBox = getBoundingBox(response);
var cityBoundary = new mapboxgl.GeoJSONSource({ data: response } );
map.addSource('city', cityBoundary);
map.addLayer({
'id': 'city',
'type': 'line',
'source': 'city',
'paint': {
'line-color': 'blue',
'line-width': 3
}
});
map.fitBounds([[boundingBox.xMin, boundingBox.yMin], [boundingBox.xMax, boundingBox.yMax]]);
})
});
function getBoundingBox(data) {
var bounds = {}, coords, point, latitude, longitude;
for (var i = 0; i < data.features.length; i++) {
coords = data.features[i].geometry.coordinates;
for (var j = 0; j < coords.length; j++) {
longitude = coords[j][0];
latitude = coords[j][1];
bounds.xMin = bounds.xMin < longitude ? bounds.xMin : longitude;
bounds.xMax = bounds.xMax > longitude ? bounds.xMax : longitude;
bounds.yMin = bounds.yMin < latitude ? bounds.yMin : latitude;
bounds.yMax = bounds.yMax > latitude ? bounds.yMax : latitude;
}
}
return bounds;
}
Here's a walkthrough of what the code is doing, for anyone out there who needs a detailed explanation:
map.on('style.load', function(){
When the map loads, let's do the stuff in this function.
$.getJSON('http://citystrides.dev/city_name.geojson', function(response){
Get the city's GeoJSON data. This is an asynchronous call, so we have to put the all the code that uses this data (the response) inside this function.
var boundingBox = getBoundingBox(response);
Get the bounding box of this GeoJSON data. This is calling the , function(){ that appears after the 'map on style load' block.
var cityBoundary = new mapboxgl.GeoJSONSource({ data: response } );
Build Mapbox's GeoJSON data.
map.addSource('city', cityBoundary);
Add the source to Mapbox.
map.addLayer({
Add the layer to Mapbox.
map.fitBounds([[boundingBox.xMin, boundingBox.yMin], [boundingBox.xMax, boundingBox.yMax]]);
Adjust the map to fix the GeoJSON data into view.
function getBoundingBox(data) {
This function iterates over the returned GeoJSON data, finding the minimum and maximum latitude and longitude values.
One thing to note in the getBoundingBox function is this line:
coords = data.features[i].geometry.coordinates;
In the original post, linked above, this line was written as coords = data.features[i].geometry.coordinates[0]; because their data for the list of coordinates was an array of arrays. My data isn't formatted that way, so I had to drop the [0]. If you try this code & it blows up, that might be the reason.
You can use the turf.js library. It has a bbox function:
const bbox = turf.bbox(foo);
https://turfjs.org/docs/#bbox
I use the turf-extent library, which is maintained by the Mapbox bunch anyhow. https://www.npmjs.com/package/turf-extent is the node module link.
In your code you simply import(ES6) or require as so:
ES6/Webpack: import extent from 'turf-extent';
Via script tag: `<script src='https://api.mapbox.com/mapbox.js/plugins/turf/v2.0.2/turf.min.js'></script>`
Then feed your response to the function, for example:
ES6/Webpack: let orgBbox = extent(response);
Normal: var orgBbox = turf.extent(geojson);
Then you can use the array values to set your map center:
center: [orgBbox[0], orgBbox[1]]
Or as you want, to fit bounds:
map.fitBounds(orgBbox, {padding: 20});
Here is an example using the turf.min.js in a regular html tag in case you are not using webpack or browser:
https://bl.ocks.org/danswick/83a8ddff7fb9193176a975a02a896792
Happy coding and mapping!
Based on James Chevalier's answer. For polygon/multipolygon tilesets that are assigend to a map in Mapbox Studio I am using this to get the bounding box:
getPolygonBoundingBox: function(feature) {
// bounds [xMin, yMin][xMax, yMax]
var bounds = [[], []];
var polygon;
var latitude;
var longitude;
for (var i = 0; i < feature.geometry.coordinates.length; i++) {
if (feature.geometry.coordinates.length === 1) {
// Polygon coordinates[0][nodes]
polygon = feature.geometry.coordinates[0];
} else {
// Polygon coordinates[poly][0][nodes]
polygon = feature.geometry.coordinates[i][0];
}
for (var j = 0; j < polygon.length; j++) {
longitude = polygon[j][0];
latitude = polygon[j][1];
bounds[0][0] = bounds[0][0] < longitude ? bounds[0][0] : longitude;
bounds[1][0] = bounds[1][0] > longitude ? bounds[1][0] : longitude;
bounds[0][1] = bounds[0][1] < latitude ? bounds[0][1] : latitude;
bounds[1][1] = bounds[1][1] > latitude ? bounds[1][1] : latitude;
}
}
return bounds;
}

in-place Update Leaflet GeoJSON feature

I was hoping that GeoJSON.addData() would return the newly created subLayer of the GeoJSON object, but it does not. Why Do I need something like this?
(currently using Leaflet 1.0Beta2)
I am using Leaflet GeoJson to show live data in a GeoJSON (point, line, polygon). It is a CRUD interface (Create, Update and Delete). I receive WebSocket messages with GeoJSON data, each one with a GUID.
I the case of a CREATE I just do a GeoJSon.AddData() to the appropriate layer.
But for the UPDATE and DELETE I want a handle for the layer that was added to the GeoJSON so that I can update its location, or update the Geometry. addData is not giving me this handle. And it is really hard to get it from onEachFeature() or from pointToLayer()
Currently, I do have a way that works but ugly. I have to do is search the entire layer with GeoJSon.eachLayer(fn)
whenever an update or delete occurs. It seems a bit expensive.
{even if Leaflet is not truly engineered for this live r/t display of data, it is working, and it seems sad if you cannot use it for watching a lot of sensor data, IoT) as we are doing.
this.delete = function (layerName, feature) {
if (!(layerName in self.myLayers)) {
alert("live.display: Missing Layer: " + layerName);
return;
}
var layerInfo = Live.myLayers[layerName];
var base = layerInfo.layer;
var id = feature.properties.objectID;
this.find(layerName, id, function (layer) {
this.removeLayer(layer);
Tools.debug(16, "live.delete:", "killed object: " + id);
});
}
this.find = function (layerName, id, action) {
var base = Live.myLayers[layerName].layer;
base.eachLayer(function (feature) {
if (!("objectID" in feature.feature.properties)) { return; }
var objectID = feature.feature.properties.objectID;
if (objectID == id) {
action.call(base, feature);
}
});
}
Instead (or in parallel) of "merging" all created GeoJSON features into a single Leaflet GeoJSON layer group (which you do with addData), why not creating first each feature in its own Leaflet GeoJSON layer, so that it gives you the handle you are looking for (then you could simply record this handle in an object / mapping with the key being your objectID for example)?
If desired, you could even still merge the individual layers into your single GeoJSON layer group after that.
var myGeoJsonLayerGroup = L.geoJson().addTo(map);
var myFeaturesMap = {};
function addNewFeatureToGeoJsonLayerGroup(newGeoJsonData) {
var newGeoJSONfeature = L.geoJson(newGeoJsonData);
myFeaturesMap[newGeoJsonData.properties.objectID] = newGeoJSONfeature;
myGeoJsonLayerGroup.addLayer(newGeoJSONfeature);
}
function updateFeature(updatedGeoJsonData) {
var updatedFeature = myFeaturesMap[updatedGeoJsonData.properties.objectID];
updatedFeature.clearLayers(); // Remove the previously created layer.
updatedFeature.addData(updatedGeoJsonData); // Replace it by the new data.
}
function deleteFeature(deletedGeoJsonData) {
var deletedFeature = myFeaturesMap[deletedGeoJsonData.properties.objectID];
myGeoJsonLayerGroup.removeLayer(deletedFeature);
}
Demo (not using GeoJSON): http://jsfiddle.net/ve2huzxw/94/
EDIT:
A slightly more simple solution would be to store the reference to each individual layer through the onEachFeature function of the GeoJSON layer group:
var myFeaturesMap = {};
var myGeoJsonLayerGroup = L.geoJson({
onEachFeature: function (feature, layer) {
myFeaturesMap[feature.properties.objectID] = layer;
}
}).addTo(map);
function addNewFeatureToGeoJsonLayerGroup(newGeoJsonData) {
myGeoJsonLayerGroup.addData(newGeoJsonData);
}
function updateFeature(updatedGeoJsonData) {
deleteFeature(updatedGeoJsonData); // Remove the previously created layer.
addNewFeatureToGeoJsonLayerGroup(updatedGeoJsonData); // Replace it by the new data.
}
function deleteFeature(deletedGeoJsonData) {
var deletedFeature = myFeaturesMap[deletedGeoJsonData.properties.objectID];
myGeoJsonLayerGroup.removeLayer(deletedFeature);
}
If you want to store references to the actual layers being created, you can access them from the layeradd event on your L.GeoJSON instance:
var geojson = new L.GeoJSON().on(
'layeradd', function (e) {
console.log(e.layer);
}
).addTo(map);
// This addData call will fire the handler above twice
// because it adds two features.
geojson.addData({
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Point",
"coordinates": [90, 0]
}
}, {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Point",
"coordinates": [-90, 0]
}
}]
});
If someone still looking for another short method to update GeoJSON, you can try something like this,
//function to clear the previous geojson feature
function clearMap() {
map.eachLayer(function(layer){
if(layer.myTag && layer.myTag === 'previousLayer'){
lmap.removeLayer(layer);
}
});
}
function geojsonUpdate(geojsonData){
var geojsonLayer = L.geoJson(geojsonData, {
onEachFeature: function (feature, layer) {
layer.myTag = 'previousLayer'
);
},
});
geojsonLayer.addTo(lmap);
lmap.fitBounds(geojsonLayer.getBounds());
}
//function call
clearMap();
geojsonUpdate(data);