├── Readme.md
├── common
├── .gitrepo
├── custom_layers.js
├── icons
│ └── marker.svg
├── interactive_layer.js
├── interactive_map.js
├── share_marker.js
├── style.css
└── utils.js
├── images
├── collectibles
│ └── example.png
└── icons
│ └── information.png
├── index.html
├── map.js
├── map_utils.js
├── marker
├── collectibles.js
└── information.js
└── marker_logic
├── collectibles.js
└── information.js
/Readme.md:
--------------------------------------------------------------------------------
1 | # Using the template
2 | If you have any questions: https://github.com/interactive-game-maps/template/discussions
3 |
4 | ## Requirements
5 | * A high quality picture of your desired map.
6 | * Basic git knowledge. You should be aware of cloning locally, committing and pushing your changes.
7 | * Basic programming knowledge. Understanding and editing this example is sufficient for simple things.
8 | * Know how to run python scripts.
9 |
10 | ## General steps
11 | 1. Create a copy of this repository. GitHub makes this easy with "Use this template".
12 | 1. Clone it to your local drive using `git`.
13 | 1. Copy your image into the cloned repository.
14 | 1. Split your high quality picture into smaller chunks with this python script: https://github.com/commenthol/gdal2tiles-leaflet
15 | Here's a basic example. `-z` controls the generated zoom levels.
16 | `./gdal2tiles.py -l -p raster -w none -z 0-5 my_high_quality_map.jpg map_tiles`
17 | Your generated chunks with all zoom level are now in the folder `map_tiles`.
18 | 1. Open `index.html` in your browser. You should be able to see your map with some example markers.
19 | 1. You can now open the edit pane on the lower left and add desired markers.
20 | When done make sure to export the layer with the button on the right side.
21 | You'll get a geoJSON. Replace an example geoJSON in `marker/` with your geoJSON.
22 | 1. Reload the map and you should see your markers in the map.
23 |
24 | ## Structure
25 | * Head over into `map.js` to add additional layers or change map metadata.
26 | * Add or edit marker positions in `marker`.
27 | * Add or edit marker behavior and look in `marker_logic`.
28 | * Include added files in the `index.html` body.
29 |
--------------------------------------------------------------------------------
/common/.gitrepo:
--------------------------------------------------------------------------------
1 | ; DO NOT EDIT (unless you know what you are doing)
2 | ;
3 | ; This subdirectory is a git "subrepo", and this file is maintained by the
4 | ; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme
5 | ;
6 | [subrepo]
7 | remote = git@github.com:interactive-game-maps/common.git
8 | branch = master
9 | commit = 3e799e5362c40a0297b779f71f98e3436c14c569
10 | parent = 9b65b6fb469e6302e990c771d818ff56236bddb6
11 | method = merge
12 | cmdver = 0.4.9
13 |
--------------------------------------------------------------------------------
/common/custom_layers.js:
--------------------------------------------------------------------------------
1 | class CustomLayers {
2 | #custom_layers = new Map();
3 | #custom_layer_controls;
4 | #edit_mode = false;
5 | #interactive_map;
6 | #map;
7 | #website_subdir;
8 |
9 | /**
10 | * Add custom editable layers to the map. Loads and saves them to local storage.
11 | * @param {InteractiveMap} interactive_map The interactive map this gets added to
12 | */
13 | constructor(interactive_map) {
14 | this.#map = interactive_map.getMap();
15 | this.#interactive_map = interactive_map;
16 | this.#website_subdir = interactive_map.getWebsiteSubdir();
17 |
18 | this.#loadFromStorage();
19 |
20 | this.#extendDefaultLayerControl(this.#map);
21 | this.#custom_layer_controls = new L.Control.Layers(null, Object.fromEntries(this.#custom_layers), {
22 | collapsed: false
23 | });
24 |
25 | // Save manual edits before leaving
26 | window.onbeforeunload = this.#saveToStorage.bind(this);
27 | // The unload method seems sometimes unreliable so also save every 5 minutes
28 | window.setInterval(this.#saveToStorage.bind(this), 300000);
29 | }
30 |
31 | /**
32 | * Show custom layers on the map. This needs the display names!
33 | * @param {string[]} layers Array of display names of layers to add
34 | */
35 | addLayersToMap(layers) {
36 | layers.forEach(layer => {
37 | if (this.#hasLayer(layer)) {
38 | this.#map.addLayer(this.#getLayer(layer));
39 | }
40 | });
41 | }
42 |
43 | /**
44 | * Create a new custom layer. If currently in edit mode also switch directly to it.
45 | * @returns {boolean} Success or not
46 | */
47 | createLayer() {
48 | var active_layer = this.#getActiveLayer();
49 |
50 | var layer_id = prompt("Unique new layer name");
51 |
52 | if (layer_id == null || layer_id == '' || layer_id in this.#custom_layers) {
53 | return false;
54 | }
55 |
56 | var new_layer = L.featureGroup(null, {
57 | pmIgnore: false
58 | });
59 |
60 | this.#custom_layers.set(layer_id, new_layer);
61 |
62 | // Refresh layer to controls
63 | this.#custom_layer_controls.addOverlay(new_layer, layer_id);
64 |
65 | // Display new layer and active
66 | new_layer.addTo(this.#map);
67 |
68 | this.#map.pm.setGlobalOptions({
69 | layerGroup: new_layer,
70 | markerStyle: {
71 | icon: Utils.getCustomIcon(layer_id.substring(0, 2))
72 | }
73 | });
74 |
75 | this.#interactive_map.addUserLayer(layer_id);
76 |
77 | if (this.isInEditMode()) {
78 | this.#interactive_map.removeUserLayer(this.#getActiveLayerId());
79 | this.#switchLayer(active_layer, new_layer);
80 | }
81 |
82 | return true;
83 | }
84 |
85 | /**
86 | * Disable the editing mode.
87 | */
88 | disableEditing() {
89 | L.PM.setOptIn(true);
90 |
91 | var active_layer = this.#getActiveLayer();
92 | if (active_layer) {
93 | L.PM.reInitLayer(active_layer);
94 | }
95 |
96 | this.#map.pm.disableDraw();
97 | this.#map.pm.disableGlobalEditMode();
98 | this.#map.pm.disableGlobalDragMode();
99 | this.#map.pm.disableGlobalRemovalMode();
100 | this.#map.pm.disableGlobalCutMode();
101 | this.#map.pm.disableGlobalRotateMode();
102 | this.#map.pm.toggleControls();
103 |
104 | this.#edit_mode = false;
105 | this.updateControls();
106 | this.#map.off('pm:create');
107 | this.#interactive_map.getShareMarker().turnOn();
108 | }
109 |
110 | /**
111 | * Enable the editing mode.
112 | * @returns Nothing
113 | */
114 | enableEditing() {
115 | if (this.#getActiveLayerCount() < 1) {
116 | if (!this.createLayer()) {
117 | return;
118 | }
119 | } else if (this.#getActiveLayerCount() > 1) {
120 | alert('Please select only one custom layer to edit');
121 | return;
122 | }
123 |
124 | var active_layer = this.#getActiveLayer();
125 | if (!active_layer) {
126 | return;
127 | }
128 |
129 | // Enable general editing for new markers
130 | L.PM.setOptIn(false);
131 | L.PM.reInitLayer(active_layer);
132 |
133 | this.#map.pm.toggleControls();
134 | this.#map.pm.setGlobalOptions({
135 | layerGroup: active_layer,
136 | markerStyle: {
137 | icon: Utils.getCustomIcon(this.#getActiveLayerId().substring(0, 2))
138 | }
139 | });
140 |
141 | this.#edit_mode = true;
142 | this.#hideControls();
143 | this.#interactive_map.getShareMarker().turnOff();
144 | Utils.setHistoryState(undefined, undefined, this.#website_subdir);
145 |
146 | this.#map.on('pm:create', event => {
147 | this.#createPopup(event.layer);
148 | });
149 | }
150 |
151 | /**
152 | * Export the currently active custom layer to a downloadable file.
153 | * @returns Nothing
154 | */
155 | exportLayer() {
156 | var active_layer = this.#getActiveLayer();
157 |
158 | if (!active_layer) {
159 | return;
160 | }
161 |
162 | Utils.download(this.#getActiveLayerId() + '.json', JSON.stringify(active_layer.toGeoJSON(), null, ' '));
163 | }
164 |
165 | /**
166 | * Check if the edit mode is currently active.
167 | * @returns {boolean} The current edit mode status
168 | */
169 | isInEditMode() {
170 | return this.#edit_mode;
171 | }
172 |
173 | /**
174 | * Show or hide the custom layer control box to the map.
175 | */
176 | updateControls() {
177 | if (this.#getLayerCount() > 0) {
178 | this.#showControls();
179 | } else {
180 | this.#hideControls();
181 | }
182 | }
183 |
184 | /**
185 | * Remove a custom layer
186 | * @returns Nothing
187 | */
188 | removeLayer() {
189 | if (!this.isInEditMode()) {
190 | return;
191 | }
192 |
193 | if (!confirm('Really delete the current custom marker layer?')) {
194 | return;
195 | }
196 |
197 | // should be only one because we're in edit mode
198 | var active_layer = this.#getActiveLayer();
199 |
200 | if (active_layer) {
201 | var active_layer_id = this.#getActiveLayerId();
202 | localStorage.removeItem(`${this.#website_subdir}:${active_layer_id}`);
203 | this.#custom_layer_controls.removeLayer(active_layer);
204 | this.#map.removeLayer(active_layer);
205 | this.#custom_layers.delete(active_layer_id);
206 |
207 | // Manually trigger the events that should fire in 'overlayremove'
208 | this.#interactive_map.removeUserLayer(active_layer_id);
209 | }
210 |
211 | this.disableEditing();
212 | }
213 |
214 | /**
215 | * Add an edit popup to a layer.
216 | * @param {L.Layer} layer The layer to add to
217 | */
218 | #createPopup(layer) {
219 | layer.bindPopup(() => {
220 | var html = document.createElement('div');
221 |
222 | var id_p = document.createElement('p');
223 |
224 | var id_input = document.createElement('input');
225 | id_input.setAttribute('type', 'text');
226 | id_input.id = layer._leaflet_id + ':id';
227 |
228 | var id_label = document.createElement('label');
229 | id_label.htmlFor = id_input.id;
230 | id_label.innerHTML = 'ID: ';
231 |
232 | if (!layer.feature) {
233 | layer.feature = {};
234 | layer.feature.type = 'Feature';
235 | }
236 |
237 | if (!layer.feature.properties) {
238 | layer.feature.properties = {};
239 | }
240 |
241 | if (layer.feature.properties.id) {
242 | id_input.value = layer.feature.properties.id;
243 | }
244 |
245 | id_input.addEventListener('change', event => {
246 | layer.feature.properties.id = event.target.value;
247 | });
248 |
249 | id_p.appendChild(id_label);
250 | id_p.appendChild(id_input);
251 | html.appendChild(id_p);
252 |
253 | var name_p = document.createElement('p');
254 |
255 | var name_input = document.createElement('input');
256 | name_input.setAttribute('type', 'text');
257 | name_input.id = layer._leaflet_id + ':name';
258 |
259 | var name_label = document.createElement('label');
260 | name_label.htmlFor = name_input.id;
261 | name_label.innerHTML = 'Name: ';
262 |
263 | if (layer.feature.properties.name) {
264 | name_input.value = layer.feature.properties.name;
265 | }
266 |
267 | name_input.addEventListener('change', event => {
268 | layer.feature.properties.name = event.target.value;
269 | });
270 |
271 | name_p.appendChild(name_label);
272 | name_p.appendChild(name_input);
273 | html.appendChild(name_p);
274 |
275 | var image_id_p = document.createElement('p');
276 |
277 | var image_id_input = document.createElement('input');
278 | image_id_input.setAttribute('type', 'text');
279 | image_id_input.id = layer._leaflet_id + ':image_id';
280 |
281 | var image_id_label = document.createElement('label');
282 | image_id_label.htmlFor = image_id_input.id;
283 | image_id_label.innerHTML = 'Image ID: ';
284 |
285 | if (layer.feature.properties.image_id) {
286 | image_id_input.value = layer.feature.properties.image_id;
287 | }
288 |
289 | image_id_input.addEventListener('change', event => {
290 | layer.feature.properties.image_id = event.target.value;
291 | });
292 |
293 | image_id_p.appendChild(image_id_label);
294 | image_id_p.appendChild(image_id_input);
295 | html.appendChild(image_id_p);
296 |
297 | var video_id_p = document.createElement('p');
298 |
299 | var video_id_input = document.createElement('input');
300 | video_id_input.setAttribute('type', 'text');
301 | video_id_input.id = layer._leaflet_id + ':video_id';
302 |
303 | var video_id_label = document.createElement('label');
304 | video_id_label.htmlFor = video_id_input.id;
305 | video_id_label.innerHTML = 'Video ID: ';
306 |
307 | if (layer.feature.properties.video_id) {
308 | video_id_input.value = layer.feature.properties.video_id;
309 | }
310 |
311 | video_id_input.addEventListener('change', event => {
312 | layer.feature.properties.video_id = event.target.value;
313 | });
314 |
315 | video_id_p.appendChild(video_id_label);
316 | video_id_p.appendChild(video_id_input);
317 | html.appendChild(video_id_p);
318 |
319 | var description_p = document.createElement('p');
320 |
321 | var description_input = document.createElement('input');
322 | description_input.setAttribute('type', 'text');
323 | description_input.id = layer._leaflet_id + ':description';
324 |
325 | var description_label = document.createElement('label');
326 | description_label.htmlFor = description_input.id;
327 | description_label.innerHTML = 'Description: ';
328 |
329 | if (layer.feature.properties.description) {
330 | description_input.value = layer.feature.properties.description;
331 | }
332 |
333 | description_input.addEventListener('change', event => {
334 | layer.feature.properties.description = event.target.value;
335 | });
336 |
337 | description_p.appendChild(description_label);
338 | description_p.appendChild(description_input);
339 | html.appendChild(description_p);
340 |
341 | return html;
342 | });
343 |
344 | layer.on('popupopen', event => {
345 | Utils.setHistoryState(undefined, undefined, this.#website_subdir);
346 | this.#interactive_map.getShareMarker().removeMarker();
347 | });
348 |
349 | layer.on('popupclose', event => {
350 | if (this.isInEditMode()) return;
351 |
352 | this.#interactive_map.getShareMarker().prevent();
353 | });
354 | }
355 |
356 | /**
357 | * Workaround to get active layers from a control
358 | * @param {L.Map} map The map
359 | */
360 | // https://stackoverflow.com/a/51484131
361 | #extendDefaultLayerControl(map) {
362 | // Add method to layer control class
363 | L.Control.Layers.include({
364 | getOverlays: function (args = {}) {
365 | var defaults = {
366 | only_active: false
367 | };
368 | var params = { ...defaults, ...args } // right-most object overwrites
369 |
370 | // create hash to hold all layers
371 | var control, layers;
372 | layers = {};
373 | control = this;
374 |
375 | // loop thru all layers in control
376 | control._layers.forEach(function (obj) {
377 | var layerName;
378 |
379 | // check if layer is an overlay
380 | if (obj.overlay) {
381 | // get name of overlay
382 | layerName = obj.name;
383 | // store whether it's present on the map or not
384 | if (params.only_active && !map.hasLayer(obj.layer)) {
385 | return;
386 | }
387 | return layers[layerName] = map.hasLayer(obj.layer);
388 | }
389 | });
390 |
391 | return layers;
392 | }
393 | });
394 | }
395 |
396 | /**
397 | * Get the currently active custom layer if only one is active.
398 | * @returns {L.Layer | undefined} Layer
399 | */
400 | #getActiveLayer() {
401 | if (this.#getActiveLayerCount() != 1) {
402 | return undefined;
403 | }
404 |
405 | return this.#custom_layers.get(this.#getActiveLayerId());
406 | }
407 |
408 | /**
409 | * Get the count of currently active custom layers
410 | * @returns {num} Count
411 | */
412 | #getActiveLayerCount() {
413 | var active_layers = this.#custom_layer_controls.getOverlays({
414 | only_active: true
415 | });
416 |
417 | return Object.keys(active_layers).length;
418 | }
419 |
420 | /**
421 | * Get the ID of the currently active custom layer
422 | * @returns {string} ID (== name for custom layers)
423 | */
424 | #getActiveLayerId() {
425 | var active_layers = this.#custom_layer_controls.getOverlays({
426 | only_active: true
427 | });
428 |
429 | return Object.keys(active_layers)[0];
430 | }
431 |
432 | /**
433 | * Get a custom layer.
434 | * @param {string} id ID (== name) of the custom layer
435 | * @returns {L.Layer} Layer
436 | */
437 | #getLayer(id) {
438 | return this.#custom_layers.get(id);
439 | }
440 |
441 | /**
442 | * Get the custom layer count.
443 | * @returns {int} Count
444 | */
445 | #getLayerCount() {
446 | return this.#custom_layers.size;
447 | }
448 |
449 | /**
450 | * Check if the custom layer exists.
451 | * @param {string} id ID (== name) of the custom layer
452 | * @returns {boolean} True or false
453 | */
454 | #hasLayer(id) {
455 | return this.#custom_layers.has(id);
456 | }
457 |
458 | /**
459 | * Hide the custom layer controls
460 | */
461 | #hideControls() {
462 | this.#map.removeControl(this.#custom_layer_controls);
463 | }
464 |
465 | /**
466 | * Load the current custom layer state from local storage.
467 | */
468 | #loadFromStorage() {
469 | if (localStorage.getItem(`${this.#website_subdir}:custom_layers`)) {
470 | JSON.parse(localStorage.getItem(`${this.#website_subdir}:custom_layers`)).forEach(id => {
471 | if (!localStorage.getItem(`${this.#website_subdir}:${id}`)) {
472 | return;
473 | }
474 |
475 | var geojson = JSON.parse(localStorage.getItem(`${this.#website_subdir}:${id}`));
476 |
477 | var geojson_layer = L.geoJSON(geojson, {
478 | pointToLayer: (feature, latlng) => {
479 | return L.marker(latlng, {
480 | icon: Utils.getCustomIcon(id.substring(0, 2)),
481 | riseOnHover: true
482 | });
483 | },
484 | onEachFeature: (feature, l) => {
485 | this.#createPopup(l);
486 | },
487 | pmIgnore: false
488 | });
489 | this.#custom_layers.set(id, geojson_layer);
490 | });
491 | }
492 | }
493 |
494 | /**
495 | * Save the current custom layer state to local storage.
496 | * @returns Nothing
497 | */
498 | #saveToStorage() {
499 | var array = new Array();
500 |
501 | if (this.#getLayerCount() < 1) {
502 | localStorage.removeItem(`${this.#website_subdir}:custom_layers`);
503 | return;
504 | }
505 |
506 | this.#custom_layers.forEach((layer, id) => {
507 | localStorage.setItem(`${this.#website_subdir}:${id}`, JSON.stringify(layer.toGeoJSON()));
508 | array.push(id);
509 | });
510 |
511 | localStorage.setItem(`${this.#website_subdir}:custom_layers`, JSON.stringify(array));
512 | }
513 |
514 | /**
515 | * Show the custom layer controls.
516 | */
517 | #showControls() {
518 | // Don't know why I have to create a new control but adding the old one is giving me an exception
519 | this.#custom_layer_controls = new L.Control.Layers(null, Object.fromEntries(this.#custom_layers), {
520 | collapsed: false
521 | });
522 |
523 | this.#map.addControl(this.#custom_layer_controls);
524 | }
525 |
526 | /**
527 | * Switch the currently active custom layer.
528 | * @param {L.Layer} old_layer Old Layer
529 | * @param {L.Layer} new_layer New layer
530 | */
531 | #switchLayer(old_layer, new_layer) {
532 | // We should be in edit mode here
533 | this.#map.off('pm:create');
534 |
535 | // Disable current active layer
536 | this.#map.removeLayer(old_layer);
537 | L.PM.setOptIn(true);
538 | L.PM.reInitLayer(old_layer);
539 |
540 | L.PM.setOptIn(false);
541 | L.PM.reInitLayer(new_layer);
542 |
543 | this.#map.on('pm:create', event => {
544 | this.#createPopup(event.layer);
545 | });
546 | }
547 | }
548 |
--------------------------------------------------------------------------------
/common/icons/marker.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/common/interactive_layer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A general interactive map layer which includes marker and polygons created from geoJSON features.
3 | */
4 | class InteractiveLayer {
5 | #create_checkbox;
6 | #ignore_next_resize = new Set(); // set of entries to skip initial resize call
7 | #feature_group;
8 | #geojsons = new Array();
9 | #highlighted_layers = new Array();
10 | #interactive_map;
11 | #is_default;
12 | #layers = new Map();
13 | #polygon_style_highlights = new Map();
14 | #resize_observer = new ResizeObserver(entries => {
15 | for (const entry of entries) {
16 | let feature_id = entry.target.closest('.popup-id').id.split(':')[2];
17 |
18 | // The observer also fires when it gets added so ignore that resize 'event'
19 | // or else we'll get a infinite loop
20 | if (this.#ignore_next_resize.has(feature_id)) {
21 | this.#ignore_next_resize.delete(feature_id);
22 | continue;
23 | }
24 |
25 | this.#getLayers(feature_id).forEach(layer => {
26 | if (layer.isPopupOpen()) {
27 | this.#resize_observer.unobserve(entry.target);
28 |
29 | // This changes the content of the element and the observer looses track of it because of that
30 | // That's why we're re-adding the observer
31 | layer.getPopup().update();
32 |
33 | // The observer also fires when it gets added so ignore that resize 'event'
34 | // or else we'll get a infinite loop
35 | this.#ignore_next_resize.add(feature_id);
36 | for (const element of document.getElementById(`popup:${this.id}:${feature_id}`).getElementsByClassName('popup-media')) {
37 | this.#resize_observer.observe(element);
38 | }
39 | }
40 | });
41 | }
42 | });
43 | #sidebar;
44 | #sidebar_list_html = undefined;
45 | #website_subdir;
46 |
47 | #default_onEachFeature = function (feature, layer) { };
48 | #default_pointToLayer = function (feature, latlng) {
49 | return L.marker(latlng, {
50 | icon: Utils.getCustomIcon(this.id),
51 | riseOnHover: true
52 | });
53 | };
54 | #default_polygon_style = function (feature) { return {}; };
55 | #default_polygon_style_highlight = function () {
56 | return {
57 | opacity: 1.0,
58 | fillOpacity: 0.7
59 | }
60 | };
61 | #default_sidebar_icon_html = function () {
62 | return ``;
63 | };
64 |
65 | /**
66 | * A layer containing marker and polygons created from geoJSON features.
67 | * Multiple features can form a logical combined feature by having the same feature ID.
68 | * @param {string} id Unique layer id
69 | * @param {string} geojson geoJSON including features to add to the layer
70 | * @param {InteractiveMap} interactive_map Interactive map
71 | * @param {object} [args] Object containing various optional arguments
72 | * @param {string} [args.name=this.id] Human readable display name of the layer. Default: `this.id`
73 | * @param {boolean} [args.create_checkbox=false] Create a sidebar with a trackable list. Default: false
74 | * @param {boolean} [args.create_feature_popup=false] Create a popup for the first batch of geoJSON features. Default: false
75 | * @param {boolean} [args.is_default=false] Show this layer by default if a user visits the map for the first time. Default: false
76 | * @param {string | function} [args.sidebar_icon_html=function () { return ``; }] A html string for the sidebar icon. Can be a function which returns a html string. The function has access to values of this layer e.g. the `this.id`.
77 | * @param {function} [args.onEachFeature=function (feature, layer) { }] A function with stuff to do on each feature. Has access to values of this layer e.g. `this.id`. Default: `function (feature, layer) { }`
78 | * @param {function} [args.pointToLayer=function (feature, latlng) { return L.marker(latlng, { icon: Utils.getCustomIcon(this.id), riseOnHover: true }); }] A function describing what to do when putting a geoJSON point to a layer.
79 | * @param {function} [args.coordsToLatLng=L.GeoJSON.coordsToLatLng] A function describing converting geoJSON coordinates to leaflets latlng.
80 | * @param {object | function} [args.polygon_style=function (feature) { return {}; }] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
81 | * @param {object | function} [args.polygon_style_highlight=function () { return { opacity: 1.0, fillOpacity: 0.7 }}] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
82 | * @param {L.LayerGroup} [args.feature_group=L.featureGroup.subGroup(this.#interactive_map.getClusterGroup())] The group all geoJson features get added to. Defaults to the default marker cluster.
83 | */
84 | constructor(id, geojson, interactive_map, args) {
85 | let defaults = {
86 | name: id,
87 | create_checkbox: false,
88 | create_feature_popup: false,
89 | is_default: false,
90 | sidebar_icon_html: this.#default_sidebar_icon_html,
91 | pointToLayer: this.#default_pointToLayer,
92 | onEachFeature: this.#default_onEachFeature,
93 | polygon_style: this.#default_polygon_style,
94 | polygon_style_highlight: this.#default_polygon_style_highlight,
95 | coordsToLatLng: L.GeoJSON.coordsToLatLng
96 | };
97 |
98 | let params = { ...defaults, ...args };
99 |
100 | this.id = id;
101 | this.name = params.name;
102 | this.#interactive_map = interactive_map;
103 |
104 | this.#create_checkbox = params.create_checkbox;
105 | this.#is_default = params.is_default;
106 | this.#feature_group = params.feature_group ? params.feature_group : L.featureGroup.subGroup(this.#interactive_map.getClusterGroup());
107 | this.#sidebar = this.#interactive_map.getSidebar();
108 | this.#website_subdir = this.#interactive_map.getWebsiteSubdir();
109 |
110 | if (this.#create_checkbox) {
111 | this.#sidebar_list_html = this.#createSidebarTab(params.sidebar_icon_html);
112 | }
113 |
114 | this.addGeoJson(geojson, {
115 | create_feature_popup: params.create_feature_popup,
116 | pointToLayer: params.pointToLayer,
117 | onEachFeature: params.onEachFeature,
118 | polygon_style: params.polygon_style,
119 | polygon_style_highlight: params.polygon_style_highlight,
120 | coordsToLatLng: params.coordsToLatLng
121 | });
122 | }
123 |
124 | /**
125 | * Add another geoJSON to this layer group.
126 | * @param {string} geojson geoJSON containing the features to add
127 | * @param {object} [args] Optional arguments
128 | * @param {boolean} [args.create_feature_popup=false] Create a popup for each feature
129 | * @param {function} [args.onEachFeature=function (feature, layer) { }] A function with stuff to do on each feature. Has access to values of this layer e.g. `this.id`. Default: `function (feature, layer) { }`
130 | * @param {function} [args.pointToLayer=function (feature, latlng) { return L.marker(latlng, { icon: Utils.getCustomIcon(this.id), riseOnHover: true }); }] A function describing what to do when putting a geoJSON point to a layer.
131 | * @param {function} [args.coordsToLatLng=L.GeoJSON.coordsToLatLng] A function describing converting geoJSON coordinates to leaflets latlng.
132 | * @param {object | function} [args.polygon_style=function (feature) { return {}; }] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
133 | * @param {object | function} [args.polygon_style_highlight=function () { return { opacity: 1.0, fillOpacity: 0.7 }}] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
134 | */
135 | addGeoJson(geojson, args) {
136 | let defaults = {
137 | create_feature_popup: false,
138 | pointToLayer: this.#default_pointToLayer,
139 | onEachFeature: this.#default_onEachFeature,
140 | polygon_style: this.#default_polygon_style,
141 | polygon_style_highlight: this.#default_polygon_style_highlight,
142 | coordsToLatLng: L.GeoJSON.coordsToLatLng
143 | };
144 |
145 | let params = { ...defaults, ...args };
146 | var onEachFeature = params.onEachFeature.bind(this);
147 |
148 | var geojson_layer = L.geoJSON(geojson, {
149 | pointToLayer: params.pointToLayer.bind(this),
150 | onEachFeature: (feature, layer) => {
151 | if (this.#create_checkbox) {
152 | this.#createSidebarCheckbox(feature);
153 | }
154 |
155 | if (params.create_feature_popup) {
156 | this.#createFeaturePopup(feature, layer);
157 | }
158 |
159 | onEachFeature(feature, layer);
160 |
161 | this.#setFeature(feature.properties.id, layer);
162 | },
163 | coordsToLatLng: params.coordsToLatLng.bind(this),
164 | style: params.polygon_style
165 | });
166 |
167 | this.#geojsons.push(geojson_layer);
168 |
169 | if (params.polygon_style_highlight instanceof Function) {
170 | this.#polygon_style_highlights.set(geojson_layer, params.polygon_style_highlight.bind(this));
171 | } else {
172 | this.#polygon_style_highlights.set(geojson_layer, params.polygon_style_highlight);
173 | }
174 |
175 | this.#feature_group.addLayer(geojson_layer);
176 | geojson_layer.eachLayer(layer => {
177 | layer.feature._origin = this.#feature_group.getLayerId(geojson_layer);
178 | });
179 | }
180 |
181 | /**
182 | * Get a map of all layers.
183 | * @returns Map
184 | */
185 | getAllLayers() {
186 | return this.#layers;
187 | }
188 |
189 | /**
190 | * Get the group layer which contains all markers and polygons.
191 | * @returns L.LayerGroup
192 | */
193 | getGroup() {
194 | return this.#feature_group;
195 | }
196 |
197 | /**
198 | * Get the outer bounds of this entire layer group.
199 | * @returns L.LatLngBounds
200 | */
201 | getGroupBounds() {
202 | var bounds = L.latLngBounds();
203 |
204 | this.#layers.forEach((layers, key) => {
205 | bounds.extend(this.#getLayerBounds(key));
206 | });
207 |
208 | return bounds;
209 | }
210 |
211 | /**
212 | * Check if this layer group has a feature.
213 | * @param {string} id Feature ID
214 | * @returns boolean
215 | */
216 | hasFeature(id) {
217 | return this.#layers.has(id);
218 | }
219 |
220 | /**
221 | * Highlight a feature.
222 | * @param {string} id Feature ID
223 | */
224 | highlightFeature(id) {
225 | this.#getLayers(id).forEach(layer => {
226 | if (layer instanceof L.Path) {
227 | this.#highlightPolygon(layer);
228 | } else {
229 | // Marker
230 | this.#highlightPoint(layer);
231 | }
232 | });
233 |
234 | this.#interactive_map.getMap().on('click', () => { this.removeFeatureHighlight(id); });
235 | }
236 |
237 | /**
238 | * Check if this is a lay which should be visible by default.
239 | * @returns boolean
240 | */
241 | isDefault() {
242 | return this.#is_default;
243 | }
244 |
245 | /**
246 | * Remove all currently active highlights for this layer group.
247 | */
248 | removeAllHighlights() {
249 | this.#highlighted_layers.forEach(layer => {
250 | if (layer instanceof L.Path) {
251 | this.#removePolygonHighlight(layer);
252 | } else {
253 | this.#removePointHighlight(layer);
254 | }
255 | });
256 |
257 | this.#highlighted_layers = [];
258 | this.#interactive_map.getMap().off('click', this.removeAllHighlights, this);
259 | }
260 |
261 | /**
262 | * Remove a active highlight for a feature.
263 | * @param {string} id Feature ID
264 | */
265 | removeFeatureHighlight(id) {
266 | // Remove from the same array that gets iterated
267 | // https://stackoverflow.com/a/24813338
268 | var layers = this.#getLayers(id);
269 |
270 | for (const index of this.#reverseKeys(this.#highlighted_layers)) {
271 | var layer = this.#highlighted_layers[index];
272 |
273 | if (!layers.includes(layer)) {
274 | continue;
275 | }
276 |
277 | if (layer instanceof L.Path) {
278 | this.#removePolygonHighlight(layer);
279 | this.#highlighted_layers.splice(index, 1);
280 | } else {
281 | this.#removePointHighlight(layer);
282 | this.#highlighted_layers.splice(index, 1);
283 | }
284 | }
285 |
286 | this.#interactive_map.getMap().off('click', () => { this.removeFeatureHighlight(id); });
287 | }
288 |
289 | /**
290 | * Remove a layer from the layer group.
291 | * @param {L.Layer} layer L.Layer to remove.
292 | */
293 | removeLayer(layer) {
294 | this.#getGroupForEdit(layer).removeLayer(layer);
295 | }
296 |
297 | /**
298 | * Set the amount of columns of the sidebar grid.
299 | * @returns Nothing
300 | */
301 | setSidebarColumnCount() {
302 | if (!this.#sidebar_list_html) {
303 | return;
304 | }
305 |
306 | var length = 4;
307 | var columns = 1;
308 |
309 | this.#layers.forEach((layer, id) => {
310 | if (id.length > length) {
311 | length = id.length;
312 | }
313 | });
314 |
315 | if (length < 5) {
316 | columns = 3;
317 | } else if (length < 15) {
318 | columns = 2;
319 | }
320 |
321 | this.#sidebar_list_html.setAttribute('style', `grid-template-columns: repeat(${columns}, auto)`);
322 | }
323 |
324 | /**
325 | * Show this layer group on the map.
326 | */
327 | show() {
328 | this.getGroup().addTo(this.#interactive_map.getMap());
329 | }
330 |
331 | /**
332 | * Zoom to this layer group.
333 | */
334 | zoomTo() {
335 | this.#interactive_map.zoomToBounds(this.getGroupBounds());
336 | }
337 |
338 | /**
339 | * Zoom to a specific feature.
340 | * @param {string} id Feature ID
341 | * @returns Nothing
342 | */
343 | zoomToFeature(id) {
344 | var layers = this.#getLayers(id);
345 |
346 | if (layers.length > 1) {
347 | // Multiple features
348 | this.#interactive_map.zoomToBounds(this.#getLayerBounds(id));
349 | return;
350 | }
351 |
352 | var layer = layers[0];
353 |
354 | if (layer instanceof L.Path) {
355 | // Polygon
356 | this.#interactive_map.zoomToBounds(this.#getLayerBounds(id));
357 | return;
358 | }
359 |
360 | var group = this.#getGroupForEdit(layer);
361 |
362 | if (group instanceof L.MarkerClusterGroup && group.hasLayer(layer)) {
363 | // Single Point
364 | group.zoomToShowLayer(layer, () => {
365 | // Zoom in further if we can
366 | window.setTimeout(() => {
367 | if (this.#interactive_map.getMap().getZoom() < this.#interactive_map.getMaxZoom()) {
368 | this.#interactive_map.zoomToBounds(this.#getLayerBounds(id));
369 | }
370 | }, 300);
371 | });
372 | return;
373 | }
374 |
375 | // not visible
376 | this.#interactive_map.zoomToBounds(this.#getLayerBounds(id));
377 | }
378 |
379 | /**
380 | * Add a layer back to the group it belongs to. That should be the original L.geoJSON but has to be the the parent MarkerCluster if the geoJSON was added to a marker cluster.
381 | * @param {L.Layer} layer L.Layer
382 | */
383 | #addLayer(layer) {
384 | this.#getGroupForEdit(layer).addLayer(layer);
385 | }
386 |
387 | /**
388 | * Create a popup for a feature.
389 | * @param {object} feature Original feature object
390 | * @param {L.Layer} layer Resulting layer
391 | */
392 | #createFeaturePopup(feature, layer) {
393 | let content = function (layer) {
394 | var html = document.createElement('div');
395 | html.className = 'popup-id';
396 | html.id = `popup:${this.id}:${feature.properties.id}`;
397 |
398 | var title = document.createElement('h2');
399 | title.className = 'popup-title';
400 | title.innerHTML = feature.properties.name ? feature.properties.name : feature.properties.id;
401 |
402 | html.appendChild(title);
403 |
404 | let media_html = getPopupMedia(feature, this.id);
405 | if (media_html) {
406 | html.appendChild(media_html);
407 | }
408 |
409 | if (feature.properties.description) {
410 | var description = document.createElement('p');
411 | description.className = 'popup-description';
412 | var span = document.createElement('span');
413 | span.setAttribute('style', 'white-space: pre-wrap');
414 | span.appendChild(document.createTextNode(feature.properties.description));
415 | description.appendChild(span);
416 |
417 | html.appendChild(description);
418 | }
419 |
420 | // Checkbox requires a global counterpart
421 | if (this.#create_checkbox && document.getElementById(this.id + ':' + feature.properties.id)) {
422 | var label = document.createElement('label');
423 | label.className = 'popup-checkbox is-fullwidth';
424 |
425 | var label_text = document.createTextNode('Hide this marker');
426 |
427 | var checkbox = document.createElement('input');
428 | checkbox.type = 'checkbox';
429 |
430 | if (localStorage.getItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`)) {
431 | checkbox.checked = true;
432 | }
433 |
434 | checkbox.addEventListener('change', element => {
435 | if (element.target.checked) {
436 | // check global checkbox
437 | document.getElementById(this.id + ':' + feature.properties.id).checked = true;
438 | // remove all with ID from map
439 | this.#getLayers(feature.properties.id).forEach(l => {
440 | this.#getGroupForEdit(l).removeLayer(l);
441 | });
442 | // save to localStorage
443 | localStorage.setItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`, true);
444 | } else {
445 | // uncheck global checkbox
446 | document.getElementById(this.id + ':' + feature.properties.id).checked = false;
447 | // add all with ID to map
448 | this.#getLayers(feature.properties.id).forEach(l => {
449 | this.#addLayer(l);
450 | });
451 | // remove from localStorage
452 | localStorage.removeItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`);
453 | }
454 | });
455 |
456 | label.appendChild(checkbox);
457 | label.appendChild(label_text);
458 | html.appendChild(label);
459 | }
460 |
461 | return html;
462 | }.bind(this);
463 |
464 | layer.bindPopup(content, { maxWidth: "auto" });
465 |
466 | layer.on('popupopen', event => {
467 | this.#interactive_map.getShareMarker().removeMarker();
468 | Utils.setHistoryState(this.id, feature.properties.id);
469 |
470 | // Listen for size changes and update when it does
471 | for (const entry of document.getElementById(`popup:${this.id}:${feature.properties.id}`).getElementsByClassName('popup-media')) {
472 | this.#resize_observer.observe(entry);
473 | }
474 | }, this);
475 |
476 | layer.on('popupclose', event => {
477 | this.#interactive_map.getShareMarker().prevent();
478 | Utils.setHistoryState(undefined, undefined, this.#website_subdir);
479 | this.#resize_observer.disconnect();
480 | }, this);
481 | }
482 |
483 | /**
484 | * Create a sidebar checkbox for a feature if it doesn't already exist.
485 | * @param {object} feature Original feature object
486 | */
487 | #createSidebarCheckbox(feature) {
488 | if (!document.getElementById(this.id + ':' + feature.properties.id)) {
489 | var list_entry = document.createElement('li');
490 | list_entry.className = 'flex-grow-1';
491 |
492 | var leave_function = () => { this.removeFeatureHighlight(feature.properties.id); };
493 | list_entry.addEventListener('mouseenter', () => { this.highlightFeature(feature.properties.id); });
494 | list_entry.addEventListener('mouseleave', leave_function);
495 |
496 | var checkbox = document.createElement('input');
497 | checkbox.type = "checkbox";
498 | checkbox.id = this.id + ':' + feature.properties.id;
499 | checkbox.className = 'flex-grow-0';
500 |
501 | var label = document.createElement('label')
502 | label.appendChild(document.createTextNode(feature.properties.id + ' '));
503 | label.htmlFor = checkbox.id;
504 | label.className = 'flex-grow-1';
505 |
506 | var icon = document.createElement('i');
507 | icon.className = 'fas fa-crosshairs fa-xs';
508 |
509 | var locate_button = document.createElement('button');
510 | locate_button.innerHTML = icon.outerHTML;
511 | locate_button.addEventListener('click', () => {
512 | // Close sidebar if it spans over the complete view
513 | if (window.matchMedia('(max-device-width: 767px)').matches) {
514 | this.#sidebar.close();
515 | }
516 |
517 | // rewrite url for easy copy pasta
518 | Utils.setHistoryState(this.id, feature.properties.id);
519 |
520 | this.#interactive_map.removeAllHighlights();
521 | this.highlightFeature(feature.properties.id);
522 | this.zoomToFeature(feature.properties.id);
523 |
524 | // tmp disable after button click
525 | list_entry.removeEventListener('mouseleave', leave_function);
526 | window.setTimeout(() => {
527 | list_entry.addEventListener('mouseleave', leave_function);
528 | }, 3000);
529 | });
530 | locate_button.className = 'flex-grow-0';
531 |
532 | list_entry.appendChild(checkbox);
533 | list_entry.appendChild(label);
534 | list_entry.appendChild(locate_button);
535 | this.#sidebar_list_html.appendChild(list_entry);
536 |
537 | // hide if checked previously
538 | if (localStorage.getItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`)) {
539 | checkbox.checked = true;
540 | }
541 |
542 | // watch global checkbox
543 | if (document.getElementById(this.id + ':' + feature.properties.id) != null) {
544 | // if not a marker try to assign to the same checkbox as the corresponding marker
545 | document.getElementById(this.id + ':' + feature.properties.id).addEventListener('change', element => {
546 | if (element.target.checked) {
547 | // remove all layers with ID from map
548 | this.#getLayers(feature.properties.id).forEach(l => {
549 | this.#getGroupForEdit(l).removeLayer(l);
550 | });
551 | // save to localStorage
552 | localStorage.setItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`, true);
553 | } else {
554 | // add all layers with ID to map
555 | this.#getLayers(feature.properties.id).forEach(l => {
556 | this.#addLayer(l);
557 | });
558 | // remove from localStorage
559 | localStorage.removeItem(`${this.#website_subdir}:${this.id}:${feature.properties.id}`);
560 | }
561 | });
562 | }
563 | }
564 | }
565 |
566 | /**
567 | * Create a sidebar tab for this layer group.
568 | * @param {string} icon_html Icon html
569 | * @returns HTMLUListElement
570 | */
571 | #createSidebarTab(icon_html) {
572 | var list = document.createElement('ul');
573 | list.className = 'collectibles_list';
574 |
575 | var icon = icon_html;
576 |
577 | if (icon_html instanceof Function) {
578 | icon = icon_html.bind(this);
579 | icon = icon();
580 | }
581 |
582 | // Add list to sidebar
583 | this.#sidebar.addPanel({
584 | id: this.id,
585 | tab: icon,
586 | title: this.name,
587 | pane: '
' // placeholder to get a proper pane
588 | });
589 | document.getElementById(this.id).appendChild(list);
590 |
591 | return list;
592 | }
593 |
594 | /**
595 | * Get the layer group for adding and removing layers. This can differ from their original layer group.
596 | * @param {L.Layer} layer Layer
597 | * @returns L.LayerGroup
598 | */
599 | #getGroupForEdit(layer) {
600 | // The group is the GeoJSON FeatureGroup
601 | var group = this.#feature_group.getLayer(layer.feature._origin);
602 | var parent_group = this.#feature_group;
603 |
604 | // Subgroups can be nested, get top level
605 | while (parent_group instanceof L.FeatureGroup.SubGroup) {
606 | parent_group = this.#feature_group.getParentGroup();
607 | }
608 |
609 | // There's an issue with marker from a geojson with marker cluster so we have use parent cluster then
610 | if (parent_group instanceof L.MarkerClusterGroup) {
611 | group = parent_group;
612 | }
613 |
614 | return group;
615 | }
616 |
617 | /**
618 | * Get all layers with a specific feature ID.
619 | * @param {string} id ID of features to retrieve.
620 | * @returns Array of layers with that feature ID.
621 | */
622 | #getLayers(id) {
623 | return this.#layers.get(id);
624 | }
625 |
626 | /**
627 | * Get the bounds of all layers with a feature ID
628 | * @param {string} id Feature ID
629 | * @returns L.LatLngBounds
630 | */
631 | #getLayerBounds(id) {
632 | var bounds = L.latLngBounds();
633 |
634 | this.#getLayers(id).forEach(layer => {
635 | if (layer instanceof L.Polyline) {
636 | // Polygons
637 | bounds.extend(layer.getBounds());
638 | } else if (layer instanceof L.Circle) {
639 | // FIXME: This somehow fails:
640 | // bounds.extend(layer.getBounds());
641 | // Do this in the meantime:
642 | var position = layer._latlng;
643 | var radius = layer._mRadius;
644 | bounds.extend([[position.lat - radius, position.lng - radius], [position.lat + radius, position.lng + radius]]);
645 | } else {
646 | // Point
647 | bounds.extend([layer.getLatLng()]);
648 | }
649 | });
650 |
651 | return bounds;
652 | }
653 |
654 | /**
655 | * Highlight a point (marker)
656 | * @param {L.Layer} layer Marker
657 | * @returns Nothing
658 | */
659 | #highlightPoint(layer) {
660 | if (this.#highlighted_layers.includes(layer)) {
661 | return;
662 | }
663 |
664 | var icon = layer.getIcon();
665 | icon.options.html = `
${icon.options.html}`;
666 | layer.setIcon(icon);
667 |
668 | this.#highlighted_layers.push(layer);
669 | }
670 |
671 | /**
672 | * Highlight a polygon
673 | * @param {L.Layer} layer Polygon
674 | * @returns Nothing
675 | */
676 | #highlightPolygon(layer) {
677 | if (this.#highlighted_layers.includes(layer)) {
678 | return;
679 | }
680 |
681 | this.#polygon_style_highlights.forEach((style, geojson) => {
682 | if (geojson.hasLayer(layer)) {
683 | if (style instanceof Function) {
684 | layer.setStyle(style(layer.feature));
685 | } else {
686 | layer.setStyle(style);
687 | }
688 | }
689 | });
690 |
691 |
692 | if (!L.Browser.ie && !L.Browser.opera && !L.Browser.edge) {
693 | layer.bringToFront();
694 | }
695 |
696 | this.#highlighted_layers.push(layer);
697 | }
698 |
699 | /**
700 | * Remove a highlight from a point (marker)
701 | * @param {L.Layer} layer Marker
702 | * @returns Nothing
703 | */
704 | #removePointHighlight(layer) {
705 | if (!this.#highlighted_layers.includes(layer)) {
706 | return;
707 | }
708 |
709 | var icon = layer.getIcon();
710 | icon.options.html = icon.options.html.replace('
', '');
711 | layer.setIcon(icon);
712 | }
713 |
714 | /**
715 | * Remove a highlight from a polygon. If no layer is specified the whole geoJson will remove the highlight.
716 | * @param {L.Layer} [layer=undefined] Polygon
717 | * @returns Nothing
718 | */
719 | #removePolygonHighlight(layer = undefined) {
720 | if (layer) {
721 | if (!this.#highlighted_layers.includes(layer)) {
722 | return;
723 | }
724 |
725 | this.#geojsons.forEach(geojson => {
726 | if (geojson.hasLayer(layer)) {
727 | geojson.resetStyle(layer);
728 | return;
729 | }
730 | });
731 | return;
732 | }
733 |
734 | this.#geojsons.forEach(geojson => {
735 | geojson.resetStyle(layer);
736 | });
737 | }
738 |
739 | // For removeFeatureHighlight()
740 | // https://stackoverflow.com/a/24813338
741 | * #reverseKeys(arr) {
742 | var key = arr.length - 1;
743 |
744 | while (key >= 0) {
745 | yield key;
746 | key -= 1;
747 | }
748 | }
749 |
750 | /**
751 | * Map a layer to a feature ID.
752 | * @param {string} id Feature ID
753 | * @param {L.Layer} layer Feature layer
754 | */
755 | #setFeature(id, layer) {
756 | if (!this.#layers.has(id)) {
757 | this.#layers.set(id, new Array());
758 | }
759 |
760 | this.#layers.get(id).push(layer);
761 | }
762 | }
763 |
--------------------------------------------------------------------------------
/common/interactive_map.js:
--------------------------------------------------------------------------------
1 | class InteractiveMap {
2 | #cluster_group;
3 | #common_attribution = `
4 | Leaflet under BSD2 .
5 | Leaflet.markercluster under MIT .
6 | Leaflet.FeatureGroup.SubGroup under BSD2 .
7 | leaflet-sidebar-v2 under MIT .
8 | Leaflet-Geoman under MIT .
9 | Icons from Font Awesome under CCA4 .
10 | `
11 | #custom_layers;
12 | #interactive_layers = new Map();
13 | #map;
14 | #overlay_maps = new Object();
15 | #share_marker;
16 | #sidebar;
17 | #tile_layers = new Object();
18 | #user_layers;
19 | #website_subdir = '';
20 |
21 | /**
22 | *
23 | * @param {string} id ID of the html div this map gets added to
24 | * @param {object} [args] Optional arguments
25 | * @param {string} [args.attribution=''] General attribution html list about used stuff. Wrap every attribution in its own ` `
26 | * @param {int} [args.max_good_zoom=5] Specify the maximum good looking zoom which will be used for location events
27 | * @param {int} [args.max_map_zoom=8] Maximum zoom the user can zoom to even if it looks ugly. Use a reasonable value here
28 | * @param {string} [args.website_source] Where to find the source of this interactive map
29 | * @param {string} [args.website_subdir] Subdir this interactive map will be hosted in
30 | */
31 | constructor(id, args) {
32 | let defaults = {
33 | maxClusterRadius: 20,
34 | attribution: '',
35 | max_good_zoom: 5,
36 | website_source: '',
37 | website_subdir: '',
38 | max_map_zoom: 8
39 | }
40 | let params = { ...defaults, ...args };
41 |
42 | this.#map = L.map(id, {
43 | crs: L.CRS.Simple,
44 | maxZoom: params.max_map_zoom,
45 | });;
46 | this.MAX_ZOOM = params.max_good_zoom;
47 | this.#website_subdir = params.website_subdir;
48 |
49 | this.#cluster_group = L.markerClusterGroup({
50 | spiderfyOnMaxZoom: true,
51 | maxClusterRadius: params.maxClusterRadius
52 | }).addTo(this.#map);
53 |
54 | this.#setUpToolbar();
55 | this.#setUpSidebar(params.attribution, params.website_source, this.#website_subdir);
56 |
57 | this.#user_layers = JSON.parse(localStorage.getItem(`${this.#website_subdir}:user_layers`));
58 | this.#share_marker = new ShareMarker(this);
59 | this.#custom_layers = new CustomLayers(this);
60 |
61 | this.#map.on('overlayadd', event => {
62 | this.addUserLayer(event.name);
63 | });
64 | this.#map.on('overlayremove ', event => {
65 | this.removeUserLayer(event.name);
66 |
67 | if (this.hasLayer(this.#getLayerByName(event.name))) {
68 | this.#getLayerByName(event.name).removeAllHighlights();
69 | }
70 | });
71 | }
72 |
73 | /**
74 | * Add a new background tile layer.
75 | *
76 | * Use tiled maps if possible, allows better zooming
77 | * Make sure tiling scheme is growing downwards!
78 | * https://github.com/commenthol/gdal2tiles-leaflet
79 | * https://github.com/Leaflet/Leaflet/issues/4333#issuecomment-199753161
80 | *
81 | * `./gdal2tiles.py -l -p raster -w none -z 3-5 full_map.jpg map_tiles`
82 | * @param {string} name Display name of this layer, also the ID
83 | * @param {object} [args] Optional arguments. Most likely you want to adapt `minNativeZoom` and `maxNativeZoom` to the generated tiles
84 | * @param {int} [args.minNativeZoom=3] The minimal zoom that can be found in the path
85 | * @param {int} [args.maxNativeZoom=5] The maximal zoom that can be found in the path
86 | * @param {string} [args.attribution=''] Tile layer specific attribution
87 | * @param {string} [url=map_tiles/{z}/{x}/{y}.png] Path to tile images
88 | */
89 | addTileLayer(name, args, url = `map_tiles/{z}/{x}/{y}.png`) {
90 | let defaults = {
91 | minNativeZoom: 3,
92 | maxNativeZoom: 5,
93 | noWrap: true,
94 | detectRetina: true,
95 | bounds: this.#getTileLayerBounds(url),
96 | }
97 |
98 | console.log(defaults.bounds)
99 |
100 | let params = { ...defaults, ...args };
101 | params.maxNativeZoom = L.Browser.retina ? params.maxNativeZoom - 1 : params.maxNativeZoom; // 1 level LOWER for high pixel ratio device.
102 |
103 | var tile_layer = new L.tileLayer(url, params);
104 |
105 | // Make first base layer visible by default
106 | if (Object.keys(this.#tile_layers).length < 1) {
107 | tile_layer.addTo(this.#map);
108 | }
109 |
110 | this.#tile_layers[name] = tile_layer;
111 | }
112 |
113 | /**
114 | * Add a new interactive layer to the interactive map from a geoJSON. Returns the layer to be able to e.g. add more geoJSONS.
115 | * @param {string} id Unique layer id
116 | * @param {string} geojson geoJSON with features to add
117 | * @param {object} [args] Optional arguments
118 | * @param {string} [args.name=this.id] Human readable display name of the layer. Default: `this.id`
119 | * @param {boolean} [args.create_checkbox=false] Create a sidebar with a trackable list. Default: false
120 | * @param {boolean} [args.create_feature_popup=false] Create a popup for the first batch of geoJSON features. Default: false
121 | * @param {boolean} [args.is_default=false] Show this layer by default if a user visits the map for the first time. Default: false
122 | * @param {string | function} [args.sidebar_icon_html=function () { return ``; }] A html string for the sidebar icon. Can be a function which returns a html string. The function has access to values of this layer e.g. the `this.id`.
123 | * @param {function} [args.onEachFeature=function (feature, layer) { }] A function with stuff to do on each feature. Has access to values of this layer e.g. `this.id`. Default: `function (feature, layer) { }`
124 | * @param {function} [args.pointToLayer=function (feature, latlng) { return L.marker(latlng, { icon: Utils.getCustomIcon(this.id), riseOnHover: true }); }] A function describing what to do when putting a geoJSON point to a layer.
125 | * @param {function} [args.coordsToLatLng=L.GeoJSON.coordsToLatLng] A function describing converting geoJSON coordinates to leaflets latlng.
126 | * @param {object | function} [args.polygon_style=function (feature) { return {}; }] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
127 | * @param {object | function} [args.polygon_style_highlight=function () { return { opacity: 1.0, fillOpacity: 0.7 }}] An object or function returning an object with L.Path options. https://leafletjs.com/reference.html#path
128 | * @param {L.LayerGroup} [args.feature_group=L.featureGroup.subGroup(this.#interactive_map.getClusterGroup())] The group all geoJson features get added to. Defaults to the default marker cluster.
129 | * @returns InteractiveLayer
130 | */
131 | addInteractiveLayer(id, geojson, args) {
132 | let layer = new InteractiveLayer(id, geojson, this, args);
133 |
134 | this.#interactive_layers.set(layer.id, layer);
135 |
136 | return layer;
137 | }
138 |
139 | /**
140 | * Add a layer to the remembered user preferences.
141 | * @param {string} name Layer ID
142 | */
143 | addUserLayer(name) {
144 | if (!this.#user_layers.includes(name)) {
145 | this.#user_layers.push(name);
146 | }
147 | localStorage.setItem(`${this.#website_subdir}:user_layers`, JSON.stringify(this.#user_layers));
148 | }
149 |
150 |
151 | /**
152 | * Finalize the interactive map. Call this after adding all layers to the map.
153 | */
154 | finalize() {
155 | // Set the column size for each interactive layer sidebar
156 | this.getLayers().forEach((layer, id) => {
157 | layer.setSidebarColumnCount();
158 | });
159 |
160 | // Defining overlay maps - markers
161 | this.getLayers().forEach((layer, id) => {
162 | this.#overlay_maps[layer.name] = layer.getGroup();
163 | });
164 |
165 | // Add layer selection to map
166 | L.control.layers(this.#tile_layers, this.#overlay_maps, {
167 | hideSingleBase: true
168 | }).addTo(this.#map);
169 |
170 | // Add custom layers controls to map
171 | this.#custom_layers.updateControls();
172 |
173 | // Show remembered layers
174 | if (!this.#user_layers) {
175 | this.#user_layers = new Array();
176 | this.getLayers().forEach((layer, id) => {
177 | if (layer.isDefault()) {
178 | this.#user_layers.push(layer.name);
179 | }
180 | });
181 | }
182 | this.getLayers().forEach((layer, id) => {
183 | if (this.#user_layers.includes(layer.name)) {
184 | layer.show();
185 | }
186 | });
187 | this.#custom_layers.addLayersToMap(this.#user_layers);
188 |
189 | // Center view over map
190 | this.zoomToBounds(this.#getBounds());
191 |
192 | // hide all previously checked marker
193 | this.getLayers().forEach((layer, layer_id) => {
194 | layer.getAllLayers().forEach((array, feature_id) => {
195 | // Remove if checked
196 | if (localStorage.getItem(`${this.#website_subdir}:${layer_id}:${feature_id}`)) {
197 | array.forEach(feature => {
198 | layer.removeLayer(feature);
199 | });
200 | }
201 | });
202 | });
203 |
204 | // Search in url for marker and locate them
205 | const queryString = window.location.search;
206 | const urlParams = new URLSearchParams(queryString);
207 | if (urlParams.has('share')) {
208 | const share = urlParams.get('share');
209 |
210 | let latlng = share.split(",");
211 | this.#share_marker.move([latlng[1], latlng[0]]);
212 |
213 | this.#share_marker.highlight();
214 | this.#share_marker.zoomTo();
215 | } else if (urlParams.has('list')) {
216 | const list = urlParams.get('list');
217 |
218 | if (this.hasLayer(list)) {
219 | var layer = this.getLayer(list);;
220 |
221 | // make group visible
222 | layer.show();
223 |
224 | if (!urlParams.has('id')) {
225 | layer.zoomTo();
226 |
227 | // if no id open sidebar
228 | this.#sidebar._tabitems.every(element => {
229 | if (element._id == list) {
230 | this.#sidebar.open(list);
231 | return false;
232 | }
233 | return true;
234 | });
235 | } else {
236 | const id = urlParams.get('id');
237 |
238 | if (layer.hasFeature(id)) {
239 | layer.highlightFeature(id);
240 | layer.zoomToFeature(id);
241 | this.#map.on('click', this.removeAllHighlights, this);
242 | }
243 |
244 | // TODO: unhide?
245 | }
246 | }
247 | }
248 | }
249 |
250 | /**
251 | * Get the parent marker cluster. Might not be used at all.
252 | * @returns L.MarkerClusterGroup
253 | */
254 | getClusterGroup() {
255 | return this.#cluster_group;
256 | }
257 |
258 | /**
259 | * Get the layer with a specific ID.
260 | * @param {string} id Layer ID
261 | * @returns InteractiveLayer
262 | */
263 | getLayer(id) {
264 | if (!this.#interactive_layers.has(id)) {
265 | return undefined;
266 | }
267 |
268 | return this.#interactive_layers.get(id);
269 | }
270 |
271 | /**
272 | * Get all layers this interactive map is aware of.
273 | * @returns Map
274 | */
275 | getLayers() {
276 | return this.#interactive_layers;
277 | }
278 |
279 | /**
280 | * Get the leaflet map.
281 | * @returns L.Map
282 | */
283 | getMap() {
284 | return this.#map;
285 | }
286 |
287 | /**
288 | * Get the maximum good looking zoom value.
289 | * @returns integer
290 | */
291 | getMaxZoom() {
292 | return this.MAX_ZOOM;
293 | }
294 |
295 | /**
296 | * Get the share marker for this interactive map.
297 | * @returns ShareMarker
298 | */
299 | getShareMarker() {
300 | return this.#share_marker;
301 | }
302 |
303 | /**
304 | * Get the sidebar associated to this interactive map.
305 | * @returns L.Control.Sidebar
306 | */
307 | getSidebar() {
308 | return this.#sidebar;
309 | }
310 |
311 | /**
312 | * Get the subdirectory this interactive map is associated to.
313 | * @returns string
314 | */
315 | getWebsiteSubdir() {
316 | return this.#website_subdir;
317 | }
318 |
319 | /**
320 | * Get a list off all layer IDs currently in the user preferences.
321 | * @returns string[]
322 | */
323 | // getUserLayers() {
324 | // return this.#user_layers;
325 | // }
326 |
327 | /**
328 | * Check if this interactive map has a specific layer group.
329 | * @param {string} id Layer group ID
330 | * @returns boolean
331 | */
332 | hasLayer(id) {
333 | return this.#interactive_layers.has(id);
334 | }
335 |
336 | /**
337 | * Remove all currently active highlights.
338 | */
339 | removeAllHighlights() {
340 | this.getLayers().forEach((layer, id) => {
341 | layer.removeAllHighlights();
342 | });
343 |
344 | this.#share_marker.removeHighlight();
345 |
346 | this.#map.off('click', this.removeAllHighlights, this);
347 | }
348 |
349 | /**
350 | * Remove a layer from the remembered user preferences.
351 | * @param {string} name ID of the layer
352 | */
353 | removeUserLayer(name) {
354 | this.#user_layers = this.#user_layers.filter((value, index, array) => {
355 | return value != name;
356 | });
357 | localStorage.setItem(`${this.#website_subdir}:user_layers`, JSON.stringify(this.#user_layers));
358 | }
359 |
360 | /**
361 | * Zoom to given bounds on this interactive map.
362 | * @param {L.LatLngBounds | L.LatLng[] | L.Point[] | Array[]} bounds Bounds to zoom to. Can be an array of points.
363 | */
364 | zoomToBounds(bounds) {
365 | this.#map.fitBounds(bounds, {
366 | maxZoom: this.MAX_ZOOM
367 | });
368 | }
369 |
370 | /**
371 | * Initialize the sidebar.
372 | * @param {string} attribution General attribution list about used stuff
373 | * @param {string} website Where to find the source of this interactive map
374 | * @param {string} website_subdir Subdir this interactive map will be hosted in
375 | */
376 | #setUpSidebar(attribution, website, website_subdir) {
377 | this.#sidebar = L.control.sidebar({
378 | autopan: true,
379 | closeButton: true,
380 | container: 'sidebar',
381 | position: 'left'
382 | }).addTo(this.#map);
383 |
384 | // make resetting localStorage possible
385 | this.#sidebar.addPanel({
386 | id: 'reset',
387 | tab: ' ',
388 | position: 'bottom',
389 | button: () => {
390 | if (!confirm('Really delete all marked locations and all custom marker layers?')) {
391 | return;
392 | }
393 |
394 | window.onbeforeunload = () => { };
395 |
396 | for (var key in localStorage) {
397 | if (key.startsWith(`${website_subdir}:`)) {
398 | localStorage.removeItem(key);
399 | }
400 | };
401 |
402 | location.reload();
403 | }
404 | });
405 |
406 | this.#sidebar.addPanel({
407 | id: 'edit',
408 | tab: ' ',
409 | title: 'Add or edit marker',
410 | position: 'bottom',
411 | button: () => {
412 | if (!this.#custom_layers.isInEditMode()) {
413 | this.#custom_layers.enableEditing();
414 | } else {
415 | this.#custom_layers.disableEditing();
416 | }
417 | }
418 | });
419 |
420 | this.#sidebar.addPanel({
421 | id: 'attributions',
422 | tab: ' ',
423 | title: 'Attributions',
424 | position: 'bottom',
425 | pane: `This project uses: ${attribution}${this.#common_attribution} `
426 | });
427 |
428 | this.#sidebar.addPanel({
429 | id: 'visit-github',
430 | tab: ' ',
431 | position: 'bottom',
432 | button: website
433 | });
434 |
435 | this.#sidebar.addPanel({
436 | id: 'go-back',
437 | tab: ' ',
438 | position: 'bottom',
439 | button: 'https://interactive-game-maps.github.io/'
440 | });
441 |
442 | // make group visible on pane opening
443 | this.#sidebar.on('content', event => {
444 | if (event.id == 'attributions') return;
445 |
446 | this.#map.addLayer(this.#interactive_layers.get(event.id).getGroup());
447 | Utils.setHistoryState(event.id);
448 | this.getShareMarker().removeMarker();
449 | });
450 |
451 | this.#sidebar.on('closing', () => {
452 | Utils.setHistoryState(undefined, undefined, this.#website_subdir);
453 | this.getShareMarker().removeMarker();
454 | })
455 | }
456 |
457 | /**
458 | * Initialize the editing toolbar.
459 | */
460 | #setUpToolbar() {
461 | // Disable general editing
462 | L.PM.setOptIn(true);
463 |
464 | this.#map.pm.Toolbar.createCustomControl({
465 | name: 'add_layer',
466 | block: 'custom',
467 | title: 'Add custom layer',
468 | className: 'fas fa-plus',
469 | toggle: false,
470 | onClick: () => {
471 | this.#custom_layers.createLayer();
472 | }
473 | });
474 | this.#map.pm.Toolbar.createCustomControl({
475 | name: 'remove_layer',
476 | block: 'custom',
477 | title: 'Remove custom layer',
478 | className: 'fas fa-trash',
479 | toggle: false,
480 | onClick: () => {
481 | this.#custom_layers.removeLayer();
482 | }
483 | });
484 | this.#map.pm.Toolbar.createCustomControl({
485 | name: 'export_layer',
486 | block: 'custom',
487 | title: 'Export custom layer',
488 | className: 'fas fa-file-download',
489 | toggle: false,
490 | onClick: () => {
491 | this.#custom_layers.exportLayer();
492 | }
493 | });
494 | this.#map.pm.addControls({
495 | position: 'bottomright',
496 | drawCircleMarker: false,
497 | oneBlock: false
498 | });
499 | this.#map.pm.toggleControls(); // hide by default
500 | }
501 |
502 | /**
503 | * Get the outer bounds of all layers on a map, including currently hidden layers.
504 | * @returns L.LatLngBounds
505 | */
506 | #getBounds() {
507 | var bounds = L.latLngBounds();
508 |
509 | this.getLayers().forEach((layer, k) => {
510 | bounds.extend(layer.getGroupBounds());
511 | });
512 |
513 | return bounds;
514 | }
515 |
516 | /**
517 | * Get a layer by its name.
518 | * @param {string} name Layer name
519 | * @returns L.Layer
520 | */
521 | #getLayerByName(name) {
522 | var interactive_layer = undefined;
523 | this.#interactive_layers.forEach((layer, id) => {
524 | if (layer.name == name) {
525 | interactive_layer = layer;
526 | }
527 | });
528 |
529 | return interactive_layer;
530 | }
531 |
532 | /**
533 | * Tries to read an adjacent tilemapresource.xml and calculate the bounds for this tile layer.
534 | *
535 | * Falls back to 256.
536 | *
537 | * @param {string} url Location of the tiles
538 | */
539 | #getTileLayerBounds(url) {
540 | if (window.location.protocol !== 'file:') {
541 | // This request has to be synchronous because we can't set the tile layer bounds after initialization
542 | const request = new XMLHttpRequest();
543 | request.open("GET", url.replace("{z}/{x}/{y}.png", "tilemapresource.xml"), false); // `false` makes the request synchronous
544 | request.send(null);
545 |
546 | if (request.status === 200) {
547 | try {
548 | const parser = new DOMParser();
549 | const xmlDoc = parser.parseFromString(request.responseText, "text/xml");
550 | const boundingBox = xmlDoc.getElementsByTagName("BoundingBox")[0];
551 | const reducedBounds = this.#reduceTileSizeBelow256(Math.abs(boundingBox.getAttribute("miny")), Math.abs(boundingBox.getAttribute("maxx")));
552 |
553 | return L.latLngBounds(L.latLng(0, 0), L.latLng(-reducedBounds[0], reducedBounds[1]));
554 | } catch {
555 | console.log("Failed reading tilemapresource.xml");
556 | }
557 | }
558 | }
559 |
560 | return L.latLngBounds(L.latLng(0, 0), L.latLng(-256, 256)); // gdal2tiles.py never produces tiles larger than 256
561 | }
562 |
563 | /**
564 | * Takes a two numbers and halfs them simultaneously until both are smaller than 256.
565 | * @param {number} size1 Number to minify
566 | * @param {number} size2 Number to minify similarly
567 | * @returns [number, number]
568 | */
569 | #reduceTileSizeBelow256(size1, size2) {
570 | while (size1 > 256 || size2 > 256) {
571 | size1 = Math.floor(size1 / 2);
572 | size2 = Math.floor(size2 / 2);
573 | }
574 |
575 | return [size1, size2];
576 | }
577 | }
578 |
--------------------------------------------------------------------------------
/common/share_marker.js:
--------------------------------------------------------------------------------
1 | class ShareMarker extends L.Marker {
2 | #interactive_map;
3 | #map;
4 |
5 | /**
6 | * Clicking on the map sets a marker that can be shared.
7 | * @param {InteractiveMap} interactive_map Interactive map
8 | */
9 | constructor(interactive_map) {
10 | super([0, 0], {
11 | icon: Utils.getCustomIcon('fa-share-alt'),
12 | riseOnHover: true,
13 | draggable: true,
14 | pmIgnore: true
15 | });
16 |
17 | this.#interactive_map = interactive_map;
18 | this.#map = this.#interactive_map.getMap();
19 |
20 | this.on('moveend', this.removeHighlight);
21 | this.on('moveend', event => {
22 | history.replaceState({}, "", `?share=${event.target._latlng.lng},${event.target._latlng.lat}`);
23 | });
24 |
25 | this.bindPopup(() => {
26 | var html = document.createElement('div');
27 |
28 | var title = document.createElement('h2');
29 | title.className = 'popup-title';
30 | title.innerHTML = 'Share marker';
31 | html.appendChild(title);
32 |
33 | var button = document.createElement('button');
34 | button.innerHTML = 'Remove';
35 | button.className = 'popup-checkbox is-fullwidth';
36 | html.appendChild(button);
37 |
38 | button.addEventListener('click', () => {
39 | this.removeMarker();
40 | Utils.setHistoryState(undefined, undefined, this.#interactive_map.getWebsiteSubdir());
41 | });
42 |
43 | return html;
44 | });
45 |
46 | this.turnOn();
47 | }
48 |
49 | /**
50 | * Highlight the share marker.
51 | */
52 | highlight() {
53 | var icon = this.getIcon();
54 | icon.options.html = `
${icon.options.html}`;
55 | this.setIcon(icon);
56 |
57 | this.#map.on('click', this.removeHighlight, this);
58 | }
59 |
60 | /**
61 | * Moves to share marker to a specific location.
62 | * @param {L.LatLng} latlng Coordinates
63 | */
64 | move(latlng) {
65 | this.setLatLng([latlng[0], latlng[1]]);
66 | this.addTo(this.#map);
67 | }
68 |
69 | /**
70 | * Prevent placing the share marker by clicking on the map for a short amount of time.
71 | * Useful for events that would place a share marker but shouldn't. E.g. closing a popup by clicking
72 | * somewhere on the map.
73 | * @param {int} [time=300] Time in msec
74 | */
75 | prevent(time = 300) {
76 | this.#map.off('click', this.#moveEvent, this);
77 | window.setTimeout(() => {
78 | this.#map.on('click', this.#moveEvent, this);
79 | }, time);
80 | }
81 |
82 | /**
83 | * Remove a highlight from the share marker.
84 | */
85 | removeHighlight() {
86 | var icon = this.getIcon();
87 | icon.options.html = icon.options.html.replace('
', '');
88 | this.setIcon(icon);
89 |
90 | this.off('moveend', this.removeHighlight);
91 | this.#map.off('click', this.removeHighlight, this);
92 | }
93 |
94 | /**
95 | * Remove the share marker from the map.
96 | */
97 | removeMarker() {
98 | this.removeHighlight();
99 | this.remove();
100 | }
101 |
102 | /**
103 | * Turn off the share marker and don't listen for events from now on.
104 | */
105 | turnOff() {
106 | this.removeMarker();
107 | this.#map.off('click', this.#moveEvent, this);
108 | }
109 |
110 | /**
111 | * Turn on the share marker by listening for events from now on.
112 | */
113 | turnOn() {
114 | this.#map.on('click', this.#moveEvent, this);
115 | }
116 |
117 | /**
118 | * Zoom to the share marker.
119 | */
120 | zoomTo() {
121 | let bounds = [];
122 |
123 | bounds.push([this._latlng.lat, this._latlng.lng]);
124 |
125 | this.#interactive_map.zoomToBounds(bounds);
126 | }
127 |
128 | /**
129 | * Do something when the share marker was moved.
130 | * @param {L.Event} event Event
131 | */
132 | #moveEvent(event) {
133 | this.setLatLng(event.latlng);
134 | this.addTo(this.#map);
135 | history.replaceState({}, "", `?share=${event.latlng.lng},${event.latlng.lat}`);
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/common/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 0;
3 | margin: 0;
4 | }
5 |
6 | html, body, #map {
7 | height: 100%;
8 | width: 100%;
9 | }
10 |
11 | /* || Collectibles sidebar listing */
12 |
13 | .collectibles_list {
14 | list-style: none;
15 | padding: 0%;
16 | display: grid;
17 | }
18 |
19 | @media (max-width: 1199px) {
20 | .collectibles_list {
21 | grid-template-columns: repeat(1, auto) !important;
22 | }
23 | }
24 |
25 | .collectibles_list li {
26 | display: flex;
27 | align-content: center;
28 | border-bottom: 1px solid #dddddd;
29 | }
30 |
31 | .collectibles_list li:last-child {
32 | border-bottom: 0;
33 | }
34 |
35 | .collectibles_list li input:checked + label {
36 | background: #dddddd;
37 | text-decoration: line-through;
38 | }
39 |
40 | .collectibles_list li input[type="checkbox"] {
41 | margin: 10px;
42 | }
43 |
44 | .collectibles_list li label {
45 | margin: 0;
46 | padding: 10px;
47 | transition: background 0.2s;
48 | flex: 1;
49 | /* font-family:'helvetica neue'; */
50 | font-size: 12px;
51 | font-weight: 200;
52 | border-left: 1px solid #dddddd;
53 | }
54 |
55 | .collectibles_list li button {
56 | display: inline-flex;
57 | justify-content: center;
58 | align-items: center;
59 | padding: 10px;
60 | }
61 |
62 | .leaflet-container {
63 | background: #738aaf;
64 | }
65 |
66 | .is-fullwidth {
67 | display: flex;
68 | width: 100%;
69 | }
70 |
71 | .popup-checkbox {
72 | border-width: 1px;
73 | padding: calc(0.5em - 1px) 0;
74 | white-space: nowrap;
75 | justify-content: center;
76 | text-align: center;
77 | appearance: none;
78 | align-items: center;
79 | border-radius: 4px;
80 | background-color: #fff;
81 | border: 1px solid transparent;
82 | border-color: #b5b5b5;
83 | cursor: pointer;
84 | }
85 |
86 | .popup-title {
87 | text-align: center;
88 | }
89 |
90 | .popup-media {
91 | max-width: min(500px, 75vw);
92 | max-height: min(500px, 25vh);
93 | }
94 |
95 | .flex-grow-0 {
96 | flex-grow: 0;
97 | }
98 |
99 | .flex-grow-1 {
100 | flex-grow: 1;
101 | }
102 |
103 | .sidebar-image {
104 | width: 100%;
105 | }
106 |
107 | /* || Place icons in the middle of the marker */
108 |
109 | .map-marker-foreground {
110 | width: 15px !important;
111 | display: table-cell;
112 | vertical-align: bottom;
113 | text-align: center;
114 | }
115 |
116 | .map-marker-foreground-wrapper {
117 | display: table;
118 | width: 15px;
119 | height: 15px;
120 | position: absolute;
121 | top: 3px;
122 | left: 5px;
123 | }
124 |
125 | /* || Firefox warning about will-change */
126 |
127 | /* https://github.com/Leaflet/Leaflet/issues/4686#issuecomment-476738312 */
128 | .leaflet-fade-anim .leaflet-tile,.leaflet-zoom-anim .leaflet-zoom-animated {
129 | will-change: auto !important;
130 | }
131 |
132 | /* || Allow scrolling through sidebar tabs */
133 |
134 | .leaflet-sidebar-tabs {
135 | display: flex;
136 | overflow-y: scroll;
137 | flex-flow: column;
138 | scrollbar-width: none;
139 | }
140 |
141 | .leaflet-sidebar-tabs > ul {
142 | flex: 1 0 auto;
143 | position: unset;
144 | }
145 |
146 | .leaflet-sidebar-tabs > ul + ul {
147 | flex: 0 0 auto;
148 | }
149 |
150 | /* || Styling adjustments for sidebar content */
151 |
152 | .leaflet-sidebar-header {
153 | position: sticky;
154 | top: 0px;
155 | }
156 |
157 | .leaflet-sidebar-content {
158 | scrollbar-width: none;
159 | }
160 |
161 | /* || Highlight marker
162 | https://cssdeck.com/labs/tedyvui4
163 | */
164 |
165 | .map-marker-ping {
166 | background: #000000;
167 | border-radius: 50%;
168 | height: 14px;
169 | width: 14px;
170 | position: absolute;
171 | left: 69%;
172 | top: 53%;
173 | margin: 11px 0px 0px -12px;
174 | transform: rotateX(55deg);
175 | z-index: -2;
176 | animation: pulsate_inner 2s ease-out;
177 | animation-iteration-count: infinite;
178 | }
179 |
180 | .map-marker-ping::before {
181 | content: "";
182 | border-radius: 50%;
183 | height: 40px;
184 | width: 40px;
185 | position: absolute;
186 | margin: -13px 0 0 -13px;
187 | animation: pulsate_outer 1s ease-out;
188 | animation-iteration-count: infinite;
189 | opacity: 0;
190 | box-shadow: 0 0 1px 2px #ffffff;
191 | }
192 |
193 | .map-marker-ping::after {
194 | content: "";
195 | border-radius: 50%;
196 | height: 40px;
197 | width: 40px;
198 | position: absolute;
199 | margin: -13px 0 0 -13px;
200 | animation: pulsate_outer 1s ease-out;
201 | animation-iteration-count: infinite;
202 | opacity: 0;
203 | box-shadow: 0 0 1px 2px #000000;
204 | animation-delay: 0.1s;
205 | }
206 |
207 | @keyframes pulsate_outer {
208 | 0% {
209 | transform: scale(0.1, 0.1);
210 | opacity: 0;
211 | }
212 |
213 | 50% {
214 | opacity: 1;
215 | }
216 |
217 | 100% {
218 | transform: scale(1.2, 1.2);
219 | opacity: 0;
220 | }
221 | }
222 |
223 | @keyframes pulsate_inner {
224 | 50% {
225 | background-color: white;
226 | }
227 |
228 | 100% {
229 | background-color: black;
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/common/utils.js:
--------------------------------------------------------------------------------
1 | class Utils {
2 | /**
3 | * Spawn a browser download out of a string
4 | * @param {string} filename Name of the downloaded file with file extension
5 | * @param {string} text Text that appears in the file
6 | */
7 | // https://stackoverflow.com/a/18197341
8 | static download(filename, text) {
9 | var element = document.createElement('a');
10 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
11 | element.setAttribute('download', filename);
12 |
13 | element.style.display = 'none';
14 | document.body.appendChild(element);
15 |
16 | element.click();
17 |
18 | document.body.removeChild(element);
19 | }
20 |
21 | /**
22 | * Get an icon with a background variation and a centered symbol/icon/short string/nothing on top.
23 | * @param {string} [icon_id=undefined] The ID for the icon that can be found in `images/icons/ID.png` (length > 2). Can also be a Font Awesome ID (fa-ID), a text (length <= 2) or undefined.
24 | * @param {string} [icon_mode=undefined] The ID for the background variation that can be found in `images/icons/marker_ID.svg`. Can be undefined for the default icon background.
25 | * @returns L.divIcon
26 | */
27 | static getCustomIcon(icon_id = undefined, icon_mode = undefined) {
28 | var background_path = icon_mode ? `images/icons/marker_${icon_mode}.svg` : "common/icons/marker.svg";
29 |
30 | if (!icon_id) {
31 | return L.divIcon({
32 | className: 'map-marker',
33 | html: `
34 |
35 | `,
36 | iconSize: [25, 41],
37 | popupAnchor: [1, -34],
38 | iconAnchor: [12, 41],
39 | tooltipAnchor: [0, 0]
40 | });
41 | }
42 |
43 | if (icon_id.startsWith('fa-')) {
44 | return L.divIcon({
45 | className: 'map-marker',
46 | html: `
47 |
48 |
49 | `,
50 | iconSize: [25, 41],
51 | popupAnchor: [1, -34],
52 | iconAnchor: [12, 41],
53 | tooltipAnchor: [0, 0]
54 | });
55 | } else if (icon_id.length > 2) {
56 | return L.divIcon({
57 | className: 'map-marker',
58 | html: `
59 |
60 |
61 | `,
62 | iconSize: [25, 41],
63 | popupAnchor: [1, -34],
64 | iconAnchor: [12, 41],
65 | tooltipAnchor: [0, 0]
66 | });
67 | } else if (icon_id.length < 3) {
68 | return L.divIcon({
69 | className: 'map-marker',
70 | html: `
71 |
72 |
73 | `,
74 | iconSize: [25, 41],
75 | popupAnchor: [1, -34],
76 | iconAnchor: [12, 41],
77 | tooltipAnchor: [0, 0]
78 | });
79 | }
80 | }
81 |
82 | /**
83 | * Replace the current browser address bar.
84 | * If only `website_subdir` is given it will reset to that url
85 | * @param {string} [list_id=undefined] Group ID
86 | * @param {string} [feature_id=undefined] Feature ID
87 | * @param {string} [website_subdir=''] Resets to plain url
88 | */
89 | static setHistoryState(list_id = undefined, feature_id = undefined, website_subdir = '') {
90 | if (list_id && feature_id) {
91 | history.replaceState({}, "", `?list=${list_id}&id=${feature_id}`);
92 | } else if (list_id) {
93 | history.replaceState({}, "", `?list=${list_id}`);
94 | } else {
95 | // CORS is driving me crazy
96 | // https://stackoverflow.com/a/3920899
97 | switch (window.location.protocol) {
98 | case 'http:':
99 | case 'https:':
100 | //remote file over http or https
101 | history.replaceState({}, "", `/${website_subdir}/`);
102 | break;
103 | case 'file:':
104 | //local file
105 | history.replaceState({}, "", `index.html`);
106 | break;
107 | default:
108 | //some other protocol
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/images/collectibles/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/interactive-game-maps/template/79741b604f76962a2170d2a84e45c190c34c1f6a/images/collectibles/example.png
--------------------------------------------------------------------------------
/images/icons/information.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/interactive-game-maps/template/79741b604f76962a2170d2a84e45c190c34c1f6a/images/icons/information.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Interactive map of Template
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
28 |
30 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
40 |
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 |
--------------------------------------------------------------------------------
/map.js:
--------------------------------------------------------------------------------
1 | // Step 0:
2 | // Add all feature geoJSON and layer logic to the `index.html`
3 | // In this example this is:
4 | // * `marker/collectibles.js`
5 | // * `marker/information.js`
6 | // * `marker_logic/collectibles.js`
7 | // * `marker_logic/information.js`
8 |
9 | // Step 1:
10 | // Initialize the map with basic information
11 | var interactive_map = new InteractiveMap('map', {
12 | // This will limit automatic zooming to this zoom level
13 | max_good_zoom: 6,
14 | // This is the max zoom the map will allow
15 | max_map_zoom: 8,
16 | website_source: 'https://github.com/interactive-game-maps/template',
17 | website_subdir: 'template',
18 | attribution: `
19 | $Thing used by $person under $license
20 | This project uses sample images from picsum.photos
21 | `
22 | });
23 |
24 | // Step 2:
25 | // Add at least one tile layer
26 | //
27 | // generate them from an image with (don't forget do adjust the zoom levels `-z`):
28 | // https://github.com/commenthol/gdal2tiles-leaflet
29 | // `./gdal2tiles.py -l -p raster -w none -z 3-5 full_map.jpg map_tiles`
30 | interactive_map.addTileLayer('Ingame map', {
31 | minNativeZoom: 2,
32 | maxNativeZoom: 4,
33 | attribution: 'Map from $source '
34 | });
35 |
36 | // Step 2.5 (optional):
37 | // Add more tile layer
38 | // interactive_map.addTileLayer('Overview', {
39 | // minNativeZoom: 2,
40 | // maxNativeZoom: 4,
41 | // attribution: 'Map from $source '
42 | // }, 'overview_tiles/{z}/{x}/{y}.png');
43 |
44 | // Step 3:
45 | // Add at least one marker layer
46 | // The order matters - they will appear in this order in the sidebar and layer control
47 | // See `marker_logic/collectibles.js` for a really basic layer
48 | addCollectibles(interactive_map);
49 |
50 | // Step 3.5 (optional):
51 | // Add more marker layer
52 | // See `marker_logic/information.js` for more advanced technics
53 | addInformation(interactive_map);
54 |
55 | // Step 4:
56 | // Finalize the map after adding all layers.
57 | interactive_map.finalize();
58 |
59 | // Step 5:
60 | // Open `index.html` to view the map.
61 | // You can now add additional layers by clicking the edit button in the lower left
62 | // While editing a layer you can export the geoJSON in the toolbar on the right when you're done
63 | // and add them here to step 3 to display them fixed for all users.
64 |
--------------------------------------------------------------------------------
/map_utils.js:
--------------------------------------------------------------------------------
1 | // Return html with the media to display
2 | // Add media that should be included into the popup to a new `html` and return the `html` afterwards
3 | // This is just basic html stuff from within JavaScript
4 |
5 | function getPopupMedia(feature, layer_id) {
6 |
7 | // Create top element to insert to
8 | var html = document.createElement('div');
9 |
10 | // Some logical distinction between information our geoJSON provides
11 | // Do the following for geoJSON features that have an `image_id` property
12 | if (feature.properties.image_id) {
13 |
14 | // Create a new element - `a` will be a clickable link
15 | var image_link = document.createElement('a');
16 |
17 | // Add a destination to our link
18 | image_link.href = `images/${layer_id}/${feature.properties.image_id}.png`;
19 |
20 | // Create a new element - `img` will be an image
21 | var image = document.createElement('img');
22 |
23 | // Add a class to our image. `popup-media` will get a size change listener to readjust
24 | // the popup location
25 | image.className = 'popup-media';
26 |
27 | // Add the image that should be displayed to the image element
28 | image.src = image_link.href;
29 |
30 | // Add the image inside the image link so clicking on the image will open the image in big
31 | image_link.appendChild(image);
32 |
33 | // Add the image link with the included image to our top html element
34 | html.appendChild(image_link);
35 |
36 | // Do the following for geoJSON features hat have an `external_id` property
37 | } else if (feature.properties.external_id) {
38 |
39 | // Create a new element - `a` will be a clickable link
40 | var image_link = document.createElement('a');
41 |
42 | // Add a destination to our link
43 | image_link.href = `https://www.example.com/collectibles${feature.properties.image_link}`;
44 |
45 | // Create a new element - `img` will be an image
46 | var image = document.createElement('img');
47 |
48 | // Add a class to our image. `popup-media` will get a size change listener to readjust
49 | // the popup location
50 | image.className = 'popup-media';
51 |
52 | // Add the image that should be displayed to the image element
53 | image.src = `https://picsum.photos/${feature.properties.external_id}`;
54 |
55 | // Add the image inside the image link so clicking on the image will open the image in big
56 | image_link.appendChild(image);
57 |
58 | // Add the image link with the included image to the top html element
59 | html.appendChild(image_link);
60 |
61 | // Do the following for geoJSON features hat have an `video_id` property
62 | } else if (feature.properties.video_id) {
63 |
64 | // Videos can't resize properly yet so we have to do hardcode them in for now
65 | const POPUP_WIDTH_16_9 = Math.min(500, window.screen.availWidth - 100, (window.screen.availHeight - 200) * 16 / 9);
66 | const POPUP_WIDTH_4_3 = Math.min(500, window.screen.availWidth - 100, (window.screen.availHeight - 200) * 4 / 3);
67 |
68 | // YouTube videos need an `iframe` element
69 | var video = document.createElement('iframe');
70 |
71 | // Add the `popup-media` class anyway
72 | video.className = 'popup-media';
73 |
74 | // Set a fixed width and height for the video
75 | video.width = POPUP_WIDTH_16_9;
76 | video.height = POPUP_WIDTH_16_9 / 16 * 9;
77 |
78 | // The source of the iframe
79 | video.src = `https://www.youtube-nocookie.com/embed/${feature.properties.video_id}`;
80 |
81 | // Add the video to the top html element
82 | html.appendChild(video);
83 | }
84 |
85 | // At last return the created html element
86 | return html;
87 | }
88 |
--------------------------------------------------------------------------------
/marker/collectibles.js:
--------------------------------------------------------------------------------
1 | var collectibles = {
2 | "type": "FeatureCollection",
3 | "features": [
4 | {
5 | "type": "Feature",
6 | "properties": {
7 | "id": "1",
8 | "image_id": "example"
9 | },
10 | "geometry": {
11 | "type": "Point",
12 | "coordinates": [
13 | 0,
14 | 0
15 | ]
16 | }
17 | },
18 | {
19 | "type": "Feature",
20 | "properties": {
21 | "id": "2",
22 | "external_id": "/1920/1080",
23 | "image_link": "#dsa",
24 | "description": "Go there and do that"
25 | },
26 | "geometry": {
27 | "type": "Point",
28 | "coordinates": [
29 | 1,
30 | 0
31 | ]
32 | }
33 | }
34 | ]
35 | };
36 |
--------------------------------------------------------------------------------
/marker/information.js:
--------------------------------------------------------------------------------
1 | var information = {
2 | "type": "FeatureCollection",
3 | "features": [
4 | {
5 | "type": "Feature",
6 | "properties": {
7 | "id": "1",
8 | "name": "Information 1",
9 | "external_id": "/1920/1080"
10 | },
11 | "geometry": {
12 | "type": "Polygon",
13 | "coordinates": [[
14 | [0, -1],
15 | [1, -1],
16 | [1, -2],
17 | [0, -2]
18 | ]]
19 | }
20 | },
21 | {
22 | "type": "Feature",
23 | "properties": {
24 | "id": "1",
25 | "external_id": "/1920/1080"
26 | },
27 | "geometry": {
28 | "type": "Point",
29 | "coordinates": [
30 | 0.5,
31 | -1.5
32 | ]
33 | }
34 | },
35 | {
36 | "type": "Feature",
37 | "properties": {
38 | "id": "Dangerous areas",
39 | "name": "Dangerous area 1",
40 | "external_id": "/1920/1080"
41 | },
42 | "geometry": {
43 | "type": "Polygon",
44 | "coordinates": [[
45 | [0, -3],
46 | [1, -3],
47 | [1, -4],
48 | [0, -4]
49 | ]]
50 | }
51 | },
52 | {
53 | "type": "Feature",
54 | "properties": {
55 | "id": "Dangerous areas",
56 | "name": "Dangerous area 2",
57 | "video_id": "abcdef"
58 | },
59 | "geometry": {
60 | "type": "Polygon",
61 | "coordinates": [[
62 | [0, -5],
63 | [1, -5],
64 | [1, -6],
65 | [0, -6]
66 | ]]
67 | }
68 | },
69 | {
70 | "type": "Feature",
71 | "properties": {
72 | "id": "Important waypoint 1",
73 | "external_id": "/1920/1080"
74 | },
75 | "geometry": {
76 | "type": "Point",
77 | "coordinates": [
78 | 2,
79 | 0
80 | ]
81 | }
82 | }
83 | ]
84 | };
85 |
--------------------------------------------------------------------------------
/marker_logic/collectibles.js:
--------------------------------------------------------------------------------
1 | // Simple
2 | // Just a simple group of collectibles, trackable in the sidebar
3 |
4 | function addCollectibles(map) {
5 |
6 | // New layer with id `collectibles` from geoJSON `collectibles`
7 | map.addInteractiveLayer('collectibles', collectibles, {
8 |
9 | // The display name for this layer
10 | name: 'Collectibles',
11 |
12 | // This layer should have a tab in the sidebar with a list for each feature ID
13 | create_checkbox: true,
14 |
15 | // Each feature should have a popup
16 | // This internally calls `getPopupMedia()` to associate an image or video
17 | // See `map_utils.js` for an example
18 | create_feature_popup: true,
19 |
20 | // This layer should be visible by default
21 | is_default: true,
22 |
23 | // We don't have created a custom icon so let's use a generic one from Font Awesome
24 | // Omitting this uses the group icon in `images/icons/${this.id}.png` by default
25 | // This needs a html string or a function that return a html string
26 | sidebar_icon_html: ' ',
27 |
28 | // We don't have created a custom icon so we have to manually provide a marker
29 | // Omitting this sets a marker with the group icon in `images/icons/${this.id}.png` by default
30 | // This can include logic based on feature properties
31 | // https://leafletjs.com/reference.html#geojson-pointtolayer
32 | pointToLayer: function (feature, latlng) {
33 |
34 | // https://leafletjs.com/reference.html#marker
35 | return L.marker(latlng, {
36 |
37 | // We don't have created a custom icon so let's use a generic one from Font Awesome
38 | // This can take:
39 | // * a Font Awesome `fa-` string
40 | // * the group id (`this.id`) to take the `images/icons/${this.id}.png`
41 | // * a max 2 char long string
42 | // * nothing for a generic marker
43 | icon: Utils.getCustomIcon('fa-gem'),
44 | riseOnHover: true
45 | });
46 | }
47 | });
48 | }
49 |
--------------------------------------------------------------------------------
/marker_logic/information.js:
--------------------------------------------------------------------------------
1 | // Advanced
2 | // Shows polygons and marker with overlapping IDs
3 | // The geoJSON contains 5 features which combine logically by 3 IDs
4 |
5 | function addInformation(map) {
6 |
7 | // New layer with id `collectibles` from geoJSON `collectibles`
8 | let layer = map.addInteractiveLayer('information', information, {
9 |
10 | // The display name for this layer
11 | name: 'Information',
12 |
13 | // This layer should have a tab in the sidebar with a list for each feature ID
14 | create_checkbox: true,
15 |
16 | // Each feature should have a popup
17 | // This internally calls `getPopupMedia()` to associate an image or video
18 | // See `map_utils.js` for an example
19 | create_feature_popup: true,
20 |
21 | // This layer should be visible by default
22 | is_default: true,
23 |
24 | // Let's do something on every feature
25 | // https://leafletjs.com/reference.html#geojson-oneachfeature
26 | onEachFeature: function (feature, layer) {
27 |
28 | // Listen for events and do something
29 | // https://leafletjs.com/reference.html#evented-on
30 | layer.on({
31 |
32 | // Do some fancy highlighting by hovering with the mouse
33 | mouseover: event => {
34 | this.highlightFeature(feature.properties.id);
35 | },
36 | mouseout: event => {
37 | this.removeFeatureHighlight(feature.properties.id);
38 | },
39 |
40 | // Clicking on the layer zooms to it
41 | click: event => {
42 |
43 | // This layer gets a popup which also does some additional stuff…
44 | this.zoomToFeature(feature.properties.id);
45 |
46 | // …which can be manually included if no popup is generated:
47 | // map.share_marker.prevent();
48 | // Utils.setHistoryState(this.id, feature.properties.id);
49 | }
50 | });
51 |
52 | // Bind a tooltip which follows the mouse around when hovering over a feature that
53 | // isn't a point (marker)
54 | if (feature.geometry.type != "Point") {
55 |
56 | // https://leafletjs.com/reference.html#layer-bindtooltip
57 | layer.bindTooltip(feature.properties.name, {
58 | sticky: true
59 | });
60 | }
61 | },
62 |
63 | // Give polygons some special styling
64 | // Function that return a path object or directly a path object
65 | // https://leafletjs.com/reference.html#geojson-style
66 | // https://leafletjs.com/reference.html#path-option
67 | polygon_style: function (feature) {
68 | return {
69 | color: 'red',
70 | opacity: 0.2
71 | };
72 | },
73 |
74 | // Give polygons some special styling when a highlight occurs e.g. by mouse hovering or location finding
75 | // Function that return a path object or directly a path object
76 | // https://leafletjs.com/reference.html#geojson-style
77 | // https://leafletjs.com/reference.html#path-option
78 | polygon_style_highlight: function (feature) {
79 | return {
80 | color: 'blue',
81 | opacity: 0.5,
82 | fillColor: 'red',
83 | fillOpacity: 0.2
84 | };
85 | },
86 |
87 | // If the coordinates are extracted from the game files they might need a transformation to
88 | // map correctly
89 | coordsToLatLng: function (coords) {
90 | var lx = (coords[0] + 1) * 0.5;
91 | var ly = (coords[1] - 1) * 0.5;
92 | return L.latLng(ly, lx);
93 | }
94 |
95 | // Some additional notes:
96 | // * We're omitting the `sidebar_icon_html` so `images/icons/${this.id}.png` will be used as an icon
97 | // * We're omitting the `pointToLayer` so `images/icons/${this.id}.png` will be used for marker
98 | // * We're omitting the `feature_group` so markers will cluster with other layers
99 | });
100 |
101 | // Optionally add further geojsons
102 | // layer.addGeoJson(another_geojson, {
103 | // create_feature_popup: true,
104 | // …
105 | // });
106 | }
107 |
--------------------------------------------------------------------------------