├── 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:

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

    ${icon_id}

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