/*********************************************************************** * Copyright © 2024-2025 Guilhem Moulin * * 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 * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . **********************************************************************/ 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'; import ScaleLine from 'ol/control/ScaleLine.js'; import Zoom from 'ol/control/Zoom.js'; import ZoomSlider from 'ol/control/ZoomSlider.js'; import MVT from 'ol/format/MVT.js'; 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 Fill from 'ol/style/Fill.js'; import Icon from 'ol/style/Icon.js'; import proj4 from 'proj4'; import { get as getProjection } from 'ol/proj.js'; import { register as registerProjection } from 'ol/proj/proj4.js'; import { Modal, Popover } from 'bootstrap'; import { layers } from './src/layers.js'; import { popover } from './src/popover.js'; import './src/style.css'; 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'); /* 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 * the resolution at level 0 is 4096m per pixel (each side is 1048.576km long). * * https://www.lantmateriet.se/globalassets/geodata/geodatatjanster/tb_twk_visning_cache_v1.1.0.pdf * https://www.lantmateriet.se/globalassets/geodata/geodatatjanster/tb_twk_visning-oversiktlig_v1.0.3.pdf * * We set the extent to a 4×4 tiles square at level 2 (1024px = 1048.576km per * 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]; /* 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 * violate its current terms of use (as of January 2024 it's not CC0 open data). * See * * https://www.lantmateriet.se/sv/om-lantmateriet/Rattsinformation/upphovsratt-och-publicering-av-lantmateriets-geografiska-information/ * https://www.lantmateriet.se/sv/kartor/vara-karttjanster/min-karta/#anchor-2 * https://help.locusmap.eu/topic/support-for-swedish-lantmateriets-min-karta-wms * * More precise background maps might be available in the future as open data, * though: * * https://www.lantmateriet.se/sv/om-lantmateriet/press/nyheter/lantmateriets-arbete-mot-oppna-data-i-full-gang/ * 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, }); const age_filter_settings = { active: false, type: 'relative', operator: '<=', quantity: 1, unit: 'y', show_unknown: false, get_relative_date: function(quantity, unit) { if (quantity == null || isNaN(quantity) || unit == null) { return null; } /* use today noon localtime to avoid issues due to DST when substracting dates */ const d = new Date(); d.setHours(12, 0, 0, 0); switch (unit) { case 'd': d.setDate(d.getDate() - quantity); break; case 'w': d.setDate(d.getDate() - 7 * quantity); break; case 'm': d.setMonth(d.getMonth() - quantity); break; case 'y': d.setFullYear(d.getFullYear() - quantity); break; default: return null; } return d; }, _min_ts: null, _max_ts: null, _date_to_ts: function(d) { if (d == null) { return null; } /* number of days since 1970-01-01; take both dates at 00:00:00.0 UTC */ return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())/86_400_000); }, setup_minmax: function() { this._min_ts = this._max_ts = null; switch (this.type) { case 'relative': { const date = this.get_relative_date(this.quantity, this.unit); const prop = {'<=':'_min_ts', '>=':'_max_ts'}[this.operator]; this[prop] = this._date_to_ts(date); break; } case 'interval': { this._min_ts = this._date_to_ts(this.from); this._max_ts = this._date_to_ts(this.to); break; } } }, }; let baseMapLayer = 'topowebb_nedtonad'; (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('layers') || (!params.get('layers').match(/^\s*$/) && /* compat redirect/layer subst for old non-hierachical names */ !params.get('layers').split(' ').some((l) => l.includes('.')))) { params.set('layers', [ 'svk.ledningar', 'svk.stolpar', 'svk.stationer', 'svk.transmissionsnatsprojekt', 'misc.gigafactories', 'misc.dammar', 'mrr.appr_ec', 'mrr.appl_ec', 'mrr.appr_ogd', 'mrr.appl_ogd', 'mrr.appr_met', 'mrr.appl_met', 'mrr.appr_dl', 'vbk.area_current', 'vbk.area_notcurrent', ].join(' ')); location.hash = '#' + params.toString(); } if (params.has('basemap')) { baseMapLayer = params.get('basemap'); } baseMapSource.setUrl(`https://minkarta.lantmateriet.se/map/topowebbcache?LAYER=${encodeURIComponent(baseMapLayer)}`); if (params.has('age-filter')) { (function(param) { if (param === '') { return; } /* eslint-disable-next-line no-useless-escape */ const m0 = /^([ +\-]?)([0-9]+)([dwmy])$/.exec(param); if (m0 != null) { age_filter_settings.type = 'relative'; age_filter_settings.operator = (m0[1] === ' ' || m0[1] === '+' || m0[1] === '') ? '>=' : m0[1] === '-' ? '<=' : null; age_filter_settings.quantity = parseInt(m0[2], 10); age_filter_settings.unit = m0[3]; age_filter_settings.setup_minmax(); age_filter_settings.active = true; return; } const m1 = /^([0-9]{8})-([0-9]{8})$/.exec(param); /* YYYYMMDD */ if (m1 != null) { const parse_date = (m) => new Date( parseInt(m.slice(0,4), 10), parseInt(m.slice(4,6), 10)-1, parseInt(m.slice(6,8), 10), 12 /* use 12:00:00.0 like for the */ ); age_filter_settings.type = 'interval'; age_filter_settings.from = parse_date(m1[1]); age_filter_settings.to = parse_date(m1[2]); age_filter_settings.setup_minmax(); age_filter_settings.active = true; return; } console.log(`Ignoring invalid value for 'age-filter' parameter: ${param}`); })(params.get('age-filter')); } if (params.has('show-unknown-age')) { const param = params.get('show-unknown-age'); if (param === '0') { age_filter_settings.show_unknown = false; } else if (param === '1') { age_filter_settings.show_unknown = true; } } })(); const map = new Map({ controls: [], view: view, layers: [ new TileLayer({ source: baseMapSource }), ], target: document.getElementById('map'), }); const popup = document.getElementById('popup'); /* move the control container to the viewport */ const container = document.getElementById('map-control-container'); (function() { const container0 = map.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0]; container0.appendChild(document.getElementById('zoom-control')); container0.appendChild(container); container0.appendChild(document.getElementById('info-modal')); const info_backdrop = document.createElement('div'); container0.appendChild(info_backdrop); info_backdrop.id = 'info-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'); container0.appendChild(age_filter); const age_filter_backdrop = document.createElement('div'); age_filter_backdrop.id = 'age-filter-modal-backdrop'; container0.appendChild(age_filter_backdrop); })(); /* zoom in/out */ (function() { const zoomInLabel = document.createElement('i'); zoomInLabel.classList.add('bi', 'bi-plus'); const zoomOutLabel = document.createElement('i'); zoomOutLabel.classList.add('bi', 'bi-dash'); const control = new Zoom({ zoomInTipLabel: 'Zooma in', zoomInLabel: zoomInLabel, zoomOutTipLabel: 'Zooma ut', zoomOutLabel: zoomOutLabel, target: document.getElementById('zoom-control'), }); control.element.classList.add('btn-group-vertical'); for (const btn of control.element.getElementsByTagName('button')) { btn.classList.add('btn', 'btn-light'); } map.addControl(control); })(); /* zoom slider */ (function() { const control = new ZoomSlider({ target: document.getElementById('zoom-control'), }); control.element.classList.add('modal'); for (const btn of control.element.getElementsByTagName('button')) { btn.classList.add('btn', 'btn-light'); } map.addControl(control); })(); /* scale line */ (function() { const size = map.getSize(); const control = new ScaleLine({ units: 'metric', minWidth: 150, maxWidth: size[1] < 350 ? size[1] - 50 : 350, target: container, }); control.element.classList.add('modal', 'modal-content'); map.addControl(control); })(); 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); div.classList.add('ol-unselectable', 'ol-control'); const btn = document.createElement('button'); div.appendChild(btn); btn.type = 'button'; btn.title = 'Öppna karta i ny flik'; 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-box-arrow-up-right'); btn.onclick = function() { const coordinates = view.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, '')); url.hash = '#' + searchParams.toString(); return window.open(url.href, '_blank'); }; } /* layer selection button and legend */ if (window.location === window.parent.location) { const buttons = Object.fromEntries([ {id: 'layer-selection', title: 'Lagerval', bi: 'stack'}, {id: 'map-legend', title: 'Teckenförklaring', bi: 'list-task'}, {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.add('btn', 'btn-light'); const i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-' + x.bi); return [x.id, btn] })); Object.keys(buttons).forEach(function(id) { const panel = document.getElementById(id + '-panel'); if (panel == null) { return; } const btn = buttons[id]; 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 */ if (window.location === window.parent.location) { const label = document.createElement('i'); label.classList.add('bi', 'bi-fullscreen'); const labelActive = document.createElement('i'); labelActive.classList.add('bi', 'bi-fullscreen-exit'); const titleInactive = 'Helskärmsläge'; const titleActive = 'Lämna helskärmsläge'; const classInactive = 'btn-light'; const classActive = 'btn-dark'; const control = new FullScreen({ label: label, labelActive: labelActive, tipLabel: titleInactive, keys: true, target: menu, }) const btn = control.element.getElementsByTagName('button')[0]; btn.classList.add('btn', classInactive); btn.setAttribute('aria-label', btn.title); map.addControl(control); control.addEventListener('enterfullscreen', function() { featureOverlayLayer.setVisible(false); /* dispose popover as entering fullscreen messes up its position */ Popover.getInstance(popup)?.dispose(); const btn = control.element.getElementsByTagName('button')[0]; btn.classList.replace(classInactive, classActive); btn.title = titleActive; btn.setAttribute('aria-label', btn.title); const exp = document.getElementById('export-to-image'); if (exp !== undefined) { /* hide export button in fullscreen mode as it exits it */ exp.classList.add('d-none'); } }) control.addEventListener('leavefullscreen', function() { featureOverlayLayer.setVisible(false); /* dispose popover as is might overflow the viewport */ Popover.getInstance(popup)?.dispose(); const btn = control.element.getElementsByTagName('button')[0]; btn.classList.replace(classActive, classInactive); btn.title = titleInactive; btn.setAttribute('aria-label', btn.title); const exp = document.getElementById('export-to-image'); if (exp !== undefined) { exp.classList.remove('d-none'); } }) } /* export/download button */ if (window.location === window.parent.location) { const div = document.createElement('div'); div.classList.add('ol-unselectable', 'ol-control'); div.id = 'export-to-image'; const btn = document.createElement('button'); div.appendChild(btn); btn.classList.add('btn', 'btn-light'); btn.type = 'button'; btn.title = 'Ladda ner som en PNG-fil'; btn.setAttribute('aria-label', btn.title); const i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-download'); menu.appendChild(div); btn.onclick = function() { map.once('rendercomplete', function() { const canvas0 = document.createElement('canvas'); 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) { if (canvas.width > 0) { const opacity = canvas.parentNode.style.opacity || canvas.style.opacity; context.globalAlpha = opacity === '' ? 1 : Number(opacity); context.drawImage(canvas, 0, 0); } }); context.globalAlpha = 1; context.setTransform(1, 0, 0, 1, 0, 0); canvas0.toBlob(function(blob) { const a = document.createElement('a'); a.download = 'karta.png'; a.rel = 'noopener'; a.href = URL.createObjectURL(blob); setTimeout(function() { URL.revokeObjectURL(a.href) }, 4E4); // 40s setTimeout(function() { a.click() }, 0); }); }); map.renderSync(); }; } /* info button */ (function() { const div = document.createElement('div'); menu.appendChild(div); div.id = 'info-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 i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-info-lg'); const panel = document.getElementById('info-modal'); const modal = new Modal(panel, { backdrop: false, }); const backdrop = document.getElementById('info-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('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'); 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'); } } } } }); }); btn.onclick = function() { infoMetadataAccordions.forEach((x) => x.element.replaceChildren()); modal.show(); Promise.allSettled(Object.entries(mapLayers).map(function([grp,lyr]) { if (lyr?.getSource() instanceof VectorTile) { const url = lyr.getSource().getUrls()[0]; if (url == null || url.length <= 16 || url.substr(url.length - 16) !== '/{z}/{x}/{y}.pbf') { return new Promise(() => { throw new Error(`Invalid URL ${url}`); }); } return fetch(url.substr(0, url.length - 16) + '/metadata.json') .then(function(resp0) { if (resp0.status === 200) { return resp0.json().then((x) => [grp,x]); } else { throw new Error(`${resp0.url} [${resp0.status}]`); } }); } return new Promise(() => { throw new Error(`Unknown source for "${grp}"`); }); })) .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) => new Date(ts).toLocaleDateString('sv-SE')) .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); } if (x.copyright != null) { const p = document.createElement('p'); li.appendChild(p) const t = document.createTextNode(x.copyright); p.appendChild(t); } if (x.license != null) { const p = document.createElement('p'); li.appendChild(p) p.appendChild(document.createTextNode('Licensvillkor: ')); const t = document.createTextNode(x.license.name); if (x.license.url == null) { p.appendChild(t); } else { const a = document.createElement('a'); a.href = x.license.url; a.target = '_blank'; a.appendChild(t); p.appendChild(a); } } if (x.product_url != null) { const p = document.createElement('p'); li.appendChild(p) const t = document.createTextNode('Produktlänk '); const i = document.createElement('i'); i.classList.add('bi', 'bi-box-arrow-up-right'); const a = document.createElement('a'); a.href = x.product_url; a.target = '_blank'; a.appendChild(t); a.appendChild(i); p.appendChild(a); } if (x.last_modified != null) { const p = document.createElement('p'); p.classList.add('small', 'text-muted'); li.appendChild(p); const i = document.createElement('i'); i.classList.add('bi', 'bi-file-earmark-code'); p.appendChild(i); p.appendChild(document.createTextNode(' ')); const t0 = document.createTextNode('Källfil'); if (x.url == null) { p.appendChild(t0); } else { const a = document.createElement('a'); p.appendChild(a); const i = document.createElement('i'); i.classList.add('bi', 'bi-box-arrow-up-right'); a.appendChild(t0); a.appendChild(document.createTextNode(' ')); a.appendChild(i); a.href = x.url; a.target = '_blank'; } const t1 = document.createTextNode(' ändrades senast '); p.appendChild(t1); const d = new Date(x.last_modified); const td = document.createTextNode(d.toLocaleDateString('sv-SE')); p.appendChild(td); const t2 = document.createTextNode('.') p.appendChild(t2); } }); }); }); }); }; })(); /* we're all set, show the control container now */ container.setAttribute('aria-hidden', 'false'); view.on('change', function() { featureOverlayLayer.setVisible(false); Popover.getInstance(popup)?.dispose(); 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 layerHierarchy = [ { text: 'Transmissionsnät för el', children: [ { text: 'Kraftledningar (befintliga)', layer: ['svk.stolpar', 'svk.ledningar'], }, { text: 'Stationer', layer: 'svk.stationer', }, { text: 'Transmissionsnätsprojekt ', layer: 'svk.transmissionsnatsprojekt', }, ], }, { text: 'Stora industrisatsningar', layer: 'misc.gigafactories', }, { text: 'Dammar', layer: 'misc.dammar', }, { text: 'Mineralrättigheter', children: [ { text: 'Bearbetningskoncessioner', children: [ { text: 'Beviljad', layer: 'mrr.appr_ec', }, { text: 'Ansökt', layer: 'mrr.appl_ec', }, ], }, { text: 'Undersökningstillstånd, olja, gas och diamant', children: [ { text: 'Beviljad', layer: 'mrr.appr_ogd', }, { text: 'Ansökt', layer: 'mrr.appl_ogd', }, ], }, { text: 'Undersökningstillstånd, metaller och industrimineral', children: [ { text: 'Beviljad', layer: 'mrr.appr_met', }, { text: 'Ansökt', layer: 'mrr.appl_met', }, ], }, { text: 'Markanvisningar till koncession', layer: 'mrr.appr_dl', }, ], }, { text: 'Vindbruk', children: [ { text: 'Landbaserade projekteringsområden', children: [ { text: 'Aktuella', layer: 'vbk.area_current', }, { text: 'Ej aktuella', layer: 'vbk.area_notcurrent', }, ], }, { text: 'Landbaserade vindkraftverk', children: [ { text: 'Uppförda', layer: 'vbk.station_completed', }, { text: 'Handläggs', layer: 'vbk.station_processed', }, { text: 'Beviljade', layer: 'vbk.station_approved', }, { text: 'Inte längre aktuella/återkallade', layer: 'vbk.station_revoked', }, { text: 'Avslagna/nekad', layer: 'vbk.station_rejected', }, { text: 'Nedmonterade', layer: 'vbk.station_dismounted', }, { text: 'Överklagade', layer: 'vbk.station_appealed', }, ], }, { text: 'Havsbaserad vindkraft', children: [ { text: 'Uppförd', layer: 'vbk.offshore_completed', }, { text: 'Tillståndsansökan beviljad', layer: 'vbk.offshore_approved', }, { text: 'Ändringsansökan', layer: 'vbk.offshore_amended', }, { text: 'Tillståndsansökan avslagen', layer: 'vbk.offshore_rejected', }, { text: 'Överklagad', layer: 'vbk.offshore_appealed', }, { text: 'Tillståndsansökan inlämnad', layer: 'vbk.offshore_applied', }, { text: 'Samråd inför tillståndsansökan', layer: 'vbk.offshore_consultation', }, { text: 'Inledande undersökningar', layer: 'vbk.offshore_investigation', }, { text: 'Inte längre aktuell/återkallad', layer: 'vbk.offshore_revoked', }, ], }, ] }, { text: 'Skogsbruk', children: [ { text: 'Utförda avverkningar', layer: 'avverk.utford', }, { text: 'Avverkningsanmälningar', layer: 'avverk.anmald', }, ] }, { text: 'Skyddad natur', children: [ { text: 'Nationella skyddsformer från Naturvårdsregistret', children: [ { text: 'Tillträdesförbud', layer: 'skydd.tilltradesforbud', }, { text: 'Nationalpark', layer: 'skydd.nationalpark', }, { text: 'Naturreservat', layer: 'skydd.naturreservat', }, { text: 'Kommunala naturreservat', layer: 'skydd.naturreservat_kommunalt', }, { text: 'Naturvårdsområden', layer: 'skydd.naturvardsomrade', }, { text: 'Djur- och växtskyddsområden', layer: 'skydd.djur_och_vaxtskyddsomrade', }, { text: 'Kulturreservat', layer: 'skydd.kulturreservat', }, { text: 'Vattenskyddsområden', layer: 'skydd.vattenskyddsomrade', }, { text: 'Landskapsbildsskyddsområden', layer: 'skydd.landskapsbildsskyddsomrade', }, { text: 'Skogliga biotopskyddsområden', layer: 'skydd.skogligt_biotopskyddsomrade', }, { text: 'Övriga biotopskyddsområden', layer: 'skydd.ovrigt_biotopskyddsomrade', }, { text: 'Naturminne', layer: [ 'skydd.naturminne_yta', 'skydd.naturminne_punkt' ], }, { text: 'Interimistiskt förbud', layer: 'skydd.interimistiskt_forbud', }, ], }, { text: 'Natura 2000-områden', children: [ { text: 'Fågeldirektivet (SPA)', layer: 'skydd.fageldirektivet', }, { text: 'Art- och habitatdirektivet (SCI)', layer: 'skydd.habitatdirektivet', }, ], }, { text: 'Områden med internationell status', children: [ { text: 'Marina skyddade områden (Helcom MPA)', layer: 'skydd.helcom', }, { text: 'Ramsar-områden (Våtmarkskonventionen)', layer: 'skydd.ramsar', }, { text: 'Marina skyddade områden (Ospar MPA)', layer: 'skydd.ospar', }, { text: 'Världsarv med mycket höga naturvärden (UNESCO)', layer: 'skydd.varldsarv', }, { text: 'Biosfärsområden (UNESCO)', layer: 'skydd.biosfarsomraden', }, ], }, { text: 'Naturvårdsavtal', children: [ { text: 'Naturvårdsverket, Länsstyrelserna', layer: 'skydd.naturvardsavtal', }, { text: 'Skogsstyrelsen', layer: 'skydd.naturvardsavtal_skogsstyrelsen', } ] }, { text: 'Återvätningsavtal', layer: 'skydd.atervatningsavtal', } ] }, { text: 'Skogliga värden', children: [ { text: 'Objekt med naturvärden (Skogsstyrelsen)', layer: 'nv.naturvarde_sks', }, { text: 'Nyckelbiotoper', layer: 'nv.nyckelbiotop', }, { text: 'Nyckelbiotoper storskogsbruket', layer: 'nv.nyckelbiotop_storskogsbruk', }, { text: 'Sumpskogar', layer: 'nv.sumpskog', }, { text: 'Pågående naturreservatsbildning', layer: 'nv.pagaende_naturreservatsbildning', }, { text: 'Skyddsvärda statliga skogar', layer: 'nv.snus', }, { text: 'Sannolikt och potentiell kontinuitetsskog', children: [ { text: 'Sannolikt kontinuitetsskog (preciserad)', layer: 'kskog.1', }, { text: 'Sannolikt påverkad kontinuitetsskog (preciserad)', layer: 'kskog.2', }, { text: 'Sannolikt kontinuitetsskog i fjällen (grövre precisering)', layer: 'kskog.3', }, { text: 'Potentiell kontinuitetsskog (2015)', layer: 'kskog.4', }, ], } ] }, { text: 'Riksintressen, naturvård och friluftsliv', children: [ { text: 'Naturvård', layer: 'ri.naturvard', }, { text: 'Friluftsliv', layer: 'ri.friluftsliv', }, { text: 'Rörligt friluftsliv', layer: 'ri.rorligt_friluftsliv', }, { text: 'Obruten kust', layer: 'ri.obruten_kust', }, { text: 'Obrutet fjäll', layer: 'ri.obrutet_fjall', }, { text: 'Skyddade vattendrag', layer: 'ri.skyddade_vattendrag', }, ] }, { /* Definitions at * https://ext-dokument.lansstyrelsen.se/Gemensamt/Geodata/Datadistribution/Information,%20Skiktf%C3%B6rteckning%20och%20f%C3%B6rklaringar.pdf */ text: 'Rennäringen', children: [ { text: 'Betesområden', layer: 'ren.betesomrade', }, { text: 'Flyttled', layer: 'ren.flyttled', }, { text: 'Riksintressen', layer: 'ren.riks_ren', }, { text: '(Kärn)områden av riksintresse', layer: 'ren.omr_riks', }, ] }, ]; const styles = (function() { const searchParams = new URLSearchParams(location.hash.substring(1)); const layersParams = searchParams.has('layers') ? searchParams.get('layers').split(' ') : []; return Object.keys(layers).reduce(function(result, key) { if (layersParams.includes(key)) { result[key] = layers[key].style; } return result; }, {}); })(); const [mapLayers, featureOverlayLayer] = (function() { const baseurl = '/'; const xyz = '/{z}/{x}/{y}.pbf'; const tileGrid = createXYZ({ extent: extent, tileSize: 1024, maxResolution: 1024, /* = 1048576/1024 */ minZoom: 0, maxZoom: 7, }); const isVisible = function(groupname) { return Object.keys(layers).some((layername) => layername.startsWith(groupname + '.') && styles[layername] !== undefined); }; const canWebGL2 = !!document.createElement('canvas').getContext('webgl2'); /* Note: layers are added in the order below, so leave SvK and * misc at the end so they show up on top of suface features */ const rasterLayers = ['kskog']; const vectorLayers = ['nv', 'mrr', 'skydd', 'ren', 'ri', 'avverk', 'vbk', 'svk', 'misc']; const canFilterByAge = ['avverk', 'mrr', 'vbk']; /* layers for which features are dated */ const ret = {}; if (!canWebGL2) { rasterLayers.forEach((k) => ret[k] = null); } else { rasterLayers.forEach(function(k) { ret[k] = new TileLayerGL({ /* Naturvårdsverket has a WMS server we could use instead, but by serving it ourselves * we can filter on he various kskog classes */ source: new GeoTIFF({ sources: [{ url: baseurl + 'raster/' + k + '.tiff', }], normalize: false, convertToRGB: false, wrapX: false, interpolate: false, /* use the projection found in the source's metadata */ }), visible: false, style: null, /* filled later */ }); map.addLayer(ret[k]); }); } vectorLayers.forEach(function(k) { const canFilterByAge0 = canFilterByAge.includes(k); ret[k] = new VectorTileLayer({ source: new VectorTile({ url: baseurl + 'tiles/' + k + xyz, format: new MVT(), projection: projection, wrapX: false, transition: 0, tileGrid: tileGrid, }), /* XXX switch to 'hybrid' if there are perf issues; but that seems to * put lines above points regardless of their respective z-index */ renderMode: 'hybrid', declutter: false, visible: isVisible(k), style: function(feature, resolution) { /* WARN: very hot code path! */ const properties = feature.getProperties(); if (age_filter_settings.active) { /* TODO avoid doing this checks for each feature; instead, set up a * different style function if age_filter_settings.active */ const ts = properties.ts; if (ts == null) { if (canFilterByAge0 && !age_filter_settings.show_unknown) { return null; } } else if ((age_filter_settings._min_ts !== null && ts < age_filter_settings._min_ts) || (age_filter_settings._max_ts !== null && ts > age_filter_settings._max_ts)) { return null; } } const style = styles[k + '.' + properties.layer]; if (!Array.isArray(style)) { return style; } else { const maxi = style.length - 1; const z = 10 /* Math.log2(maxResolution) */ - Math.log2(resolution); /* use Math.floor() as VectorTile.js calls getZForResolution(resolution, 1) */ const i = z <= 0 ? 0 : z >= maxi ? maxi : Math.floor(z); // console.log(`resolution=${resolution}, z=${z}, i=${i}`); return style[i]; } } }); ret[k].set('layerGroup', k, true); ret[k].set('canFilterByAge', canFilterByAge0, true); map.addLayer(ret[k]); }); return [ ret, /* We use a vector tile layer for featureOverlayLayer instead of a simple * vector layer overlay, since we don't want to clip selected geometries at * tile boundaries. It sounds overkill to load an entire tile layer to * display a single feature, but this shouldn't cause much overhead in * practice (the tiles are most likely cached already). */ new VectorTileLayer({ source: new VectorTile({ urls: [], format: new MVT(), projection: projection, wrapX: false, transition: 0, tileGrid: tileGrid, }), zIndex: 65535, declutter: false, visible: false, renderMode: 'vector', map: map, style: null, }), ]; })(); /* legend panel */ (function() { const modal = document.getElementById('map-legend-panel'); modal.classList.add('modal'); modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-hidden', 'true'); const content = document.createElement('div'); modal.appendChild(content); content.classList.add('modal-content'); const body = document.createElement('div'); content.appendChild(body); body.classList.add('modal-body'); const createLegend = function(ul, elem, classes) { const li = document.createElement('li'); li.classList.add('list-group-item'); ul.append(li); const t = document.createTextNode(elem.text); if (elem.layer === undefined) { li.appendChild(t); li.classList.add('map-legend-header'); } else { li.classList.add('d-flex', 'flex-row'); const div = document.createElement('div'); div.classList.add('map-legend-symbol'); li.appendChild(div); const parent_height = 24; /* see CSS */ const [width, height] = [32, 20]; const symbols = { polygon: new Polygon([[ [0, (parent_height-height)/2], [width, (parent_height-height)/2], [width, (parent_height+height)/2], [0, (parent_height+height)/2], [0, (parent_height-height)/2], ]]), linestring: new LineString([ [0, parent_height/2], [width, parent_height/2] ]), point: new Point( [width/2, parent_height/2] ), }; let canvas, render; (Array.isArray(elem.layer) ? elem.layer : [elem.layer]) .forEach(function(layer) { /* add symbols for each layer */ const layerGroup = layer.split('.', 1)[0]; if (!layerGroup || !mapLayers[layerGroup] || !layers[layer] || !layers[layer].style) { console.log(`Could not find symbol for layer ${layer}, skipping`); return; } const legend = layers[layer].legend || {}; if (canvas == null || !legend.reuse_canvas) { canvas = document.createElement('canvas'); div.appendChild(canvas); render = toContext(canvas.getContext('2d'), { size:[width, parent_height] } ); } if (mapLayers[layerGroup].getSource() instanceof GeoTIFF) { /* raster source */ render.setFillStrokeStyle(new Fill({ color: layers[layer].style, })); return render.drawGeometry(symbols.polygon); } else if (mapLayers[layerGroup].getSource() instanceof VectorTile) { /* vector source */ const style = Array.isArray(layers[layer].style) ? layers[layer].style[legend.zoomLevel ?? 5] : layers[layer].style; const legend_type = legend.type ?? 'polygon'; if (legend_type === 'point' && style.getImage(1) instanceof Icon && style.getImage(1).getSrc()) { /* use a new element since .setStyle() returns the same one and doesn't work in that case */ const div2 = document.createElement('div'); const img = document.createElement('img'); div2.appendChild(img); img.src = style.getImage(1).getSrc(); img.height = height; div.replaceChild(div2, canvas); } else { const style2 = style.clone(); const stroke = style2.getStroke(); if (legend_type === 'polygon' && stroke && stroke.getWidth() > 1) { /* don't stroke too wide to avoid spilling over boxes above/below */ stroke.setWidth(1); } render.setStyle(style2); return render.drawGeometry(symbols[legend_type]); } } else { throw new Error(`Cannot show legend for ${layer}`); } }); const span = document.createElement('div'); span.appendChild(t); li.appendChild(span); } elem._legend = li; if (elem.children !== undefined && elem.children.length > 0) { if (classes.length > 0) { li.classList.add(classes[0]); classes = classes.slice(1); } const ul2 = document.createElement('ul'); ul2.classList.add('list-group', 'list-group-flush'); ul.appendChild(ul2); elem.children.forEach((elem2) => createLegend(ul2, elem2, classes)); } }; const ul = document.createElement('ul'); ul.classList.add('list-group', 'list-group-flush'); body.appendChild(ul); layerHierarchy.forEach(function(x) { createLegend(ul, x, ['h4', 'h5', 'h6']); }); })(); /* layer selection panel */ const infoMetadataAccordions = []; (function() { const modal = document.getElementById('layer-selection-panel'); modal.classList.add('modal'); modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-hidden', 'true'); const content = document.createElement('div'); modal.appendChild(content); content.classList.add('modal-content'); const body = document.createElement('div'); content.appendChild(body); body.classList.add('modal-body'); const accordion = document.createElement('div'); body.appendChild(accordion); accordion.id = 'layer-selection-accordion'; accordion.classList.add('accordion', 'accordion-flush'); (function collectLayers(list) { list.forEach(function(elem) { elem._layers = elem.layer === undefined ? [] : Array.isArray(elem.layer) ? elem.layer : [elem.layer]; if (elem.children !== undefined && elem.children.length > 0) { collectLayers(elem.children); elem.children.forEach(function(child) { child._layers.forEach((l) => elem._layers.push(l)); }); } }); })(layerHierarchy); const setIndeterminateAndChecked = function(list) { return list.forEach(function(elem) { const layerStyles = elem._layers.map((lyr) => styles[lyr] !== undefined); elem._input.indeterminate = elem._layers.length <= 1 ? false : layerStyles.slice(1).some((v) => v !== layerStyles[0]); if (elem._input.indeterminate) { elem._legend.classList.remove('d-none'); } else { /* keep checked value if indeterminate */ const checked = layerStyles.every((v) => v); elem._input.checked = checked; if (checked) { elem._legend.classList.remove('d-none'); } else { elem._legend.classList.add('d-none'); } } if (elem.children !== undefined && elem.children.length > 0) { setIndeterminateAndChecked(elem.children); } }); }; const fixLayerVisibility = function() { const result = {} const nodata = [ 0, 0, 0, .0]; const kskog_palette = [nodata, nodata, nodata, nodata, nodata]; Object.keys(layers).forEach(function(lyr) { const layerGroup = lyr.split('.', 1)[0]; if (result[layerGroup] === undefined) { result[layerGroup] = false; } result[layerGroup] ||= styles[lyr] !== undefined; if (layerGroup === 'kskog') { const i = parseInt(lyr.slice(layerGroup.length + 1)) kskog_palette[i] = styles[lyr] ?? nodata; } }); const kskog = mapLayers['kskog']; if (kskog != null) { /* XXX unfortunately calling .setStyle() makes the layer blink */ kskog.setStyle({ color: ['palette', ['band', 1], kskog_palette ] }); } Object.entries(result).forEach(function([lyr, visible]) { //console.log(lyr, visible); mapLayers[lyr]?.setVisible(visible); }); const btn = document.getElementById('map-legend-button'); if (Object.values(styles).some((v) => v !== null)) { btn.classList.remove('disabled'); } else { btn.classList.add('disabled'); } }; const onClickFunction = function(layerList, event) { featureOverlayLayer.setVisible(false); featureOverlayLayer.changed(); Popover.getInstance(popup)?.dispose(); const searchParams = new URLSearchParams(location.hash.substring(1)); let layersParams = searchParams.get('layers') || ''; layersParams = layersParams.match(/^\s*$/) ? [] : layersParams.split(' '); if (event.target.checked) { layerList.forEach(function(lyr) { if (mapLayers[lyr.split('.', 1)[0]] == null) { return; /* keep unexisting layers (eg WebGL layers on a system without WebGL support) unselectable */ } styles[lyr] = layers[lyr].style; if (!layersParams.includes(lyr)) { layersParams.push(lyr); } }); } else { layerList.forEach(function(lyr) { delete styles[lyr]; }); layersParams = layersParams.filter((lyr) => !layerList.includes(lyr)); } setIndeterminateAndChecked(layerHierarchy); fixLayerVisibility(); layerList .map((l) => l.split('.', 1)[0]) .filter((v, i, arr) => mapLayers[v] != null && arr.indexOf(v) === i) .forEach((l) => mapLayers[l].getSource().changed()); searchParams.set('layers', layersParams.join(' ')); location.hash = '#' + searchParams.toString(); }; let layerId = 0; const addAccordionGroup = function(parentNode, children) { const ul = document.createElement('ul'); parentNode.appendChild(ul); ul.classList.add('list-group', 'list-group-flush'); children.forEach(function(child) { const li = document.createElement('li'); ul.appendChild(li); li.classList.add('list-group-item'); const div = document.createElement('div'); li.appendChild(div); div.classList.add('d-inline-flex'); const input = child._input = document.createElement('input'); div.appendChild(input); input.classList.add('form-check-input'); input.type = 'checkbox'; input.id = 'layer' + layerId++; const label = document.createElement('label'); div.appendChild(label); label.classList.add('form-check-label'); label.setAttribute('for', input.id); const textNode = document.createTextNode(child.text); label.appendChild(textNode); if (child.children !== undefined && child.children.length > 0) { addAccordionGroup(li, child.children); } input.onclick = function(event) { return onClickFunction(child._layers, event); }; }); }; layerHierarchy.forEach(function(x, idx) { const item = document.createElement('div'); accordion.appendChild(item); item.classList.add('accordion-item'); const header = document.createElement('div'); item.appendChild(header); header.classList.add('accordion-header'); const btn = document.createElement('button'); header.appendChild(btn); const collapse = document.createElement('div'); item.appendChild(collapse); collapse.id = 'accordion-collapse-' + idx; collapse.classList.add('accordion-collapse', 'collapse'); /* never expand more than accordion at a time */ collapse.setAttribute('data-bs-parent', '#' + accordion.id); btn.type = 'button'; btn.setAttribute('data-bs-toggle', 'collapse'); btn.setAttribute('data-bs-target', '#' + collapse.id); btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-controls', collapse.id); btn.classList.add('accordion-button', 'collapsed'); const span0 = document.createElement('span'); btn.appendChild(span0); span0.classList.add('form-check'); span0.setAttribute('data-bs-toggle', 'collapse'); span0.setAttribute('data-bs-target', ''); const input0 = x._input = document.createElement('input'); span0.appendChild(input0); input0.classList.add('form-check-input'); input0.type = 'checkbox'; input0.id = 'layer' + layerId++; const label0 = document.createElement('label'); span0.appendChild(label0); label0.classList.add('form-check-label'); label0.setAttribute('for', input0.id); const text0 = document.createTextNode(x.text); label0.appendChild(text0); if (x.children === undefined || x.children.length === 0) { item.replaceChild(span0, header); } else { const body = document.createElement('div'); collapse.appendChild(body); body.classList.add('accordion-body'); addAccordionGroup(body, x.children); } input0.onclick = function(event) { return onClickFunction(x._layers, event); }; }); setIndeterminateAndChecked(layerHierarchy); fixLayerVisibility(); (function() { const item = document.createElement('div'); accordion.appendChild(item); item.classList.add('accordion-item'); const div = document.createElement('div'); item.appendChild(div); div.classList.add('form-check', 'form-switch'); const input = document.createElement('input'); div.appendChild(input); input.classList.add('form-check-input'); input.type = 'checkbox'; input.setAttribute('role', 'switch'); input.id = 'layer' + layerId++; const label = document.createElement('label'); div.appendChild(label); label.classList.add('form-check-label'); label.setAttribute('for', input.id); label.innerHTML = 'Nedtonad bakgrund karta'; input.checked = baseMapLayer === 'topowebb_nedtonad'; input.onchange = function(event) { baseMapLayer = event.target.checked ? 'topowebb_nedtonad' : 'topowebb'; baseMapSource.setUrl(`https://minkarta.lantmateriet.se/map/topowebbcache?LAYER=${encodeURIComponent(baseMapLayer)}`); const searchParams = new URLSearchParams(location.hash.substring(1)); searchParams.set('basemap', baseMapLayer); location.hash = '#' + searchParams.toString(); }; })(); (function() { const accordion = document.getElementById('info-accordion'); layerHierarchy.forEach(function(x, idx) { const item = document.createElement('div'); accordion.appendChild(item); item.classList.add('accordion-item'); const header = document.createElement('div'); item.appendChild(header); header.classList.add('accordion-header'); const btn = document.createElement('button'); header.appendChild(btn); const collapse = document.createElement('div'); item.appendChild(collapse); collapse.id = 'info-accordion-collapse-' + idx; collapse.classList.add('accordion-collapse', 'collapse'); btn.type = 'button'; btn.setAttribute('data-bs-toggle', 'collapse'); btn.setAttribute('data-bs-target', '#' + collapse.id); btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-controls', collapse.id); btn.classList.add('accordion-button', 'collapsed'); const t = document.createTextNode(x.text); btn.appendChild(t); const body = document.createElement('div'); body.classList.add('accordion-body'); collapse.appendChild(body); const ul = document.createElement('ul'); ul.classList.add('list-group', 'list-group-flush'); body.appendChild(ul); infoMetadataAccordions.push({ element: ul, items: x._layers.map(function(k) { const groupname = k.split('.', 1)[0]; return [ groupname, k.slice(groupname.length + 1) ]; }), }); }); })(); })(); /* age filter panel */ (function() { const panel = document.getElementById('age-filter-modal'); const dialog_setup = (function() { const dialog = document.createElement('div'); dialog.classList.add('modal-dialog', 'modal-dialog-centered'); panel.appendChild(dialog); const content = document.createElement('div'); content.classList.add('modal-content'); dialog.appendChild(content); const header = document.createElement('div'); header.classList.add('modal-header'); content.appendChild(header); const title = document.createElement('div'); title.classList.add('h4', 'm-0'); header.appendChild(title); title.innerHTML = 'Filtrera objekt efter ålder'; const btn_close = document.createElement('button'); btn_close.classList.add('btn-close'); btn_close.type = 'button'; btn_close.title = 'Stäng'; btn_close.setAttribute('aria-label', btn_close.title); btn_close.setAttribute('data-bs-dismiss', 'modal'); header.appendChild(btn_close); const body = document.createElement('div'); body.classList.add('modal-body'); content.appendChild(body); const p = document.createElement('p'); p.classList.add('small', 'text-muted', 'age-filter-infotext'); body.appendChild(p); p.appendChild(document.createTextNode('Här kan du t.ex. filtrera bort gamla objekt. ' + 'I nulaget gäller filtret endast objekt inom ')); ['mineralrättigheter', 'skogsbruk', 'vindbruk'].forEach(function(l, idx, arr) { if (idx > 0) { p.appendChild(document.createTextNode(idx < arr.length-1 ? ', ' : ' eller ')); } const i = document.createElement('i'); i.innerHTML = l; p.appendChild(i); }); p.appendChild(document.createTextNode(' skikter.')); const form = document.createElement('form'); form.action = '#'; form.id = 'age-filter-form'; body.appendChild(form); const btn_group = document.createElement('div'); btn_group.classList.add('btn-group'); btn_group.setAttribute('role', 'group'); btn_group.setAttribute('aria-label', 'Välj filtreringstyp'); form.appendChild(btn_group); const type_choices = Object.fromEntries([ { id: 'relative', text: 'Åldersgräns', desc: 'som är' }, { id: 'interval', text: 'Datumintervall', desc: 'med datum' }, ].map(function(x) { const radio = document.createElement('input'); radio.classList.add('btn-check'); radio.type = 'radio'; radio.name = 'age-filter-type'; radio.id = 'age-filter-type-' + x.id; radio.required = true; radio.setAttribute('aria-expanded', 'false'); radio.setAttribute('autocomplete', 'off'); btn_group.appendChild(radio); const lbl = document.createElement('label'); lbl.classList.add('btn', 'btn-primary'); lbl.setAttribute('for', radio.id); lbl.appendChild(document.createTextNode(x.text)); btn_group.appendChild(lbl); const div = document.createElement('div'); div.classList.add('d-none', 'age-filter-settings'); div.setAttribute('aria-hidden', 'true'); form.appendChild(div); const p = document.createElement('p'); p.classList.add('age-filter-settings-desc'); div.appendChild(p); const t = document.createTextNode('Visa endast objekt ' + x.desc + ':'); p.appendChild(t); return [x.id, { radio: radio, div: div } ]; })); Object.values(type_choices).forEach(function(type_choice) { type_choice.radio.onclick = function(event) { const radio = event.target; radio.checked = true; radio.setAttribute('aria-expanded', 'true'); Object.values(type_choices).forEach(function(x) { const isSame = radio.isEqualNode(x.radio); if (isSame) { x.div.classList.remove('d-none'); x.div.setAttribute('aria-hidden', 'false'); } else { x.div.classList.add('d-none'); x.div.setAttribute('aria-hidden', 'true'); x.radio.checked = false; x.radio.setAttribute('aria-expanded', 'false'); } const inputs = x.div.getElementsByTagName('input'); for (let i = 0; i < inputs.length; i++) { inputs[i].required = isSame; } }); }; }); const create_select = function(parentNode, select_label, options) { const select = document.createElement('select'); select.classList.add('form-select'); select.setAttribute('aria-label', select_label); parentNode.appendChild(select); return [ select, Object.fromEntries(options.map(function(x) { const option = document.createElement('option'); option.appendChild(document.createTextNode(x.text)); option.value = x.id; select.appendChild(option); return [x.id, option]; })) ]; }; const format_date = function(d, s) { /* YYYY-MM-DD or YYYYMMDD */ return d.getFullYear() .toString().padStart(4, '0') + (s ?? '-') + (d.getMonth()+1).toString().padStart(2, '0') + (s ?? '-') + d.getDate() .toString().padStart(2, '0'); }; const parse_date = (m) => new Date( /* YYYY-MM-DD */ parseInt(m.slice(0,4), 10), parseInt(m.slice(5,7), 10)-1, parseInt(m.slice(8,10),10), 12 /* use 12:00:00.0 like for URL parameters */ ); (function(type_choice) { const div = document.createElement('div'); div.classList.add('input-group'); type_choice.div.appendChild(div); type_choice.operator = create_select(div, 'Under eller över åldersgränsen', [ { id: '<=', text: 'Nyare' }, { id: '>=', text: 'Äldre' }, ]); const span = document.createElement('span'); span.classList.add('input-group-text'); div.appendChild(span); span.innerHTML = 'än'; const input_quantity = type_choice.quantity = document.createElement('input'); input_quantity.classList.add('form-control'); input_quantity.type = 'number'; input_quantity.min = 0; input_quantity.max = 65535; input_quantity.placeholder = 'Antal'; input_quantity.setAttribute('aria-label', input_quantity.placeholder); div.appendChild(input_quantity); type_choice.unit = create_select(div, 'Enhet', [ { id: 'd', text: 'dagar' }, { id: 'w', text: 'veckor' }, { id: 'm', text: 'månader' }, { id: 'y', text: 'år' }, ]); const p = document.createElement('p'); p.classList.add('small', 'text-muted', 'invisible'); type_choice.div.appendChild(p); p.appendChild(document.createTextNode('Det vill säga med datum ')); const span_date = document.createElement('span'); p.appendChild(span_date); p.appendChild(document.createTextNode(' eller ')); const span_direction = document.createElement('span'); p.appendChild(span_direction); p.appendChild(document.createTextNode('.')); type_choice._update_helptext = function() { const d = age_filter_settings.get_relative_date( parseInt(type_choice.quantity.value, 10), type_choice.unit[0].value ); const operator = type_choice.operator[0].value; if (d == null || operator === '') { p.classList.add('invisible'); return null; } else { /* update help text */ /* TODO auto update the date passed midnight (if the modal is open) */ span_date.innerHTML = format_date(d); span_direction.innerHTML = { '>=':'tidigare', '<=':'senare' }[operator]; p.classList.remove('invisible'); return d; } }; type_choice.operator[0].onchange = input_quantity.onchange = type_choice.unit[0].onchange = function() { const d = type_choice._update_helptext(); if (d != null) { /* propagate to interval tab */ const operator = type_choice.operator[0].value; const [d1, d2] = { '>=': [new Date(1900, 0, 1, 12), d], /* between 1900-01-01 and d */ '<=': [d, new Date()], /* between d and today */ }[operator]; type_choices.interval.from.value = format_date(d1); type_choices.interval.to.value = format_date(d2); } }; })(type_choices.relative); (function(type_choice) { const div = document.createElement('div'); div.classList.add('input-group'); type_choice.div.appendChild(div); const span1 = document.createElement('span'); span1.classList.add('input-group-text'); span1.innerHTML = 'Mellan'; span1.id = 'age-filter-interval-from'; div.appendChild(span1); const date1 = type_choice.from = document.createElement('input'); date1.classList.add('form-control'); date1.type = 'date'; date1.setAttribute('aria-label', 'Intervallstart'); date1.setAttribute('aria-describedby', span1.id); div.appendChild(date1); const span2 = document.createElement('span'); span2.classList.add('input-group-text'); span2.innerHTML = 'och'; span2.id = 'age-filter-interval-to'; div.appendChild(span2); const date2 = type_choice.to = document.createElement('input'); date2.classList.add('form-control'); date2.type = 'date'; date2.setAttribute('aria-label', 'Intervallstop'); date2.setAttribute('aria-describedby', span2.id); div.appendChild(date2); /* propagate to relative tab by trying to preserve the operator and unit */ const propagate_to_relative = function() { let d; switch (type_choices.relative.operator[0].value) { case '<=': d = parse_date(date1.value); break; case '>=': d = parse_date(date2.value); break; } const delta = (new Date().getTime() - d.getTime()) / 86_400_000; let v; switch (type_choices.relative.unit[0].value) { case 'd': v = delta; break; case 'w': v = delta/7; break; case 'm': v = delta*12/365.25; break; case 'y': v = delta/365.25; break; } if (v != null) { type_choices.relative.quantity.value = Math.round(v); type_choices.relative._update_helptext(); } }; /* make sure that from_date ≤ to_date */ date1.onchange = function() { if (date1.value !== '' && (date2.value === '' || date1.value > date2.value)) { date2.value = date1.value; } propagate_to_relative(); }; date2.onchange = function() { if (date2.value !== '' && (date1.value === '' || date1.value > date2.value)) { date1.value = date2.value; } propagate_to_relative(); }; })(type_choices.interval); const show_unknown_age = (function() { const div = document.createElement('div'); div.classList.add('form-check', 'form-switch'); form.appendChild(div); const checkbox = document.createElement('input'); checkbox.classList.add('form-check-input'); checkbox.type = 'checkbox'; checkbox.id = 'age-filter-show-unknown'; checkbox.setAttribute('role', 'switch'); checkbox.checked = age_filter_settings.show_unknown; div.appendChild(checkbox); const lbl = document.createElement('label'); lbl.classList.add('form-check-label'); lbl.setAttribute('for', checkbox.id); lbl.innerHTML = 'Visa även objekt med okänd datum.'; div.appendChild(lbl); return checkbox; })(); const footer = document.createElement('div'); footer.classList.add('modal-footer'); content.appendChild(footer); const btn_cancel = document.createElement('button'); btn_cancel.classList.add('btn', 'btn-secondary'); btn_cancel.type = 'button'; btn_cancel.innerHTML = 'Återställ'; footer.appendChild(btn_cancel); const btn_apply = document.createElement('input'); btn_apply.classList.add('btn', 'btn-primary'); btn_apply.type = 'submit'; btn_apply.value = 'Filter'; btn_apply.setAttribute('form', form.id); footer.appendChild(btn_apply); btn_cancel.onclick = function() { /* deactivate deactivate the filter but preserve its settings */ age_filter_settings.active = false; Object.values(mapLayers).forEach(function(lyr) { if (lyr?.get('canFilterByAge')) { lyr.changed(); } }); const params = new URLSearchParams(window.location.hash.substring(1)); params.delete('age-filter'); params.delete('show-unknown-age'); location.hash = '#' + params.toString(); modal.hide(); }; form.onsubmit = function(event) { event.preventDefault(); const [filter_type, filter_settings] = Object.entries(type_choices).filter( (x) => x[1].radio.checked )[0]; let param; age_filter_settings._min_ts = age_filter_settings._max_ts = null; switch (filter_type) { case 'relative': { const operator = age_filter_settings.operator = filter_settings.operator[0].value; age_filter_settings.quantity = parseInt(filter_settings.quantity.value, 10); age_filter_settings.unit = filter_settings.unit[0].value; param = {'<=':'-', '>=':''}[operator]; param += age_filter_settings.quantity.toString() + age_filter_settings.unit; break; } case 'interval': { const date1 = age_filter_settings.from = parse_date(filter_settings.from.value); const date2 = age_filter_settings.to = parse_date(filter_settings.to.value); param = format_date(date1, '') + '-' + format_date(date2, ''); break; } default: return; } age_filter_settings.type = filter_type; age_filter_settings.show_unknown = show_unknown_age.checked; age_filter_settings.setup_minmax(); age_filter_settings.active = true; /* TODO auto update the filter passed midnight (if active) */ Object.values(mapLayers).forEach(function(lyr) { if (lyr?.get('canFilterByAge')) { lyr.changed(); } }); const params = new URLSearchParams(window.location.hash.substring(1)); params.set('age-filter', param); params.set('show-unknown-age', show_unknown_age.checked ? '1' : '0'); location.hash = '#' + params.toString(); modal.hide(); }; /* Now that all elements have been added to the form, click the radio * button to show the relevant
, mark its fields as required and * fills them. The function is run whenever the modal is shown. */ return function() { const type_choice = type_choices[age_filter_settings.type]; type_choice.radio.click(); switch (age_filter_settings.type) { case 'relative': { Object.entries(type_choice.operator[1]).map(function([id, option]) { option.selected = id === age_filter_settings.operator; }); type_choice.quantity.value = age_filter_settings.quantity.toString(); Object.entries(type_choice.unit[1]).map(function([id, option]) { option.selected = id === age_filter_settings.unit; }); type_choice.quantity.dispatchEvent(new Event('change')); /* propagate to absolute */ break; } case 'interval': { type_choice.from.value = format_date(age_filter_settings.from); type_choice.to.value = format_date(age_filter_settings.to); type_choice.from.dispatchEvent(new Event('change')); /* propagate to relative */ break; } } }; })(); const modal = new Modal(panel, { backdrop: false, }); const backdrop = document.getElementById('age-filter-modal-backdrop'); backdrop.onclick = function() { modal.hide(); }; const btn = document.getElementById('age-filter-button').getElementsByTagName('button')[0]; if (age_filter_settings.active) { btn.classList.replace('btn-light', 'btn-dark'); } 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() { if (!age_filter_settings.active) { btn.classList.replace('btn-dark', 'btn-light'); } btn.setAttribute('aria-expanded', 'false'); backdrop.classList.remove('modal-backdrop', 'show'); }); btn.onclick = function() { dialog_setup(); modal.show(); }; })(); popover(map, mapLayers, featureOverlayLayer);