/*********************************************************************** * Copyright © 2024 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 WMTS from 'ol/source/WMTS.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 CircleStyle from 'ol/style/Circle.js'; import Fill from 'ol/style/Fill.js'; import RegularShape from 'ol/style/RegularShape.js'; import Stroke from 'ol/style/Stroke.js'; import Style from 'ol/style/Style.js'; import proj4 from 'proj4'; import { get as getProjection } from 'ol/proj.js'; import { register as registerProjection } from 'ol/proj/proj4.js'; import { Modal } from 'bootstrap'; import './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 (top-left corner) is * at N8500000 E-1200000 (SWEREF99 TM), where each tile is 256x256 * 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 4x4 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/ */ const baseMapSource = new WMTS({ // XXX the 'layer' parameter should be passed in the options // dictionary (like style and version), but there is no setLayer() // method to switch from/to the toned down map url: 'https://minkarta.lantmateriet.se/map/topowebbcache?layer=topowebb', 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_cache_v1.1.0.pdf tileSize: 256, origin: [-1200000, 8500000], resolutions: [4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1, .5], matrixIds: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], }), 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, 4, 2, 1, .5], constrainResolution: false, }); (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); } })(); const map = 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'); (function() { const container0 = map.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0]; container0.appendChild(container); container0.appendChild(document.getElementById('modal-info')); const backdrop = document.createElement('div'); container0.appendChild(backdrop); backdrop.id = 'modal-info-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.setAttribute('aria-expanded', 'false'); btn.title = 'Öppna karta i ny flik'; 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(event) { 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 */ (function() { 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.setAttribute('aria-expanded', 'false'); btn.title = 'Lagerval'; 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.setAttribute('aria-expanded', 'false'); btn.title = 'Byt kartlager'; btn.title = 'Teckenförklaring'; 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'); btn.setAttribute('aria-expanded', 'false'); btn.classList.add('btn-light'); btn.classList.remove('btn-dark'); } else { if (btn2.getAttribute('aria-expanded') === 'true') { btn2.click(); } panel.setAttribute('aria-hidden', 'false'); btn.setAttribute('aria-expanded', 'true'); btn.classList.add('btn-dark'); btn.classList.remove('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.add('btn-light'); btn2.classList.remove('btn-dark'); } else { if (btn.getAttribute('aria-expanded') === 'true') { btn.click(); } panel2.setAttribute('aria-hidden', 'false'); btn2.setAttribute('aria-expanded', 'true'); btn2.classList.add('btn-dark'); btn2.classList.remove('btn-light'); } }; })(); /* fullscreen control */ (function() { 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, }) control.element.getElementsByTagName('button')[0].classList.add('btn', classInactive); map.addControl(control); control.addEventListener('enterfullscreen', function() { const btn = control.element.getElementsByTagName('button')[0]; btn.classList.add(classActive); btn.classList.remove(classInactive); btn.title = titleActive; }) control.addEventListener('leavefullscreen', function() { const btn = control.element.getElementsByTagName('button')[0]; btn.classList.add(classInactive); btn.classList.remove(classActive); btn.title = titleInactive; }) })(); /* export/download button */ (function() { 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'; const i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-download'); menu.appendChild(div); btn.onclick = function(event) { 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.classList.add('btn', 'btn-light'); const i = document.createElement('i'); btn.appendChild(i); i.classList.add('bi', 'bi-info-lg'); const panel = document.getElementById('modal-info'); const modal = new Modal(panel, { backdrop: false, }); const backdrop = document.getElementById('modal-info-backdrop'); backdrop.onclick = function(event) { modal.hide(); }; panel.addEventListener('show.bs.modal', function() { backdrop.classList.add('modal-backdrop', 'show'); btn.setAttribute('aria-expanded', 'true'); btn.classList.add('btn-dark'); btn.classList.remove('btn-light'); }); panel.addEventListener('hidden.bs.modal', function() { btn.classList.add('btn-light'); btn.classList.remove('btn-dark'); btn.setAttribute('aria-expanded', 'false'); backdrop.classList.remove('modal-backdrop', 'show'); }); btn.onclick = function(event) { modal.toggle(); }; })(); /* we're all set, show the control container now */ container.setAttribute('aria-hidden', 'false'); map.on('singleclick', function(event) { const size = map.getSize(); if (size[0] < 576 || size[1] < 576) { return; } }); view.on('change', function(event) { 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 layers = { svk_lines: { style: [1, 1.5, 2, 2, 2, 2, 3, 4, 5, 6, 8, 10].map(function(width) { return new Style({ zIndex: 2, stroke: new Stroke({ color: 'black', width: width, }), }); }), }, svk_pylons: { style: [undefined, undefined, undefined, undefined, undefined] .concat([3, 4, 5, 6, 8, 10, 15].map(function(radius) { return new Style({ zIndex: 1, image: new CircleStyle({ radius: radius, fill: new Fill({ color: 'black', }), }), }); })), }, svk_stations: { style: [5, 6, 8, 8, 10, 12, 12].map(function(radius) { return new Style({ zIndex: 0, image: new RegularShape({ radius: radius, points: 4, angle: Math.PI/4, fill: new Fill({ color: 'black', }), }), }); }) .concat([.5, 1, 1.5, 2, 2].map(function(width) { return new Style({ zIndex: 0, fill: new Fill({ color: 'rgba(128, 128, 128, .7)', }), stroke: new Stroke({ width: width, color: 'rgb(0, 0, 0)', }), }); })), }, }; const vectorSource = new VectorTile({ url: '/public/xyztiles/{z}/{x}/{y}.pbf', format: new MVT({ layers: Object.keys(layers), }), projection: projection, wrapX: false, transition: 0, tileGrid: createXYZ({ extent: extent, tileSize: 1024, maxResolution: 1024, /* = 1048576/1024 */ minZoom: 0, maxZoom: 9, }), }); const styles = Object.keys(layers).reduce(function(result, key) { result[key] = layers[key].style; return result; }, {}); map.addLayer(new VectorTileLayer({ source: vectorSource, /* XXX switch to 'hybrid' if there are perf issues; but that seems to * put lines above points regardless of their respective z-index */ renderMode: 'vector', declutter: false, style: function(feature, resolution) { const style = styles[feature.getProperties().layer]; if (!Array.isArray(style)) { return style; } else { const maxi = style.length - 1; const z = resolutions.length - 2 - 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]; } }, })); /* layer selection panel */ (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'); const setIndeterminateAndChecked = function(input, children) { const childrenStyles = Object.values(children.reduce(function(result, child) { if (Array.isArray(child.layerName)) { child.layerName.forEach(function(layerName) { result[layerName] = (styles[layerName] !== undefined); }); } else { result[child.layerName] = (styles[child.layerName] !== undefined); } return result; }, {})); input.indeterminate = children.length <= 1 ? false : childrenStyles.slice(1).some((v) => v !== childrenStyles[0]); input.checked = childrenStyles.every((v) => v); }; let accordionCollapseId = 0, inputId = 0; const addAccordionItem = function(headerText, children) { 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-' + accordionCollapseId++; collapse.classList.add('accordion-collapse', 'collapse'); // 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 = document.createElement('input'); span0.appendChild(input0); input0.classList.add('form-check-input'); input0.type = 'checkbox'; input0.id = 'layer' + inputId++; const label0 = document.createElement('label'); span0.appendChild(label0); label0.classList.add('form-check-label'); label0.setAttribute('for', input0.id); const text0 = document.createTextNode(headerText); label0.appendChild(text0); setIndeterminateAndChecked(input0, children); const inputs = Object.values(children).map(function(child) { const input = document.createElement('input'); setIndeterminateAndChecked(input, [child]); return input; }); const body = document.createElement('div'); collapse.appendChild(body); body.classList.add('accordion-body'); const group = document.createElement('ul'); body.appendChild(group); group.classList.add('list-group', 'list-group-flush'); children.forEach(function(child, i) { const li = document.createElement('li'); group.appendChild(li); li.classList.add('list-group-item'); const input = inputs[i]; li.appendChild(input); input.classList.add('form-check-input'); input.type = 'checkbox'; input.id = 'layer' + inputId++; const label = document.createElement('label'); li.appendChild(label); label.classList.add('form-check-label'); label.setAttribute('for', input.id); const text = document.createTextNode(child.text); label.appendChild(text); }); input0.onclick = function(event) { children.forEach(function(child, i) { const layerNames = Array.isArray(child.layerName) ? child.layerName : [child.layerName]; layerNames.forEach(function(layerName) { if (input0.checked) { styles[layerName] = layers[layerName].style; inputs[i].checked = true; } else { delete styles[layerName]; inputs[i].checked = false; } }); }); vectorSource.changed(); }; inputs.forEach(function(input, i) { input.onclick = function(event) { const layerNames = Array.isArray(children[i].layerName) ? children[i].layerName : [children[i].layerName]; layerNames.forEach(function(layerName) { if (input.checked) { styles[layerName] = layers[layerName].style; } else { delete styles[layerName]; } }); setIndeterminateAndChecked(input0, children); vectorSource.changed(); }; }); }; addAccordionItem('Transmissionsnät för el', [ {layerName: ['svk_lines', 'svk_pylons'], text: 'Kraftledningar'}, {layerName: 'svk_stations', text: 'Stationer'}, ]); (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' + inputId++; 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.onchange = function(event) { const layer = event.target.checked ? 'topowebb_nedtonad' : 'topowebb'; baseMapSource.setUrl('https://minkarta.lantmateriet.se/map/topowebbcache?LAYER=' + layer); }; })(); })(); /* 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'); body.innerHTML = 'legend TODO'; })();