-
Notifications
You must be signed in to change notification settings - Fork 0
/
SlippyMap.js
537 lines (496 loc) · 17.9 KB
/
SlippyMap.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
/**
* The Configuration of a map.
* <br>
* This is only used before calling "showMap". Any changes made to the configuration
* after the initial "showMap" will be ignored.
*/
class Configuration {
/**
* Set to true if the HTML attributes of the map's div can be used to override
* the configuration.
*
* @type {boolean}
*/
canUseDivAttributes = true;
/**
* The Center of the Map. This should be an array containing 2 numbers.
* <br>
* Default value is roughly in the center of Belgium.
*
* @type {number[]}
*/
center = [50.605902641613284, 4.752960205078125];
/**
* The Zoom level of the map.
* <br>
* Default value is 14.
*
* @type {number}
*/
zoom = 14;
/**
* The TileLayer to use.
* <br>
* See https://leafletjs.com/reference-1.5.0.html#tilelayer for more information.
* <br>
* Default value is the OSM default map: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
*
* @type {string}
*/
tileLayer = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
/**
* The tileLayer's attribution text.
* <br>
* Default value is the attribution for the OSM default map (see above).
*
* @type {string}
*/
tileLayerAttribution = '<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
/**
* The maximum number of elements that can be shown on-screen
* Set to undefined, or a number less or equal to zero to remove the limit.
* <br>
* Default value is 500
*
* @type {number|undefined}
*/
maxElements = 500;
/**
* The "padding" we should use for the bounds when showing the markers.
* This value is a percentage, where 1 = 100%, 0 = 0%, and -1 = -100%.
* <br>
* This value shouldn't be too large, and should be positive.
* A value between 0.2 and 0.5 is probably ideal for performance.
* Larger values will reduce performance in the common case, and values
* too small (<0.2) will reduce performance for users that move the map
* around a lot.
* <br>
* Default value is 0.25
*
* @type {number}
*/
boundsPadding = 0.25;
/**
* @name PopupTextProvider
* @function
* @param {object} element The element returned by the Overpass API.
* @param {object} element.tags The tags of the element. Look for tags by checking if
* element.tags['yourTag'] is undefined or not.
* @returns {string|undefined}
*/
/**
* The function that is called whenever we want to fetch the marker popup text
* for a given element.
* <br>
* This function is always called with the element object, which has an array
* of tags (element.tags) that you can use.
* <br>
* Return undefined if you don't want a popup
* <br>
* Popups support HTML.
*
* @type {PopupTextProviderType}
*/
popupTextProvider = defaultPopupTextProvider;
/**
* Overrides this configuration based on a div's attributes.
* <br>
* The attributes must have the same name as the members.
* <br>
* Attributes will be checked against a RegEx to see if they're well formed.
* If they aren't, they'll be ignored an a warning will be printed to the console.
*
* @param {object} div the div object
*/
useDivAttributes(div) {
// Utils
function warnIllFormedAttr(attrName, value) {
console.warn('Ill-formed attribute for %o: %o', attrName, value);
}
// Overriding center
if (div.hasAttribute('center')) {
let value = div.getAttribute('center');
// Must be a string with 2 floating-point numbers, separated by a comma
// and between brackets. e.g [3.14, 3.14]
if (/^\[(\d+.\d+)\s?,\s?(\d+.\d+)\]$/.test(value)) {
this.center = JSON.parse(value);
hasSetcenter = true;
}
else
warnIllFormedAttr('center', value);
}
// Overriding zoom
if (div.hasAttribute('zoom')) {
let value = div.getAttribute('zoom');
// Must be a number, maybe a decimal one.
if (/^\d+(.\d+)?$/.test(value))
this.zoom = parseInt(value);
else
warnIllFormedAttr('zoom', value);
}
// Overriding tileLayer
if (div.hasAttribute('tileLayer'))
this.tileLayer = div.getAttribute('tileLayer');
// Overriding tileLayerAttribution
if (div.hasAttribute('tileLayerAttribution'))
this.tileLayerAttribution = div.getAttribute('tileLayerAttribution');
// Overriding maxElements
if(div.hasAttribute('maxElements')) {
let value = div.getAttribute('maxElements');
// Must be a number
if (/^\d+?$/.test(value))
this.maxElements = parseInt(value);
else
warnIllFormedAttr('maxElements', value);
}
}
}
/**
*
* Creates and displays a map in place of the div with id mapId.
* <br>
* If conf.canUseDivAttributes is set to true, this will call conf.useDivAttributes to update the configuration
* based on the attributes of the div (when present)
*
* @param {string} mapId The HTML id of the div that should become the map
* @param {Configuration} conf the Configuration object.
* Can be undefined. If that's the case, a default-constructed object will be used.
* However, if you want to reuse the configuration object later, it's better to pass one.
* @param {string|undefined} focusQuery If defined, calls focusOn to initialize the map using the focusQuery.
* Note that if this parameter is used, the focusOn attribute of the div will be ignored.
* @returns {Leaflet.Map} The created Leaflet Map.
*/
function showMap(mapId, conf, focusQuery) {
if (conf == undefined)
conf = new Configuration();
let div = document.getElementById(mapId);
if (div == null) {
console.error("Cannot create map - div with id '%o' does not exist", mapId);
return;
}
if (conf.canUseDivAttributes)
conf.useDivAttributes(div);
// Create the map and give it a tile layer.
let map = L.map(mapId);
L.tileLayer(conf.tileLayer, { attribution: conf.tileLayerAttribution }).addTo(map);
// Set the zoom level
map.setZoom(conf.zoom);
// Handle the focusOn attribute.
if(div.hasAttribute('focusOn')) {
if(focusQuery == undefined)
focusQuery = div.getAttribute('focusOn');
else
console.warn("showMap - focusOn attribute of the map div overriden by the focusQuery parameter");
}
// If we got a focusQuery, use focusOn to initialize the map's position.
// Else, center the view using the configuration parameters.
if(focusQuery != undefined)
focusOn(map, focusQuery, L.latLng(conf.center));
else
map.setCenter(conf.center);
map.addEventListener('moveend', onMoveEnd);
// This is the function called whenever the user (or a programmer) is done moving the view.
// It performs some checks and may fire an additional "drawmarkers" event.
// the "drawmarkers" event is only called if:
// - this is the first time moving the view
// - the view has been moved outside the bounds of the "largest" previous view
function onMoveEnd() {
function fire(bounds) {
onMoveEnd.bounds = bounds;
map.fireEvent('drawmarkers');
};
let curBounds = map.getBounds();
// If this is the first time calling the function, fire the event and save the bounds.
// If this isn't the first time and the new bounds are outside the old ones,
// set them as the current bounds and fire the event.
// If both conditions are false, don't do anything.
if((onMoveEnd.bounds == undefined) || !onMoveEnd.bounds.contains(curBounds))
return fire(curBounds.pad(conf.boundsPadding));
return;
}
return map;
}
/**
* Centers the map on something.
* <br>
* This will perform a search using Nominatim, and center the map on the first result.
* If no result is found, the coordinates are unchanged.
* <br>
* If the result of this function isn't satisfying, try to make a more specific query
* e.g. "Antwerpen Belgium", or simply set the coordinates manually.
* <br>
* The nominatim query will be performed using the following options:
* <ul>
* <li> limit=1
* <li> viewbox=current bounds (map.getBounds().toBBoxString())
* <li> format=json
* <li> q=query (the query string)
* </ul>
* <br>
* This function can be used even if the map has not been initialized yet.
* If that's the case, the parameter 'center' must be provided.
* <br>
* If you use this on the map returned by showMap, it has already be initialized
* so the center parameter is useless and can be omitted.
*
* @param {Leaflet.Map} map the Leaflet map
* @param {string} query The nominatim query. Example "Antwerpen", "Brugge", etc.
* @param {Leaflet.LatLng|undefined} center The center of the search. Must be provided if
* the map has not been initialized yet. If the map has been initialized, the bounds of
* its view will be used instead.
*/
function focusOn(map, query, center) {
map.stop();
if(typeof query !== 'string') {
console.error("Query is not a string");
return;
}
// Prepare the request
let xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState != 4) return;
if (this.status == 200)
handleQueryResult(this.responseText);
else
console.error("focusOn - Nominatim query failed with code %o: %o", this.status, this.responseText);
};
function getBounds() {
// Try to use the map bounds
try {
return map.getBounds().toBBoxString()
}
catch(err) {
if(center == undefined)
throw "focusOn - Map has not been initialized map and there's no center for the search";
// Create a 1Km bound box from the point.
return center.toBounds(1000).toBBoxString();
}
}
// Prepare the query
let queryURL = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&viewbox=${getBounds()}&limit=1`;
// Send the request
xhttp.open("GET", encodeURI(queryURL));
xhttp.send();
/**
* Handles the result of a successful nominatim query.
* @param {string} resultString the result string of the query. Must be JSON
*/
function handleQueryResult(resultString) {
let result;
try {
result = JSON.parse(resultString);
}
catch(err) {
console.error("focusOn - an error occured while parsing the Nominatim query result: %o", err);
return;
}
// Fetch the first result. If there's no result, don't do anything.
result = result[0];
if(result == undefined) {
console.log("focusOn - No result found for query '%o'", query);
return;
}
// Center the map on the lat/lon
map.setView(L.latLng(result.lat, result.lon));
}
}
/**
* Returns a simple overpass query string that returns all nodes, ways and relations
* in the bbox that have a given key/value pair.
* <br>
* If key is undefined, all points will be returned.
* (Please be careful with the latter, if it's a large bbox, performance will be terrible, especially
* if you don't cap the number of features shown on screen)
*
* @param {string|undefined} key The key we're looking for
* @param {string|undefined} value The value of the key we're looking for.
* @returns {string} the query string
*/
function getSimpleQuery(key, value) {
function getParam() {
if(key == undefined)
return "";
return `["${key}"="${value}"]`;
}
var query = `
[out:json][timeout:25];
// gather results
(
node${getParam()}({{bbox}});
);
// print results
out body;
>;
out skel qt;`;
return query;
}
/**
* Queries the Overpass API and adds the resulting features to the map.
* This is asynchronous, the points will be added as soon as the API gives us a response.
* <br>
* Errors will be printed to the console if the query fails.
* <br>
* A warning will be printed to the console if some points are ignored due to the cap.
* (see Configuration.maxElements)
*
* @param {Leaflet.Map} map the map
* @param {string} query the query. For generating simple queries, @see getSimpleQuery
* Note: occurences of {{bbox}} inside the query will be replaced with the bounds
* of the map.
* @param {Configuration|undefined} conf the configuration object. Can be undefined to use the default configuration.
*/
function queryAndShowFeatures(map, query, conf) {
// Create a configuration object if we don't have one
if (conf == undefined)
conf = new Configuration();
// Note: We cannot use map.getBounds().toBBoxString() because
// for some reason overpass wants another format which is
// southwest_lat,southwest_lng,northeast_lat,northeast_lng
// instead of
// southwest_lng,southwest_lat,northeast_lng,northeast_lat
// Create the bbox string
let bounds = map.getBounds().pad(conf.boundsPadding);
let sw = bounds.getSouthWest();
let ne = bounds.getNorthEast();
let bboxString = `${sw.lat},${sw.lng},${ne.lat},${ne.lng}`;
// Replace all occurences of {{bbox}} inside the query with
// the bbox string
query = query.replace("{{bbox}}", bboxString);
// Create the query URL for the OHM Overpass API & encode it
let queryURL = "http://overpass-api.openhistoricalmap.org/api/interpreter?data=".concat(query);
queryURL = encodeURI(queryURL);
// Create the XMLHttpRequest to send the GET request asynchronously
let xhttp = new XMLHttpRequest();
xhttp.open("GET", queryURL);
// Set the callback
xhttp.onreadystatechange = function () {
// Only handle the result if the request has been completed
if (this.readyState != 4)
return;
// 200 = OK, anything else is considered a failure.
if (this.status === 200)
handleQueryResult(this.responseText, configuration);
else
console.error("The Request could not be completed. Status: %o", this.status);
}
xhttp.send();
/**
* Creates a single marker
* @param {*} element an element returned by the overpass query (object with a .lat and .lon field)
*/
function createMarker(layerGroup, element) {
let marker = L.marker(L.latLng(element.lat, element.lon)).addTo(layerGroup);
// Fetch the popup text using the provider
let popupText = conf.popupTextProvider(element);
if(popupText == undefined)
return;
// If the provider has given us some text, create the popup.
marker.bindPopup(conf.popupTextProvider(element));
marker.on("mouseover", function() {
this.openPopup();
})
marker.on("mouseout", function() {
this.closePopup();
})
}
/**
* Handles the result of a successful query to the Overpass API, adding the features as markers on the map.
* @param {string} rawJSON the raw JSON string
*/
function handleQueryResult(rawJSON) {
// First, parse the raw json.
let json;
try {
json = JSON.parse(rawJSON);
}
catch (err) {
console.error("JSON Parsing Error!");
console.log("string that we tried to parse: %o", rawJSON);
console.log("JSON.parse() Result: %o", json);
return;
}
let maxElements = conf.maxElements;
if(maxElements <= 0)
maxElements = undefined;
// Once that's done, gather the list of features. They're located in the "elements" part of the
// JSON.
if (json.elements != undefined) {
// Create a layer group for the markers if that's not done yet.
let layerGroup = queryAndShowFeatures.layerGroup;
if(layerGroup == undefined)
queryAndShowFeatures.layerGroup = layerGroup = L.layerGroup().addTo(map);
// If we already have a layergroup, clear it.
else
layerGroup.clearLayers();
// Fetch the elements
let elements = json.elements;
// Add the markers to the layerGroup
for (let element of elements.slice(0, maxElements))
createMarker(layerGroup, element);
// Show some debug info, including a omitted markers count if we have omitted some
// markers.
if ((maxElements != undefined) && (elements.length > maxElements)) {
let omitted = elements.length - maxElements;
console.warn("queryAndShowFeatures - Markers Cap Reached: %o markers were not shown."
+ " (Cap is %o and query returned %o features)", omitted, maxElements, elements.length);
}
else
console.log("queryAndShowFeatures - Added %o elements", elements.length);
}
else
console.log("queryAndShowFeatures - No elements found");
}
}
/**
* The default popup text provider, which tries its best to provide a description
* (in English) based on the tags of the element.
*
* @param {object} element The element returned by the Overpass API.
* @param {object} element.tags The tags of the element
* @returns {string|undefined}
*/
function defaultPopupTextProvider(element) {
let tags = element.tags;
// Can't do anything without tags.
if(tags == undefined || tags.length == 0)
return undefined;
let text = "";
// Print name
if(tags.name != undefined)
text += `<h2>${tags.name}</h2>`
// Print description
if(tags.description != undefined)
text += `<p>${tags.description}</p>`
// Try to parse a start_date and end_date if we got one
function getDateLine() {
let dateLine = "";
let start_date = tags["start_date"];
let end_date = tags["end_date"];
if((start_date != undefined) && (end_date != undefined))
dateLine += `<p><b>From</b> ${start_date} <b>to</b> ${end_date}</p>`;
return dateLine;
}
text += getDateLine();
// Try to parse an address if we got one
function getAddressLine() {
let city = tags["addr:city"];
let postcode = tags["addr:postcode"];
let housenumber = tags["addr:housenumber"];
let street = tags["addr:street"];
// Only display this if we got both a streetname and
// a housenumber.
if((housenumber != undefined) && (street != undefined)) {
let addressLine = "<p>";
addressLine += housenumber + ' ' + street + '<br>';
// If we got a city + postcode, display that as well.
if((postcode != undefined) && (city != undefined)) {
addressLine += postcode + ' ' + city;
}
addressLine += '</p>';
return addressLine;
}
return "";
}
text += getAddressLine();
return text;
}