diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | eslint.config.mjs | 27 | ||||
| -rw-r--r-- | example/example.html (renamed from example.html) | 4 | ||||
| -rw-r--r-- | example/style.css (renamed from style2.css) | 0 | ||||
| -rw-r--r-- | index.html | 210 | ||||
| -rw-r--r-- | main.js | 8132 | ||||
| -rw-r--r-- | package-lock.json | 1764 | ||||
| -rw-r--r-- | package.json | 14 | ||||
| -rw-r--r-- | style.css | 376 | ||||
| -rw-r--r-- | vite.config.js | 10 |
10 files changed, 7197 insertions, 3342 deletions
@@ -12,7 +12,7 @@ to preview the production site. # Author -© 2024 [Guilhem Moulin](https://guilhem.se). +© 2024-2025 [Guilhem Moulin](https://guilhem.se). # Licensing diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..72b17bd --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,27 @@ +import js from "@eslint/js"; +import globals from "globals"; +import css from "@eslint/css"; +import { defineConfig } from "eslint/config"; + + +export default defineConfig([ + { + files: ["src/*.js", "./*.js"], + plugins: { js }, + extends: ["js/recommended"], + languageOptions: { + globals: globals.browser, + }, + }, + { + files: ["src/*.css", "./*.css"], + plugins: { css }, + language: "css/css", + extends: ["css/recommended"], + rules: { + "css/no-important": "off", + /* too many false positives as of @eslint/css 0.9.0 */ + "css/no-invalid-properties": "off", + }, + }, +]); diff --git a/example.html b/example/example.html index 5dd0e01..d003598 100644 --- a/example.html +++ b/example/example.html @@ -4,7 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Hemsida</title> - <link rel="stylesheet" href="/style2.css"> + <link rel="stylesheet" href="/example/style.css"> </head> <body> <div id="wrapper"> @@ -15,7 +15,7 @@ man välja lager, ladda ner kartan som PNG-fil, och få information om de olika föremålen.</p> </div> - <iframe id="map" src="/#z=0&basemap=topowebb_nedtonad&layers=svk_lines+svk_pylons+svk_stations+svk_planned+mrr_appr_ec+mrr_appl_ec+mrr_appr_ogd+mrr_appl_ogd+mrr_appr_met+mrr_appl_met+mrr_appr_dl+mrr_appr_pc+vbk_area_current+vbk_station_completed+vbk_station_processed+vbk_station_approved+gigafactories" title="Webbkarta"></iframe> + <iframe id="map" src="/" title="Webbkarta"></iframe> </div> </body> </html> diff --git a/style2.css b/example/style.css index dfeaee5..dfeaee5 100644 --- a/style2.css +++ b/example/style.css @@ -11,68 +11,188 @@ <div id="map-control-container" aria-hidden="true"> <div id="layer-selection-panel"></div> <div id="map-legend-panel"></div> + <div id="measure-panel"></div> <div id="map-menu"></div> </div> <div id="popup"></div> - <div class="modal" id="modal-info" tabindex="-1" aria-hidden="true"> + <div class="modal" id="info-modal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg"> <div class="modal-content"> - <div class="modal-header py-2"> - <h5 class="my-0">Källor och licensinformation</h5> + <div class="modal-header"> + <div class="h5 m-0">Källor och licensinformation</div> <button type="button" class="btn-close" data-bs-dismiss="modal" title="Stäng" aria-label="Stäng"></button> </div> - <div class="modal-body pt-2"> - <ul class="mb-2"> - <li><i>Transmissionsnät för el</i> från - <a href="https://svk.se" target="_blank">Svenska Kraftnät (SvK)</a>. + <div id="info-body" class="modal-body"> + <div id="info-accordion" class="accordion accordion-flush"></div> + <ul class="list-group list-group-flush"> + <li class="list-group-item"> + <h6>Bakgrund kartor</h6> + <p>© <a href="https://lantmateriet.se" target="_blank">Lantmäteriet</a></p> + <p>Licensvillkor: <a href="https://www.lantmateriet.se/sv/geodata/vara-produkter/oppna-data/#anchor-1" target="_blank">CC0 1.0 Universiell</a></p> </li> - <li><i>Dammregistret</i> från - <a href="https://smhi.se" target="_blank">Sveriges meteorologiska och hydrologiska institut (SMHI)</a>, - CC-BY-4.0 (<a href="https://www.smhi.se/data/oppna-data/villkor-for-anvandning-1.30622" target="_blank">öppna data</a>). + <li class="list-group-item"> + <h6>Webbkartan</h6> + <p>© 2024-2025 Guilhem Moulin</p> + <p>Licensvillkor: <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">AGPLv3+</a></p> + <p class="small text-muted"><i class="bi bi-file-earmark-code"></i> + <a href="https://git.guilhem.org/KlimatanalysNorr/webmap" target="_blank">Källkod <i class="bi bi-box-arrow-up-right"></i></a></p> </li> - <li><i>Mineralrättsregistret</i> från - <a href="https://www.sgu.se/bergsstaten/" target="_blank">Bergsstaten</a>. + <li class="list-group-item"> + <h6>Backend verktyg</h6> + <p>© 2024-2025 Guilhem Moulin</p> + <p>Licensvillkor: <a href="https://www.gnu.org/licenses/gpl-3.0.en.html" target="_blank">GPLv3+</a> och + (endast CGI) <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">AGPLv3+</a></p> + <p class="small text-muted"><i class="bi bi-file-earmark-code"></i> + <a href="https://git.guilhem.org/KlimatanalysNorr/tools" target="_blank">Källkod <i class="bi bi-box-arrow-up-right"></i></a></p> </li> - <li><i>Vindbruk</i> från - <a href="https://www.energimyndigheten.se/energisystem-och-analys/elproduktion/vindkraft/vindbrukskollen/" target="_blank">Länsstyrelserna och Energimyndigheten</a>, - CC0 (<a href="https://ext-geodatakatalog-forv.lansstyrelsen.se/GeodataKatalogen/codelist/metadata/anvandningsrestriktioner.xml#CC01.0" target="_blank">öppna data</a>). - </li> - <li><i>Skogsbruk</i>, <i>Skogliga biotopskyddsområden</i> och <i>Naturvårdsavtal</i> från - <a href="https://skogsstyrelsen.se" target="_blank">Skogsstyrelsen</a>, - CC0 (<a href="https://www.skogsstyrelsen.se/sjalvservice/karttjanster/geodatatjanster/villkor-for-nyttjande-av-skogsstyrelsens-kartdatabaser/" target="_blank">öppna data</a>). + </ul> + <p class="small text-muted info-credits">Webbkartan är utvecklad av + <a data-mailto-b64="Z3VpbGhlbQ __AT__ ZnJpcG9zdA __DOT__ b3Jn" href="#" target="_blank" class="email-address-b64">Guilhem Moulin</a> + på uppdrag av + <a href="https://www.klimatanalysnorr.se" target="_blank">Klimatanalys Norr projektet</a>.</p> + </div> + </div> + </div> + </div> + <div class="modal" id="help-modal" tabindex="-1" aria-hidden="true"> + <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="btn-close" data-bs-dismiss="modal" title="Stäng" aria-label="Stäng"></button> + </div> + <div id="help-body" class="modal-body"> + <h3>Navigering</h3> + <p>Kartan är interaktiv och kontrolleras med musen. Tryck ner på + musens första knapp och dra kartan för att byta koordinater.</p> + <p>Zoomnivån kan styras med musens rullhjul, eller alternativt med + knapparna och skjutreglaget längst upp till vänster. Den aktuella + skalan visas längst ner till vänster på kartan.</p> + <p>Både koordinater och zoomnivå sparas i URL:en, så om du delar + URL:en med någon annan eller sparar den i din webbläsares bokmärken + kommer senare besök att landa på samma plats samt zoomnivå på + kartan.</p> + <p>Klickar du på ett objekt på kartan – exempelvis ett + undersökningstillstånd eller ett naturreservat (läs mer om lagerval + nedan) – så visar det valda objektet med en cyanfärgad kantlinje + samt dyker ett fönster upp med information om just det objektet. + Både informationen och geometrier kommer från ansvarig myndighet. + I fall det finns flera objekt i närheten av just platsen du klickade + på, så antalet träffar visas högst upp på informationsfönstret + (exempelvis ”<span class="fst-italic text-muted">Träff 1 av 3</span>”) och du + kan välja mellan dem genom att klicka på pilarna + <span class="popover-header"> + <button class="popover-button popover-button-prev text-muted help-button"></button>/<button class="popover-button popover-button-next text-muted help-button"></button> + </span> bredvid. + Informationsfönstret kan flyttas runt om det står i vägen, samt att + det förstoras om rutan är för liten.</p> + + <h3>Funktionsknappar</h3> + <p>Det finns ett antal funktionsknappar längst upp till + höger på kartan. Om du låter muspekaren vila på varje + knapp, dyker en hjälptext upp.</p> + + <ol id='help-describe-functions'> + <li data-for-button='layer-selection-button'> + <p>Här kan du välja mellan olika lager att ha på kartan. + Det finns massor med lager, och de grupperas ihop så att + det är enkelt att välja hela gruppen på en gång. Klickar + du på pilen + <span class="accordion"><span class="accordion-button collapsed help-button text-muted"></span></span> + kan du istället välja ett specifikt lager.</p> + <p>Längst ned finns knappar där du kan välja att se + administrativa gränser, samt välja mellan nedtonad eller färgad + bakgrundskarta.</p> + <p>Lager, precis som koordinater och zoomnivå, sparas i URL:en. + Så om du delar URL:en med någon annan eller sparar den i din + webbläsares bokmärken kommer senare besök att landa på samma vy + (eventuellt med uppdaterat underlag).</p> </li> - <li><i>Naturvårdsregistret</i> och <i>Naturvårdsavtal</i> från - <a href="https://www.naturvardsverket.se/" target="_blank">Naturvårdsverket</a>, - CC0 (<a href="https://geodata.naturvardsverket.se/nedladdning/naturvardsregistret/Naturvardsregistret_beskrivning_av_oppna_data.pdf" target="_blank">öppna data</a>). + + <li data-for-button='map-legend-button'> + <p>Här ser du symboler för alla lager som valts ut.</p> </li> - <li><i>Riksintresse naturvård</i> och <i>frilufsliv</i> från - <a href="https://www.naturvardsverket.se/" target="_blank">Naturvårdsverket</a> - och - <a href="https://www.lansstyrelsen.se/" target="_blank">Länsstyrelsen</a>, - CC-BY-4.0 (<a href="https://ext-geodatakatalog-forv.lansstyrelsen.se/GeodataKatalogen/codelist/metadata/anvandningsrestriktioner.xml#CCby4.0" target="_blank">öppna data</a>). + + <li data-for-button='measure-button'> + <p>Med det här verktyget kan du rita direkt på kartan och mäta + distanser eller ytor. Till exempel kan du lätt mäta avståndet + mellan en viss exploatering och ett skyddat område eller + kommungräns.</p> </li> - <li><i>Riksintresse Rennäringen</i> skikt från - <a href="https://sametinget.se" target="_blank">Sametinget</a>, - CC-BY-4.0 (<a href="https://ext-geodatakatalog-forv.lansstyrelsen.se/GeodataKatalogen/codelist/metadata/anvandningsrestriktioner.xml#CCby4.0" target="_blank">öppna data</a>). - <i>Samebyarnas betesområden</i> och <i>flyttled</i> från - <a href="https://sametinget.se" target="_blank">Sametinget</a>. + + <li data-for-button='age-filter-button'> + <p>Med det här verktyget kan du filtrera bort gamla + exploateringar, vilket underlättar övervakning. Det är bra att + kunna fokusera på nya exploateringen, då det annars snabbt kan + kännas överväldigande.</p> + + <p>Om filtret är aktivt så blir knappen svart. Filterparametrar + sparas i URL:en, så en övervaknings-URL till ett specifik område + kan enkelt delas med andra eller bokmärkas.</p> </li> - <li>Bakgrund kartor från - © <a href="https://lantmateriet.se" target="_blank">Lantmäteriet</a>, CC0 - (<a href="https://www.lantmateriet.se/sv/geodata/vara-produkter/oppna-data/#anchor-1" target="_blank">öppna data</a>). + + <li data-for-button='fullscreen-toggle'> + <p>Genom att klicka på knappen kan du aktivera eller inaktivera + helskärmsläget.</p> + <li> + + <li data-for-button='export-to-image'> + <p>Genom att klicka på knappen kan du ladda ner den aktuella + kartvyn som en bild. Bilden kan då användas i en rapport eller + ett yttrande.</p> </li> - <li>Webbkartan: - © <a href="https://guilhem.se" target="_blank">Guilhem Moulin</a>, AGPLv3+. - <a href="https://git.guilhem.org/KlimatanalysNorr/webmap" target="_blank">Källkod</a>. + + <li data-for-button='info-button'> + <p>Här kan du se källor och licensvillkor för varje lager samt + själva kartverktyget. + För de flesta lager finns det också en produktlänk till den + ansvariga myndighetens webbplats.</p> + <p>Bredvid varje källa kan du se när den senast hämtades av + kartverktyget, samt när skikten generades på kartan (det vill + säga hur gammalt underlaget är på kartan). + Nedladdning samt uppdatering av underlag för kartan sker + automatiskt varje dag.</p> </li> - <li>Backend-verktyg: - © <a href="https://guilhem.se" target="_blank">Guilhem Moulin</a>, GPLv3+. - <a href="https://git.guilhem.org/KlimatanalysNorr/tools" target="_blank">Källkod</a>. + + <li data-for-button='help-button'> + <p>Om du klickar på den här knappen ser du det här + hjälpfönstret.</p> </li> - </ul> - <p class="small mb-0">Webbkartan är utvecklad av - <a href="https://guilhem.se" target="_blank">Guilhem Datakonsult</a> på uppdrag av - <a href="https://www.klimatanalysnorr.se" target="_blank">Klimatanalys Norr projektet</a>.</p> + </ol> + + <h3>Målgrupp och syftet</h3> + <p class="mb-2">Kartan kan användas som en hjälp i att välja vilka + exploateringar som är viktigast att bekämpa. Målgruppen för kartan + är individer och organisationer som vill:</p> + <ol> + <li>se kumulativa effekter av olika exploateringar i + norr</li> + <li>se var en viss exploatering ligger i förhållande + till ett område av intresse (ex formellt skydd, höga + naturvärden, riksintresse med flera)</li> + <li>övervaka de senaste exploateringarna i ett visst + område.</li> + </ol> + + <p>Viktigt att komma ihåg är att allt underlag i kartan + kommer från olika myndigheter (Bergsstaten, Skogsstyrelsen, + Länsstyrelsen, Naturvårdsverket med flera) och ingen modell + finns för att assistera i själva avvägningen. + Med andra ord så samlar kartan in befintliga underlag från + olika myndigheter istället för att själv presentera något + nytt, och den data kartan presenterar är därmed information + som kan ingå i rapporter och yttranden (och ingen kan hävda + att underlaget är påhittat).</p> + + <p>Underlagen för kartan uppdateras automatiskt varje dag. + Se ovan för detaljer om hur gammalt varje underlag är.</p> + + <h3>Buggrapporter och feedback</h3> + <p>Tveka inte att skicka ett + <a data-mailto-b64="Z3VpbGhlbQ __AT__ ZnJpcG9zdA __DOT__ b3Jn" href="#" target="_blank" class="link-secondary email-address-b64">mejl + <i class="bi bi-envelope-at"></i></a> + med önskemål, buggrapporter, förslag till + förbättring med flera. + Kartverktyget samt tillhörande verktyg är alla fri programvara.</p> </div> </div> </div> @@ -1,5 +1,5 @@ /*********************************************************************** - * Copyright © 2024 Guilhem Moulin <info@guilhem.se> + * Copyright © 2024-2025 Guilhem Moulin <info@guilhem.se> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -18,8 +18,10 @@ import Map from 'ol/Map.js'; import View from 'ol/View.js'; import TileLayer from 'ol/layer/Tile.js'; +import TileLayerGL from 'ol/layer/WebGLTile.js'; import WMTS from 'ol/source/WMTS.js'; +import GeoTIFF from 'ol/source/GeoTIFF.js'; import WMTSTileGrid from 'ol/tilegrid/WMTS.js'; import FullScreen from 'ol/control/FullScreen.js'; @@ -34,9 +36,15 @@ import VectorTileLayer from 'ol/layer/VectorTile.js'; import VectorTile from 'ol/source/VectorTile.js'; import { createXYZ } from 'ol/tilegrid.js'; +import { toContext } from 'ol/render.js'; +import Polygon from 'ol/geom/Polygon.js'; +import LineString from 'ol/geom/LineString.js'; +import Point from 'ol/geom/Point.js'; + import VectorLayer from 'ol/layer/Vector.js'; import VectorSource from 'ol/source/Vector.js'; -import GeoJSON from 'ol/format/GeoJSON.js'; +import Draw from 'ol/interaction/Draw.js'; +import { unByKey } from 'ol/Observable.js'; import CircleStyle from 'ol/style/Circle.js'; import Fill from 'ol/style/Fill.js'; @@ -45,6 +53,8 @@ import RegularShape from 'ol/style/RegularShape.js'; import Stroke from 'ol/style/Stroke.js'; import Style from 'ol/style/Style.js'; +import Geolocation from 'ol/Geolocation.js'; + import proj4 from 'proj4'; import { get as getProjection } from 'ol/proj.js'; import { register as registerProjection } from 'ol/proj/proj4.js'; @@ -52,12 +62,14 @@ import { register as registerProjection } from 'ol/proj/proj4.js'; import { Modal, Popover } from 'bootstrap'; import './style.css'; +"use strict"; proj4.defs('EPSG:3006', '+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs'); registerProjection(proj4); -const projection = getProjection('EPSG:3006'); +const PROJECTION = getProjection('EPSG:3006'); +const LOCALE = 'sv-SE'; /* Lantmäteriet uses a tile-scheme where the origin (upper-left corner) is at * N8500000 E-1200000 (SWEREF99 TM), where each tile is 256×256 pixels, and where @@ -70,7 +82,7 @@ const projection = getProjection('EPSG:3006'); * side) somehow centered on Norrbotten and Västerbotten, and zoom in from there. * This represent a TILEROW (x) offset of 5, and a TILECOL (y) offset of 2. */ -const extent = [110720, 6927136, 1159296, 7975712]; +const EXTENT = [110720, 6927136, 1159296, 7975712]; /* XXX using the topowebbcache WMTS is fine for testing (as it doesn't require * authentication) but not in production in a public instance as doing so would @@ -88,83 +100,93 @@ const extent = [110720, 6927136, 1159296, 7975712]; * https://ext-geodatakatalog.lansstyrelsen.se/GeodataKatalogen/srv/swe/catalog.search#/map uses * https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/3c3a9cf47e7cb5ea24542d40d19698/?layer=topowebb&style=default&tilematrixset=3006&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&TileMatrix=7&TileCol=237&TileRow=155 */ -const baseMapSource = new WMTS({ - url: undefined, - version: '1.0.0', - style: 'default', - matrixSet: '3006', - format: 'image/png', - tileGrid: new WMTSTileGrid({ - extent: extent, - // https://www.lantmateriet.se/globalassets/geodata/geodatatjanster/tb_twk_visning-oversiktlig_v1.0.3.pdf - tileSize: 256, - origin: [-1200000, 8500000], - resolutions: [4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8], - matrixIds: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], - }), - projection: projection, - wrapX: false, - crossOrigin: 'anonymous', -}); - - -const view = new View({ - projection: projection, - extent: extent, - showFullExtent: true, - /* center of the bbox of the Norrbotten and Västerbotten geometries */ - center: [694767.48, 7338176.57], - zoom: 1, - enableRotation: false, - resolutions: [1024, 512, 256, 128, 64, 32, 16, 8], - constrainResolution: false, -}); - -let baseMapLayer = 'topowebb'; -(function() { - const params = new URLSearchParams(window.location.hash.substring(1)); - const x = parseFloat(params.get('x')); - const y = parseFloat(params.get('y')); - if (!isNaN(x) && !isNaN(y)) { - view.setCenter([x, y]); - } - const z = parseFloat(params.get('z')); - if (!isNaN(z)) { - view.setZoom(z); - } - - if (params.has('basemap')) { - baseMapLayer = params.get('basemap'); - } - baseMapSource.setUrl(`https://minkarta.lantmateriet.se/map/topowebbcache?LAYER=${encodeURIComponent(baseMapLayer)}`); -})(); - - -const map = new Map({ - controls: [], - view: view, - layers: [ - new TileLayer({ - source: baseMapSource +const [BASEMAP, MAP] = (function() { + const param = 'basemap'; + const baseMap = Object.seal({ + _layer: new URLSearchParams(location.hash.substring(1))?.get?.(param) ?? 'topowebb_nedtonad', + get layer() { + return this._layer; + }, + get url() { + return 'https://minkarta.lantmateriet.se/map/topowebbcache?' + + 'LAYER=' + encodeURIComponent(this.layer); + }, + set layer(layername) { + this._layer = layername; + baseMapSource.setUrl(this.url); + const searchParams = new URLSearchParams(location.hash.substring(1)); + searchParams.set(param, layername); + location.hash = '#' + searchParams.toString(); + }, + }); + const baseMapSource = new WMTS({ + url: baseMap.url, + version: '1.0.0', + style: 'default', + matrixSet: '3006', + format: 'image/png', + tileGrid: new WMTSTileGrid({ + extent: EXTENT, + // https://www.lantmateriet.se/globalassets/geodata/geodatatjanster/tb_twk_visning-oversiktlig_v1.0.3.pdf + tileSize: 256, + origin: [-1200000, 8500000], + resolutions: [4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8], + matrixIds: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], }), - ], - target: document.getElementById('map'), -}); + projection: PROJECTION, + wrapX: false, + crossOrigin: 'anonymous', + }); -const popup = document.getElementById('popup'); -const featureOverlaySource = new VectorSource(); + const view = new View({ + projection: PROJECTION, + extent: EXTENT, + showFullExtent: true, + enableRotation: false, + resolutions: [1024, 512, 256, 128, 64, 32, 16, 8], + constrainResolution: false, + }); + return [ + baseMap, + new Map({ + controls: [], + view: view, + layers: [ + new TileLayer({ + source: baseMapSource + }), + ], + target: document.getElementById('map'), + }), + ]; +})(); /* move the control container to the viewport */ -const container = document.getElementById('map-control-container'); +const CONTAINER_MAP = document.getElementById('map-control-container'); +const CONTAINER_STOPEVENT = MAP.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0]; (function() { - const container0 = map.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0]; - container0.appendChild(document.getElementById('zoom-control')); - container0.appendChild(container); - container0.appendChild(document.getElementById('modal-info')); - - const backdrop = document.createElement('div'); - container0.appendChild(backdrop); - backdrop.id = 'modal-info-backdrop'; + CONTAINER_STOPEVENT.appendChild(document.getElementById('zoom-control')); + CONTAINER_STOPEVENT.appendChild(CONTAINER_MAP); + CONTAINER_STOPEVENT.appendChild(document.getElementById('info-modal')); + CONTAINER_STOPEVENT.appendChild(document.getElementById('help-modal')); + + const info_backdrop = document.createElement('div'); + CONTAINER_STOPEVENT.appendChild(info_backdrop); + info_backdrop.id = 'info-modal-backdrop'; + + const help_backdrop = document.createElement('div'); + CONTAINER_STOPEVENT.appendChild(help_backdrop); + help_backdrop.id = 'help-modal-backdrop'; + + const age_filter = document.createElement('div'); + age_filter.id = 'age-filter-modal'; + age_filter.classList.add('modal'); + age_filter.setAttribute('tabindex', '-1'); + age_filter.setAttribute('aria-hidden', 'true'); + CONTAINER_STOPEVENT.appendChild(age_filter); + const age_filter_backdrop = document.createElement('div'); + age_filter_backdrop.id = 'age-filter-modal-backdrop'; + CONTAINER_STOPEVENT.appendChild(age_filter_backdrop); })(); /* zoom in/out */ @@ -187,7 +209,7 @@ const container = document.getElementById('map-control-container'); for (const btn of control.element.getElementsByTagName('button')) { btn.classList.add('btn', 'btn-light'); } - map.addControl(control); + MAP.addControl(control); })(); /* zoom slider */ @@ -199,29 +221,29 @@ const container = document.getElementById('map-control-container'); for (const btn of control.element.getElementsByTagName('button')) { btn.classList.add('btn', 'btn-light'); } - map.addControl(control); + MAP.addControl(control); })(); /* scale line */ (function() { - const size = map.getSize(); + const size = MAP.getSize(); const control = new ScaleLine({ units: 'metric', minWidth: 150, maxWidth: size[1] < 350 ? size[1] - 50 : 350, - target: container, + target: CONTAINER_MAP, }); control.element.classList.add('modal', 'modal-content'); - map.addControl(control); + MAP.addControl(control); })(); -const menu = document.getElementById('map-menu'); +const MENU = document.getElementById('map-menu'); const TRAILING_ZEROES = /\.?0*$/; /* "open in new tab" button */ if (window.location !== window.parent.location) { const div = document.createElement('div'); - menu.appendChild(div); + MENU.appendChild(div); div.classList.add('ol-unselectable', 'ol-control'); const btn = document.createElement('button'); @@ -236,13 +258,13 @@ if (window.location !== window.parent.location) { btn.appendChild(i); i.classList.add('bi', 'bi-box-arrow-up-right'); - btn.onclick = function(event) { - const coordinates = view.getCenter(); + btn.onclick = function() { + const coordinates = MAP.getView().getCenter(); const url = new URL(window.location.href); const searchParams = new URLSearchParams(url.hash.substring(1)); searchParams.set('x', coordinates[0].toFixed(2).replace(TRAILING_ZEROES, '')); searchParams.set('y', coordinates[1].toFixed(2).replace(TRAILING_ZEROES, '')); - searchParams.set('z', view.getZoom().toFixed(3).replace(TRAILING_ZEROES, '')); + searchParams.set('z', MAP.getView().getZoom().toFixed(3).replace(TRAILING_ZEROES, '')); url.hash = '#' + searchParams.toString(); return window.open(url.href, '_blank'); }; @@ -250,79 +272,53 @@ if (window.location !== window.parent.location) { /* layer selection button and legend */ if (window.location === window.parent.location) { - const btn = (function() { - const div = document.createElement('div'); - menu.appendChild(div); - div.id = 'layer-selection-button'; - div.classList.add('ol-unselectable', 'ol-control'); - - const btn = document.createElement('button'); - div.appendChild(btn); - btn.type = 'button'; - btn.title = 'Lagerval'; - btn.setAttribute('aria-label', btn.title); - btn.setAttribute('aria-expanded', 'false'); - btn.classList.add('btn', 'btn-light'); - - const i = document.createElement('i'); - btn.appendChild(i); - i.classList.add('bi', 'bi-stack'); - - return btn; - })(); - - const btn2 = (function() { - const div = document.createElement('div'); - menu.appendChild(div); - div.id = 'map-legend-button'; - div.classList.add('ol-unselectable', 'ol-control'); - - const btn = document.createElement('button'); - div.appendChild(btn); - btn.type = 'button'; - btn.title = 'Teckenförklaring'; - btn.setAttribute('aria-label', btn.title); - btn.setAttribute('aria-expanded', 'false'); - btn.classList.add('btn', 'btn-light'); - - const i = document.createElement('i'); - btn.appendChild(i); - i.classList.add('bi', 'bi-list-task'); - - return btn; - })(); - - const panel = document.getElementById('layer-selection-panel'); - btn.onclick = function(event) { - if (btn.getAttribute('aria-expanded') === 'true') { - panel.setAttribute('aria-hidden', 'true'); + const buttons = Object.fromEntries([ + {id: 'layer-selection', title: 'Lagerval', bi: 'stack'}, + {id: 'map-legend', title: 'Teckenförklaring', bi: 'list-task'}, + {id: 'measure', title: 'Mät i kartan', bi: 'rulers'}, + {id: 'age-filter', title: 'Filtrera objekt efter ålder', bi: 'clock-history'}, + ].map(function(x) { + const div = document.createElement('div'); + MENU.appendChild(div); + div.id = x.id + '-button'; + div.classList.add('ol-unselectable', 'ol-control'); + + const btn = document.createElement('button'); + div.appendChild(btn); + btn.type = 'button'; + btn.title = x.title; + btn.setAttribute('aria-label', btn.title); btn.setAttribute('aria-expanded', 'false'); - btn.classList.replace('btn-dark', 'btn-light'); - } else { - if (btn2.getAttribute('aria-expanded') === 'true') { - btn2.click(); - } - panel.setAttribute('aria-hidden', 'false'); - btn.setAttribute('aria-expanded', 'true'); - btn.classList.replace('btn-light', 'btn-dark'); - } - }; + btn.classList.add('btn', 'btn-light'); - const panel2 = document.getElementById('map-legend-panel'); - btn2.onclick = function(event) { - if (btn2.getAttribute('aria-expanded') === 'true') { - panel2.setAttribute('aria-hidden', 'true'); - btn2.setAttribute('aria-expanded', 'false'); - btn2.classList.replace('btn-dark', 'btn-light'); - } else { - if (btn.getAttribute('aria-expanded') === 'true') { - btn.click(); - } - panel2.setAttribute('aria-hidden', 'false'); - btn2.setAttribute('aria-expanded', 'true'); - btn2.classList.replace('btn-light', 'btn-dark'); + const i = document.createElement('i'); + btn.appendChild(i); + i.classList.add('bi', 'bi-' + x.bi); + return [x.id, btn] + })); + + Object.entries(buttons).forEach(function([id, btn]) { + const panel = document.getElementById(id + '-panel'); + if (panel != null) { + btn.onclick = function() { + if (btn.getAttribute('aria-expanded') === 'true') { + panel.setAttribute('aria-hidden', 'true'); + btn.setAttribute('aria-expanded', 'false'); + btn.classList.replace('btn-dark', 'btn-light'); + } else { + Object.values(buttons).forEach(function(btn2) { + /* close all other panels */ + if (!btn.isEqualNode(btn2) && btn2.getAttribute('aria-expanded') === 'true') { + btn2.click(); + } + }); + panel.setAttribute('aria-hidden', 'false'); + btn.setAttribute('aria-expanded', 'true'); + btn.classList.replace('btn-light', 'btn-dark'); + } + }; } - }; + }); } /* fullscreen control */ @@ -343,20 +339,17 @@ if (window.location === window.parent.location) { labelActive: labelActive, tipLabel: titleInactive, keys: true, - target: menu, - }) + target: MENU, + }); const btn = control.element.getElementsByTagName('button')[0]; btn.classList.add('btn', classInactive); btn.setAttribute('aria-label', btn.title); - map.addControl(control); + MAP.addControl(control); + control.element.id = 'fullscreen-toggle'; /* for the help dialog */ control.addEventListener('enterfullscreen', function() { - featureOverlaySource.clear(true); - const popover = Popover.getInstance(popup); - if (popover !== null) { - /* dispose popover as entering fullscreen messes up its position */ - popover.dispose(); - } + /* dispose popover as entering fullscreen messes up its position */ + disposePopover(); const btn = control.element.getElementsByTagName('button')[0]; btn.classList.replace(classInactive, classActive); @@ -368,14 +361,10 @@ if (window.location === window.parent.location) { /* hide export button in fullscreen mode as it exits it */ exp.classList.add('d-none'); } - }) + }); control.addEventListener('leavefullscreen', function() { - featureOverlaySource.clear(true); - const popover = Popover.getInstance(popup); - if (popover !== null) { - /* dispose popover as is might overflow the viewport */ - popover.dispose(); - } + /* dispose popover as is might overflow the viewport */ + disposePopover(); const btn = control.element.getElementsByTagName('button')[0]; btn.classList.replace(classActive, classInactive); @@ -386,7 +375,7 @@ if (window.location === window.parent.location) { if (exp !== undefined) { exp.classList.remove('d-none'); } - }) + }); } /* export/download button */ @@ -405,17 +394,17 @@ if (window.location === window.parent.location) { const i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-download'); - menu.appendChild(div); + MENU.appendChild(div); - btn.onclick = function(event) { - map.once('rendercomplete', function() { + btn.onclick = function() { + MAP.once('rendercomplete', function() { const canvas0 = document.createElement('canvas'); - const size = map.getSize(); + const size = MAP.getSize(); canvas0.width = size[0]; canvas0.height = size[1]; const context = canvas0.getContext('2d'); - map.getViewport().querySelectorAll('.ol-layer canvas, canvas.ol-layer').forEach(function(canvas) { + MAP.getViewport().querySelectorAll('.ol-layer canvas, canvas.ol-layer').forEach(function(canvas) { if (canvas.width > 0) { const opacity = canvas.parentNode.style.opacity || canvas.style.opacity; context.globalAlpha = opacity === '' ? 1 : Number(opacity); @@ -436,2490 +425,2637 @@ if (window.location === window.parent.location) { }); }); - map.renderSync(); + MAP.renderSync(); }; } -/* info button */ +/* info and help buttons */ (function() { - const div = document.createElement('div'); - menu.appendChild(div); - div.id = 'info-button'; - div.classList.add('ol-unselectable', 'ol-control'); + const add_button = function(x) { + const div = document.createElement('div'); + MENU.appendChild(div); + div.id = x.id + '-button'; + div.classList.add('ol-unselectable', 'ol-control'); - const btn = document.createElement('button'); - div.appendChild(btn); - btn.type = 'button'; - btn.setAttribute('aria-expanded', 'false'); - btn.title = 'Visa information'; - btn.setAttribute('aria-label', btn.title); - btn.classList.add('btn', 'btn-light'); + const btn = document.createElement('button'); + div.appendChild(btn); + btn.type = 'button'; + btn.setAttribute('aria-expanded', 'false'); + btn.title = x.title; + btn.setAttribute('aria-label', btn.title); + btn.classList.add('btn', 'btn-light'); - const i = document.createElement('i'); - btn.appendChild(i); - i.classList.add('bi', 'bi-info-lg'); + const i = document.createElement('i'); + btn.appendChild(i); + i.classList.add('bi', 'bi-' + x.bi); - const panel = document.getElementById('modal-info'); - const modal = new Modal(panel, { - backdrop: false, - }); + const panel = document.getElementById(x.id + '-modal'); + const modal = new Modal(panel, { + backdrop: false, + }); - const backdrop = document.getElementById('modal-info-backdrop'); - backdrop.onclick = function(event) { - modal.hide(); - }; + const backdrop = document.getElementById(x.id + '-modal-backdrop'); + backdrop.onclick = function() { + modal.hide(); + }; - panel.addEventListener('show.bs.modal', function() { - backdrop.classList.add('modal-backdrop', 'show'); - btn.setAttribute('aria-expanded', 'true'); - btn.classList.replace('btn-light', 'btn-dark'); - }); - panel.addEventListener('hidden.bs.modal', function() { - btn.classList.replace('btn-dark', 'btn-light'); - btn.setAttribute('aria-expanded', 'false'); - backdrop.classList.remove('modal-backdrop', 'show'); - }); + panel.addEventListener('show.bs.modal', function() { + backdrop.classList.add('modal-backdrop', 'show'); + btn.setAttribute('aria-expanded', 'true'); + btn.classList.replace('btn-light', 'btn-dark'); + }); + panel.addEventListener('hide.bs.modal', function() { + /* XXX workaround for https://github.com/twbs/bootstrap/issues/41005#issuecomment-2585390544 */ + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + }); + + panel.addEventListener('hidden.bs.modal', function() { + btn.classList.replace('btn-dark', 'btn-light'); + btn.setAttribute('aria-expanded', 'false'); + backdrop.classList.remove('modal-backdrop', 'show'); + }); + + btn.onclick = function() { + modal.show(); + }; + + /* de-obfuscate email address */ + const CLASSNAME = 'email-address-b64'; + const ATTRNAME = 'data-mailto-b64'; + for (const a of panel.getElementsByClassName(CLASSNAME)) { + if (a.tagName.toLowerCase() === 'a' && a.hasAttribute(ATTRNAME)) { + let href = 'mailto:'; + for (const part of a.getAttribute(ATTRNAME).split(/\s+/)) { + switch (part) { + case '__AT__': + href += '@'; + break; + case '__DOT__': + href += '.'; + break; + default: + href += atob(part); + } + } + a.classList.remove(CLASSNAME); + a.removeAttribute(ATTRNAME); + a.href = href; + } + } - btn.onclick = function(event) { - modal.toggle(); + return [panel, btn, modal]; }; -})(); -/* we're all set, show the control container now */ -container.setAttribute('aria-hidden', 'false'); + /* info button */ + (function() { + const [panel, btn, modal] = add_button({ + id: 'info', + title: 'Källor och licensinformation', + bi: 'info-lg', + }); -view.on('change', function(event) { - featureOverlaySource.clear(true); - const popover = Popover.getInstance(popup); - if (popover !== null) { - popover.dispose(); - } + panel.addEventListener('hidden.bs.modal', function() { + infoMetadataAccordions.forEach(function(x, idx) { + /* collapse all accordions */ + const body = x.element.parentNode.parentNode; + const name = 'info-accordion-collapse-' + idx; + if (body.id === name) { + body.classList.remove('show'); + } + if (body.parentNode !== null) { + const headers = body.parentNode.getElementsByClassName('accordion-header'); + for (let i = 0; i < headers.length; i++) { + const buttons = headers[i].getElementsByClassName('accordion-button'); + for (let j = 0; j < buttons.length; j++) { + const btn = buttons[j]; + if (btn.getAttribute('data-bs-target') === '#' + name) { + btn.setAttribute('aria-expanded', 'false'); + btn.classList.add('collapsed'); + } + } + } + } + }); + }); - const coordinates = view.getCenter(); - const searchParams = new URLSearchParams(location.hash.substring(1)); - searchParams.set('x', coordinates[0].toFixed(2).replace(TRAILING_ZEROES, '')); - searchParams.set('y', coordinates[1].toFixed(2).replace(TRAILING_ZEROES, '')); - searchParams.set('z', view.getZoom().toFixed(3).replace(TRAILING_ZEROES, '')); - location.hash = '#' + searchParams.toString(); -}); + const dateFormatter = new Intl.DateTimeFormat(LOCALE); + btn.onclick = function() { + infoMetadataAccordions.forEach((x) => x.element.replaceChildren()); + modal.show(); + Promise.allSettled(Object.entries(mapLayers).map(function([grp,lyr]) { + const baseurl = lyr?.getSource?.()?.get?.('baseurl'); + if (baseurl == null) { + return new Promise(() => { throw new Error(`Unknown source for "${grp}"`); }); + } + return fetch(new URL('metadata.json', baseurl)) + .then(function(resp0) { + if (resp0.status === 200) { + return resp0.json().then((x) => [grp,x]); + } else { + throw new Error(`${resp0.url} [${resp0.status}]`); + } + }); + })) + .then(function(rs) { + const metadata = Object.fromEntries(rs.filter(function(r) { + if (r.status === 'fulfilled') { + return true; + } else if (r.status === 'rejected') { + console.log(r.reason); + } + return false; + }).map((r) => r.value)); + + infoMetadataAccordions.forEach(function(x) { + const ul = x.element; + const groupnames = new Set(); + const last_updated = []; + x.items.forEach(function([groupname]) { + const layer_group = metadata[groupname]; + if (layer_group == null) { + return; + } + if (!groupnames.has(groupname)) { + groupnames.add(groupname); + if (layer_group.last_updated != null) { + last_updated.push(layer_group.last_updated); + } + } + }); + if (last_updated.length > 0) { + /* show creation time of the MVT layers */ + const li = document.createElement('li'); + li.classList.add('list-group-item', 'text-muted'); + ul.appendChild(li); + const i = document.createElement('i'); + i.classList.add('bi', 'bi-map'); + li.appendChild(i); + const t = document.createTextNode( + ' Lokalt skikt (vectiler) genererades ' + + last_updated + .sort() + .map((ts) => dateFormatter.format(new Date(ts))) + .join('; ') + '.' + ); + li.appendChild(t); + } + const source_files = new Set(); + x.items.forEach(function([groupname, layername]) { + /* for each source file associated with the accordion header, show copyright, license and timing information */ + const layer_group = metadata[groupname]; + if (layer_group?.layers == null || layer_group?.source_files == null) { + return; + } + const def = layer_group.layers[layername]; + if (def?.source_files == null) { + return; + } + def.source_files.forEach(function(source_file) { + if (source_files.has(source_file)) { + return; + } + const x = layer_group.source_files[source_file]; + source_files.add(source_file); + + const li = document.createElement('li'); + li.classList.add('list-group-item'); + ul.appendChild(li); + const h = document.createElement('h6'); + li.appendChild(h); + if (x.description != null) { + const t = document.createTextNode(x.description); + h.appendChild(t); + } -/* TODO: this should really be refactored… */ -const layers = { - mrr_appr_ec: { - popoverTitle: 'Bearbetningskoncession \u2013 beviljad', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Ägare', 'Owner'], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Giltig från', 'ValidFrom'], - ['Giltig till', 'ValidTo'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 22, - fill: new Fill({ - color: [247, 170, 67, Math.max((.2-1)/8 * z + 1, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [151, 173, 23, 1], - }), - }); - }), - }, - mrr_appl_ec: { - popoverTitle: 'Bearbetningskoncession \u2013 ansökt', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Sökande', 'Applicant'], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Ansökningsdatum', 'ApplicationDate'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 25, - fill: new Fill({ - color: [247, 170, 67, Math.max((.2-1)/8 * z + 1, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [197, 14, 31, 1], - lineDash: width >= 1.5 ? [2 * width] : undefined, - }), - }); - }), - }, - mrr_appr_met: { - popoverTitle: 'Undersökningstillstånd, metaller och industrimineral \u2013 beviljad', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Ägare', 'Owner'], - ['Licence id', 'LicenceID', { classes: ['feature-attr-mrr-license-id'] }], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Giltig från', 'ValidFrom'], - ['Giltig till', 'ValidTo'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 24, - fill: new Fill({ - color: [0, 0, 0, Math.max((.2-.4)/4 * z + .4, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [151, 173, 23, 1], - }), - }); - }), - }, - mrr_appl_met: { - popoverTitle: 'Undersökningstillstånd, metaller och industrimineral \u2013 ansökt', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Sökande', 'Applicant'], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Ansökningsdatum', 'ApplicationDate'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 26, - fill: new Fill({ - color: [0, 0, 0, Math.max((.2-.4)/4 * z + .4, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [197, 14, 31, 1], - lineDash: width >= 1.5 ? [2 * width] : undefined, - }), - }); - }), - }, - mrr_appr_ogd: { - popoverTitle: 'Undersökningstillstånd, olja, gas och diamant \u2013 beviljad', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Ägare', 'Owner'], - ['Licence id', 'LicenceID', { classes: ['feature-attr-mrr-license-id'] }], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Giltig från', 'ValidFrom'], - ['Giltig till', 'ValidTo'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 24, - fill: new Fill({ - color: [30, 55, 87, Math.max((.2-.4)/4 * z + .4, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [151, 173, 23, 1], - }), - }); - }), - }, - mrr_appl_ogd: { - popoverTitle: 'Undersökningstillstånd, olja, gas och diamant \u2013 ansökt', - popover: [ - ['Namn', 'Name'], - ['Koncessionsmineral', 'Mineral'], - ['Sökande', 'Applicant'], - ['Areal', 'GeomArea', { fn: 'area' }], - ['Ansökningsdatum', 'ApplicationDate'], - ['Diarienummer', 'DiaryNr', { classes: ['feature-attr-dnr'] }], - //['Kommun', 'Municipality'], - //['Län', 'County'], - ['Senast uppdaterad', 'LastUpdated'], - ], - style: [0, .1, .5, .5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5].map(function(width, z) { - return new Style({ - zIndex: 26, - fill: new Fill({ - color: [30, 55, 87, Math.max((.2-.4)/4 * z + .4, 0)], - }), - stroke: width === 0 ? undefined : new Stroke({ - width: width, - color: [197, 14, 31, 1], - lineDash: width >= 1.5 ? [2 * width] : undefined, - }), - }); - }), - }, - mrr_appr_dl: { - popoverTitle: 'Markanvisning till koncession', - popover: [< |
