diff options
Diffstat (limited to 'main.js')
| -rw-r--r-- | main.js | 1127 |
1 files changed, 850 insertions, 277 deletions
@@ -41,6 +41,11 @@ 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 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'; import Icon from 'ol/style/Icon.js'; @@ -48,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'; @@ -62,6 +69,7 @@ proj4.defs('EPSG:3006', '+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 registerProjection(proj4); 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 @@ -134,9 +142,6 @@ const [BASEMAP, MAP] = (function() { 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, @@ -163,11 +168,16 @@ const CONTAINER_STOPEVENT = MAP.getViewport().getElementsByClassName('ol-overlay 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'); @@ -265,6 +275,7 @@ 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: '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'); @@ -334,6 +345,7 @@ if (window.location === window.parent.location) { btn.classList.add('btn', classInactive); btn.setAttribute('aria-label', btn.title); MAP.addControl(control); + control.element.id = 'fullscreen-toggle'; /* for the help dialog */ control.addEventListener('enterfullscreen', function() { /* dispose popover as entering fullscreen messes up its position */ @@ -417,84 +429,128 @@ if (window.location === window.parent.location) { }; } -/* 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('info-modal'); - 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('info-modal-backdrop'); - backdrop.onclick = function() { - 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('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'); + 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(); } - 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'); - } + }); + + 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; } + } + + return [panel, btn, modal]; + }; + + /* info button */ + (function() { + const [panel, btn, modal] = add_button({ + id: 'info', + title: 'Källor och licensinformation', + bi: 'info-lg', }); - }); - 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}`); }); + 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 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(url.substr(0, url.length - 16) + '/metadata.json') + return fetch(new URL('metadata.json', baseurl)) .then(function(resp0) { if (resp0.status === 200) { return resp0.json().then((x) => [grp,x]); @@ -502,153 +558,213 @@ if (window.location === window.parent.location) { 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); - } + })) + .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); } - }); - 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 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; } - const x = layer_group.source_files[source_file]; - source_files.add(source_file); - + 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'); + li.classList.add('list-group-item', 'text-muted'); ul.appendChild(li); - const h = document.createElement('h6'); - li.appendChild(h); - if (x.description != null) { - const t = document.createTextNode(x.description); - h.appendChild(t); - } + 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); + } - if (x.copyright != null) { - const p = document.createElement('p'); - li.appendChild(p); - const t = document.createTextNode(x.copyright); - p.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.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) { + if (x.copyright != null) { + const p = document.createElement('p'); + li.appendChild(p); + const t = document.createTextNode(x.copyright); p.appendChild(t); - } else { + } + + 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.license.url; + a.href = x.product_url; a.target = '_blank'; a.appendChild(t); + a.appendChild(i); 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); + 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-box-arrow-up-right'); - a.appendChild(t0); - a.appendChild(document.createTextNode(' ')); - a.appendChild(i); - a.href = x.url; - a.target = '_blank'; + 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 td = document.createTextNode(dateFormatter.format(new Date(x.last_modified))); + p.appendChild(td); + const t2 = document.createTextNode('.'); + p.appendChild(t2); } - 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); - } + }); }); }); }); - }); - }; + }; + })(); + + /* help button */ + (function() { + const [panel] = add_button({ + id: 'help', + title: 'Hjälp med att använda kartan', + bi: 'question-circle', + }); + + /* Use the text from the .html file but ensure that buttons are + * listed in the same order as the menu, and spell titles out. This + * avoids duplication and avoids that things would get out of sync */ + const button_map = {}; + const ol = panel.querySelector('#help-describe-functions'); + if (ol != null && ol.tagName.toLowerCase() === 'ol') { + for (const li of ol.children) { + const id = li.getAttribute('data-for-button'); + if (id == null || id === '') { + continue; + } + button_map[id] = li; + } + } + + for (const node of MENU.children) { + if (node.id == null || node.id === '') { + continue + } + const btn = node.getElementsByTagName('button')[0]; + if (btn == null || btn.tagName.toLowerCase() !== 'button') { + continue; + } + const btn2 = btn.cloneNode(true); + const title = btn2.title; + btn2.id = btn2.title = ''; + for (const attr of btn.attributes) { + if (attr.name.toLowerCase().startsWith('aria-') || attr.name === 'id' || attr.name === 'title') { + btn2.removeAttribute(attr.name); + } + } + + const h = document.createElement('h6'); + h.classList.add('help-button-description'); + h.appendChild(btn2); + if (title != null && title != '') { + const t = document.createTextNode(title) + h.appendChild(t); + } + btn2.classList.add('help-button'); + + ol.insertAdjacentElement('beforebegin', h); + + const li = button_map[node.id]; + if (li != null) { + /* move <li>'s children (paragraphs) to the main text */ + while (li.children.length > 0) { + ol.insertAdjacentElement('beforebegin', li.firstElementChild); + } + } + } + ol.remove(); + })(); })(); /* we're all set, show the control container now */ @@ -728,6 +844,35 @@ const ageFilterSettings = (function() { * WebGL, which is currently blocking on https://github.com/openlayers/openlayers/issues/15807 * and https://github.com/openlayers/openlayers/issues/16246 */ const LAYERS = Object.seal({ + adm: { + lansyta: { + legend: { zoomLevel: 3, type: 'linestring' }, + style: [1.5, 2, 3, 3, 4, 4, 6, 6, 8, 8, 10, 10].map(function(width) { + return new Style({ + zIndex: 0, + fill: null, + stroke: new Stroke({ + width: width, + color: [212, 147, 208, 1], + }), + }); + }), + }, + kommunyta: { + legend: { zoomLevel: 3, type: 'linestring' }, + style: [2, 2, 3, 3, 4, 4, 6, 6, 8, 8, 10, 10].map(function(width) { + return new Style({ + zIndex: 0, + fill: null, + stroke: new Stroke({ + width: width/2, + color: [212, 147, 208, 1], + }), + }); + }), + }, + }, + mrr: { appr_ec: { legend: { zoomLevel: 4 }, @@ -1112,7 +1257,7 @@ const LAYERS = Object.seal({ }); }), }, - station_completed: { + turbine_completed: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1125,7 +1270,7 @@ const LAYERS = Object.seal({ }); }), }, - station_processed: { + turbine_processed: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1138,7 +1283,7 @@ const LAYERS = Object.seal({ }); }), }, - station_approved: { + turbine_approved: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1151,7 +1296,7 @@ const LAYERS = Object.seal({ }); }), }, - station_revoked: { + turbine_revoked: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1164,7 +1309,7 @@ const LAYERS = Object.seal({ }); }), }, - station_rejected: { + turbine_rejected: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1177,7 +1322,7 @@ const LAYERS = Object.seal({ }); }), }, - station_dismounted: { + turbine_dismounted: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -1190,7 +1335,7 @@ const LAYERS = Object.seal({ }); }), }, - station_appealed: { + turbine_appealed: { legend: { zoomLevel: 7, type: 'point' }, style: [undefined, undefined, undefined, undefined, .125, .125, .25, .5, 1, 2, 4, 8].map(function(scale) { return scale === undefined ? undefined : new Style({ @@ -2635,15 +2780,57 @@ const LAYERS = Object.seal({ const STYLES = Object.seal(Object.fromEntries(Object.entries(LAYERS).map(([k,ls]) => [k, Object.seal(Object.fromEntries(Object.keys(ls).map((l) => [l, null])))]))); (function() { + const view = MAP.getView(); const params = new URLSearchParams(window.location.hash.substring(1)); - const x = parseFloat(params.get('x')); - const y = parseFloat(params.get('y')); + const x = parseFloat(params.get('x')), + y = parseFloat(params.get('y')), + z = parseFloat(params.get('z')); if (!isNaN(x) && !isNaN(y)) { - MAP.getView().setCenter([x, y]); - } - const z = parseFloat(params.get('z')); - if (!isNaN(z)) { - MAP.getView().setZoom(z); + view.setCenter([x, y]); + view.setZoom(isNaN(z) ? 1 : z); + } else { + /* center of the bbox of the Norrbotten and Västerbotten geometries */ + view.setCenter([694767.48, 7338176.57]); + view.setZoom(1); + const geolocation = new Geolocation({ + projection: view.getProjection(), + tracking: true, + }); + const evt_key = geolocation.on('change:position', function() { + const pos = geolocation.getPosition(); + if (pos == null) { + return; + } + /* ignore further geolocation position changes */ + unByKey(evt_key); + geolocation.setTracking(false); + + const params2 = new URLSearchParams(window.location.hash.substring(1)); + /* ignore geolocation result if coordinates have changed meanwhile */ + if (params2.has('x') || params2.has('y')) { + return; + } + /* ignore geolocation result if not within extent */ + if (EXTENT[0] > pos[0] || pos[0] > EXTENT[2] || EXTENT[1] > pos[1] || pos[1] > EXTENT[3]) { + return; + } + view.setCenter(pos); + params2.set('x', pos[0].toFixed(2).replace(TRAILING_ZEROES, '')); + params2.set('y', pos[1].toFixed(2).replace(TRAILING_ZEROES, '')); + if (!params2.has('z')) { + const accuracy = geolocation.getAccuracy(); + if (accuracy == null || accuracy < 0) { + view.setZoom(Math.max(view.getMinZoom(), 0)); + } else { + /* infer resolution from accuracy, up to zoom level 7 (8px/m) */ + const [width, height] = MAP.getSize(); + const res = 8. * accuracy / Math.min(width, height); + view.setResolution(Math.max(res, view.getResolutionForZoom(7))); + } + params2.set('z', view.getZoom().toFixed(3).replace(TRAILING_ZEROES, '')); + } + location.hash = '#' + params2.toString(); + }); } if (!params.has('layers') || (!params.get('layers').match(/^\s*$/) && /* compat redirect/layer subst for old non-hierachical names */ @@ -2741,8 +2928,6 @@ MAP.getView().on('change', function(event) { /* add layers to the map */ const mapLayers = (function() { - const baseurl = '/'; - const xyz = '/{z}/{x}/{y}.pbf'; const tileGrid = createXYZ({ extent: EXTENT, tileSize: 1024, @@ -2759,7 +2944,7 @@ const mapLayers = (function() { /* 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 vectorLayers = ['adm', 'nv', 'mrr', 'skydd', 'ren', 'ri', 'avverk', 'vbk', 'svk', 'misc']; const canFilterByAge = ['avverk', 'mrr', 'vbk']; /* layers for which features are dated */ const ret = {}; @@ -2767,19 +2952,21 @@ const mapLayers = (function() { rasterLayers.forEach((k) => ret[k] = null); } else { rasterLayers.forEach(function(k) { + const baseurl = new URL('/raster/' + k + '/', window.location.toString()).toString(); + const source = new GeoTIFF({ + sources: [{ url: baseurl + encodeURIComponent(k) + '.tiff' }], + normalize: false, + convertToRGB: false, + wrapX: false, + interpolate: false, + /* use the projection found in the source's metadata */ + }); + /* GeoTIFF doesn't allow retrieving the URL later, so we manually store the baseurl instead */ + source.set('baseurl', baseurl, true); 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 */ - }), + source: source, visible: false, style: null, /* filled later */ }); @@ -2790,15 +2977,18 @@ const mapLayers = (function() { vectorLayers.forEach(function(k) { const canFilterByAge0 = canFilterByAge.includes(k); const styles = STYLES[k]; + const baseurl = new URL('/tiles/' + k + '/', window.location.toString()).toString(); + const source = new VectorTile({ + url: baseurl + '{z}/{x}/{y}.pbf', + format: new MVT(), + projection: PROJECTION, + wrapX: false, + transition: 0, + tileGrid: tileGrid, + }); + source.set('baseurl', baseurl, true); ret[k] = new VectorTileLayer({ - source: new VectorTile({ - url: baseurl + 'tiles/' + k + xyz, - format: new MVT(), - projection: PROJECTION, - wrapX: false, - transition: 0, - tileGrid: tileGrid, - }), + source: source, /* 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', @@ -2936,31 +3126,31 @@ const layerHierarchy = [ children: [ { text: 'Uppförda', - layer: 'vbk.station_completed', + layer: 'vbk.turbine_completed', }, { text: 'Beviljade', - layer: 'vbk.station_approved', + layer: 'vbk.turbine_approved', }, { text: 'Avslagna/nekad', - layer: 'vbk.station_rejected', + layer: 'vbk.turbine_rejected', }, { text: 'Handläggs', - layer: 'vbk.station_processed', + layer: 'vbk.turbine_processed', }, { text: 'Nedmonterade', - layer: 'vbk.station_dismounted', + layer: 'vbk.turbine_dismounted', }, { text: 'Överklagade', - layer: 'vbk.station_appealed', + layer: 'vbk.turbine_appealed', }, { text: 'Inte längre aktuella/återkallade', - layer: 'vbk.station_revoked', + layer: 'vbk.turbine_revoked', }, ], }, @@ -3239,6 +3429,21 @@ const layerHierarchy = [ }, ] }, + { + text: 'Administrativa gränser', + type: 'switch', + collapse_children: true, + children: [ + { + text: 'Länsgränser', + layer: 'adm.lansyta', + }, + { + text: 'Kommungränser', + layer: 'adm.kommunyta', + }, + ], + }, ]; /* legend panel */ @@ -3259,7 +3464,7 @@ const layerHierarchy = [ const createLegend = function(ul, elem, classes) { const li = document.createElement('li'); li.classList.add('list-group-item'); - ul.append(li); + ul.appendChild(li); const t = document.createTextNode(elem.text); if (elem.layer === undefined) { @@ -3298,8 +3503,11 @@ const layerHierarchy = [ console.log(`Could not find symbol for layer ${layer}, skipping`); return; } - const legend = LAYERS[layerGroup][layerName]?.legend ?? {}; - if (canvas == null || !legend.reuse_canvas) { + const legend = LAYERS[layerGroup][layerName]?.legend; + if (legend === null) { + return; /* layer has opted out from legend */ + } + if (canvas == null || !legend?.reuse_canvas) { canvas = document.createElement('canvas'); div.appendChild(canvas); render = toContext(canvas.getContext('2d'), @@ -3317,9 +3525,9 @@ const layerHierarchy = [ else if (mapLayers[layerGroup].getSource() instanceof VectorTile) { /* vector source */ const style = Array.isArray(LAYERS[layerGroup][layerName].style) ? - LAYERS[layerGroup][layerName].style[legend.zoomLevel ?? 5] : + LAYERS[layerGroup][layerName].style[legend?.zoomLevel ?? 5] : LAYERS[layerGroup][layerName].style; - const legend_type = legend.type ?? 'polygon'; + const legend_type = legend?.type ?? 'polygon'; if (legend_type === 'point' && style.getImage(1) instanceof Icon && style.getImage(1).getSrc()) { /* use a new <img> element since .setStyle() returns the same one and doesn't work in that case */ const div2 = document.createElement('div'); @@ -3349,7 +3557,7 @@ const layerHierarchy = [ } elem._legend = li; - if (elem.children !== undefined && elem.children.length > 0) { + if (elem.children != null && elem.children.length > 0) { if (classes.length > 0) { li.classList.add(classes[0]); classes = classes.slice(1); @@ -3395,7 +3603,7 @@ const infoMetadataAccordions = []; elem._layers = elem.layer === undefined ? [] : Array.isArray(elem.layer) ? elem.layer : [elem.layer]; - if (elem.children !== undefined && elem.children.length > 0) { + if (elem.children != null && elem.children.length > 0) { collectLayers(elem.children); elem.children.forEach(function(child) { child._layers.forEach((l) => elem._layers.push(l)); @@ -3425,7 +3633,7 @@ const infoMetadataAccordions = []; elem._legend.classList.add('d-none'); } } - if (elem.children !== undefined && elem.children.length > 0) { + if (elem.children != null && elem.children.length > 0) { setIndeterminateAndChecked(elem.children); } }); @@ -3500,7 +3708,7 @@ const infoMetadataAccordions = []; let layerId = 0; const addAccordionGroup = function(parentNode, children) { const ul = document.createElement('ul'); - parentNode.appendChild(ul); + parentNode?.appendChild?.(ul); ul.classList.add('list-group', 'list-group-flush'); children.forEach(function(child) { @@ -3526,7 +3734,7 @@ const infoMetadataAccordions = []; const textNode = document.createTextNode(child.text); label.appendChild(textNode); - if (child.children !== undefined && child.children.length > 0) { + if (child.children != null && child.children.length > 0) { addAccordionGroup(li, child.children); } @@ -3581,8 +3789,18 @@ const infoMetadataAccordions = []; const text0 = document.createTextNode(x.text); label0.appendChild(text0); - if (x.children === undefined || x.children.length === 0) { - item.replaceChild(span0, header); + if (x.children == null || x.children.length === 0 || x.collapse_children) { + span0.removeAttribute('data-bs-toggle'); + span0.removeAttribute('data-bs-target'); + item.replaceChildren(span0); + if (x.type === 'switch') { + span0.classList.add('form-switch'); + input0.setAttribute('role', 'switch'); + } + if (x.children != null && x.children.length > 0) { + /* create inputs for the hash param logic but don't add them to the panel */ + addAccordionGroup(null, x.children); + } } else { const body = document.createElement('div'); collapse.appendChild(body); @@ -3674,6 +3892,344 @@ const infoMetadataAccordions = []; })(); })(); +/* measurement panel */ +(function() { + const value = document.createTextNode(''), + unit = document.createTextNode(''); + const reset = function() { + source.clear(true); + const f = { LineString: formatLength, Polygon: formatArea }[getMeasureMode()]; + if (f == null) { + value.nodeValue = unit.nodeValue = ''; + } else { + f(0); + } + }; + + const source = new VectorSource({ + wrapX: false, + }); + + let draw; + const buttons = Object.fromEntries([ + { + id: 'cancel', + bi: 'trash', + title: 'Avbryt mätningen', + onclick: function() { + reset(); + Object.values(buttons).forEach((btn) => btn.disabled = true); + draw.abortDrawing(); + }, + }, + { + id: 'undo', + bi: 'arrow-counterclockwise', + title: 'Ta bort sista punkten', + onclick: function() { + draw.removeLastPoint(); + const n = { LineString: 2, Polygon: 3 }[getMeasureMode()] ?? Infinity; + draw.getOverlay().getSource().forEachFeature(function(feature) { + const geom = feature.getGeometry(); + if (geom.getType() === 'LineString') { + /* disable OK button if not enough points have been drawn (excluding cursor) */ + buttons.ok.disabled = geom.getCoordinates().length - 1 < n; + return true; /* stop iterating */ + } + }); + } + }, + { + id: 'ok', + bi: 'check-lg', + title: 'Slutför mätningen', + onclick: () => draw.finishDrawing(), + }, + ].map(function(x, idx, arr) { + const btn = document.createElement('button'); + btn.classList.add('btn', 'btn-outline-' + (idx < arr.length-1 ? 'secondary' : 'primary')); + btn.setAttribute('type', 'button'); + btn.title = x.title; + btn.setAttribute('aria-label', btn.title); + + const i = document.createElement('i'); + i.classList.add('bi', 'bi-' + x.bi); + btn.appendChild(i); + + btn.onclick = x.onclick; + return [x.id, btn]; + })); + + const formatLength = (function() { + const formatters = [ + { maximumFractionDigits: 0 }, + { maximumFractionDigits: 1 }, + { maximumFractionDigits: 2 }, + ] + .map((fmt) => new Intl.NumberFormat(LOCALE, { + ...fmt, + minimumFractionDigits: fmt.maximumFractionDigits ?? 0, + })); + return function(v) { + if (v <= 100) { /* ≤ 100 m */ + unit.nodeValue = 'm'; + value.nodeValue = formatters[1].format(v); + } else if (v <= 5_000) { /* ≤ 5 km */ + unit.nodeValue = 'm'; + value.nodeValue = formatters[0].format(v); + } else { + unit.nodeValue = 'km'; + value.nodeValue = formatters[2].format(v/1000); + } + }; + })(); + const formatArea = (function() { + const formatters = [ + { maximumFractionDigits: 1 }, + { maximumFractionDigits: 2 }, + ] + /* XXX would be nice to use Intl.NumberFormat()'s unit support, but m² and km² are not + * supported currently, see https://github.com/tc39/ecma402/issues/767 */ + .map((fmt) => new Intl.NumberFormat(LOCALE, { + ...fmt, + minimumFractionDigits: fmt.maximumFractionDigits ?? 0, + })); + return function(v) { + if (v < 10_000) { /* < 1 ha */ + unit.nodeValue = 'm²'; + value.nodeValue = formatters[0].format(v); + } else if (v < 100_000_000) { /* < 10000 ha (100 km²) */ + unit.nodeValue = 'ha'; + value.nodeValue = formatters[1].format(v/10_000); + } else { + unit.nodeValue = 'km²'; + v /= 1_000_000; + const i = v < 1_000_000 ? 1 : 0; /* ≥10⁶ km² overflows the box with 2 decimals */ + value.nodeValue = formatters[i].format(v); + } + }; + })(); + + const setup = (function() { + const styles = { + Point: new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ + color: [0, 183, 255, 1], + }), + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: .5, + }), + }), + }), + LineString: [ + new Style({ + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: 4, + }), + }), + new Style({ + stroke: new Stroke({ + color: [0, 183, 255, 1], + width: 3, + lineDash: [10, 10], + }), + }), + ], + Polygon: new Style({ + fill: new Fill({ + color: [255, 255, 255, .5], + }), + }), + }; + const layer = new VectorLayer({ + source: source, + visible: false, + style: [ + new Style({ + fill: styles.Polygon.getFill(), + stroke: styles.LineString[0].getStroke(), + }), + (function() { + const s = styles.LineString[1].clone(); + s?.getStroke?.()?.setLineDash?.(null); + return s; + })(), + ], + map: MAP, + }); + + return function(geom_type) { + if (draw != null) { + draw.abortDrawing(); + MAP.removeInteraction(draw); + } + reset(); /* remove features when toggling between geom types */ + Object.values(buttons).forEach((btn) => btn.disabled = true); + if (geom_type == null) { + layer.setVisible(false); + return; + } + draw = new Draw({ + source: source, + type: geom_type, + style: function(feature) { + return styles[ feature.getGeometry().getType() ]; + }, + }); + MAP.addInteraction(draw); + layer.setVisible(true); + + let listener; + draw.on('drawstart', function(event) { + reset(); + buttons.undo.disabled = buttons.cancel.disabled = false; + const geom = event.feature.getGeometry(); + const [isComplete, measure] = { + LineString: [ + /* 2 points drawn + cursor */ + (g) => g.getCoordinates().length >= 3, + (g) => formatLength(g.getLength()), + ], + Polygon: [ + /* 3 points drawn + cursor + 1st point */ + (g) => g.getCoordinates()[0].length >= 5, + (g) => formatArea(g.getArea()), + ], + }[geom.getType()]; + const btnOK = buttons.ok; + listener = geom.on('change', function(event) { + if (btnOK.disabled && isComplete(geom)) { + btnOK.disabled = false; + } + measure(event.target); + }); + }); + draw.on('drawend', function() { + unByKey(listener); + buttons.ok.disabled = buttons.undo.disabled = true; + }); + draw.on('drawabort', function() { + unByKey(listener); + reset(); + Object.values(buttons).forEach((btn) => btn.disabled = true); + }); + }; + })(); + + const [body, btn_close] = (function() { + const modal = document.getElementById('measure-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 header = document.createElement('div'); + content.appendChild(header); + header.classList.add('modal-header'); + + const title = document.createElement('div'); + title.classList.add('h5'); + title.innerHTML = 'Mät i kartan'; + header.appendChild(title); + + 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); + header.appendChild(btn_close); + + const body = document.createElement('div'); + content.appendChild(body); + body.classList.add('modal-body'); + return [body, btn_close]; + })(); + + const getMeasureMode = (function() { + const btn_group = document.createElement('div'); + btn_group.classList.add('btn-group'); + btn_group.setAttribute('role', 'group'); + btn_group.setAttribute('aria-label', 'Välj geometrityp'); + body.appendChild(btn_group); + + const radios = [ + { id: 'LineString', text: 'Distans' }, + { id: 'Polygon', text: 'Yta' }, + ].map(function(x, idx) { + const radio = document.createElement('input'); + radio.classList.add('btn-check'); + radio.type = 'radio'; + radio.checked = idx == 0; + radio.name = 'measure-geomtype'; + radio.id = 'measure-geomtype-' + x.id; + radio.setAttribute('aria-expanded', 'false'); + radio.setAttribute('autocomplete', 'off'); + btn_group.appendChild(radio); + + const lbl = document.createElement('label'); + lbl.classList.add('btn', 'btn-lg', 'btn-outline-dark'); + lbl.setAttribute('for', radio.id); + lbl.appendChild(document.createTextNode(x.text)); + btn_group.appendChild(lbl); + + radio.onclick = function() { + setup(x.id); + }; + return [x.id, radio]; + }); + + return () => radios.filter( (x) => x[1].checked )?.[0]?.[0]; + })(); + + (function() { + const div = document.createElement('div'); + div.classList.add('measure-value'); + body.appendChild(div); + const span0 = document.createElement('span'); + span0.appendChild(value); + div.appendChild(span0); + const span1 = document.createElement('span'); + span1.classList.add('measure-unit'); + span1.appendChild(unit); + div.appendChild(span1); + })(); + + (function() { + const btn_group = document.createElement('div'); + btn_group.classList.add('btn-group'); + btn_group.setAttribute('role', 'group'); + body.appendChild(btn_group); + + btn_group.appendChild(buttons.cancel); + btn_group.appendChild(buttons.undo); + btn_group.appendChild(buttons.ok); + })(); + + const button_menu = document.getElementById('measure-button') + .getElementsByTagName('button')[0]; + button_menu.addEventListener('click', function(event) { + if (event.currentTarget.getAttribute('aria-expanded') === 'true') { + disposePopover(); + setup(getMeasureMode()); + } else { + setup(null); + /* XXX workaround for https://github.com/twbs/bootstrap/issues/41005#issuecomment-2585390544 */ + const activeElement = document.activeElement; + if (activeElement.isEqualNode(btn_close)) { + activeElement.blur(); + } + } + }); + btn_close.onclick = () => button_menu.click(); +})(); + /* popup and feature overlays */ const disposePopover = (function() { /* return an <a> tag with the given URL and optional text */ @@ -3980,7 +4536,7 @@ const disposePopover = (function() { ['appealed', /* 5 */ 'överklagat'], ['revoked', /* 6 */ 'inte aktuell eller återkallad'], ] - .forEach(([k, title], idx) => layers.vbk['station_' + k] = { + .forEach(([k, title], idx) => layers.vbk['turbine_' + k] = { title: 'Landbaserad vindkraftverk \u2013 ' + title, fields: mapFields(idx, fieldMap, [ 'VerkID', @@ -4227,6 +4783,16 @@ const disposePopover = (function() { fields: mapFields(fieldMap, [ 'NAMN', 'geom_area' ]), }; + layers.skydd.biosfarsomraden = { + title: 'Biosfärsområden (UNESCO)', + fields: [ + { key: 'SKYDDSTYP', desc: 'Skyddstyp' }, + { key: 'NAMN', desc: 'Namn' }, + { key: 'LINK', desc: 'Länk', fn: formatLink }, + fieldMap.geom_area, + ], + }; + layers.skydd.naturvardsavtal = { title: 'Naturvårdsavtal (Naturvårdsverket, Länsstyrelsen)', fields: [ @@ -4545,31 +5111,34 @@ const disposePopover = (function() { }; /* format value to HTML */ + const numberFormatters = [ + { }, + { maximumFractionDigits: 1 }, + { maximumFractionDigits: 2 }, + ] + /* XXX would be nice to use Intl.NumberFormat()'s unit support, but m² and km² are not + * supported currently, see https://github.com/tc39/ecma402/issues/767 */ + .map((fmt) => new Intl.NumberFormat(LOCALE, fmt)); + const unitSeparator = '\u00A0'; /* U+00A0 NO-BREAK SPACE */ const formatValue = function(value, options) { - let unit = options?.unit; if (options?.fn == null) { /* no-op */ } else if (typeof options.fn === 'function') { value = options.fn(value); - } else if (options.fn === 'length' && typeof value === 'number' && unit == null) { - if (value < 1000) { - unit = 'm'; + } else if (options.fn === 'length' && typeof value === 'number' && options?.unit == null) { + if (value <= 5_000) { /* ≤ 5 km */ + value = numberFormatters[1].format(value) + unitSeparator + 'm'; } else { - value /= 1000; - value = Math.round(value*100) / 100; - unit = 'km'; + value = numberFormatters[2].format(value/1000) + unitSeparator + 'km'; } - } else if (options.fn === 'area' && typeof value === 'number' && unit == null) { - if (value < 10000) { - unit = 'm²'; - } else if (value < 10000 * 10000) { - value /= 10000; - unit = 'ha'; + } else if (options.fn === 'area' && typeof value === 'number' && options?.unit == null) { + if (value < 10_000) { /* < 1 ha */ + value = numberFormatters[1].format(value) + unitSeparator + 'm²'; + } else if (value < 100_000_000) { /* < 10000 ha (100 km²) */ + value = numberFormatters[2].format(value/10_000) + unitSeparator + 'ha'; } else { - value /= 1000000; - unit = 'km²'; + value = numberFormatters[2].format(value/1_000_000) + unitSeparator + 'km²'; } - value = Math.round(value*100) / 100; } if (value == null) { return null; @@ -4583,8 +5152,9 @@ const disposePopover = (function() { case 'string': return document.createTextNode(value); case 'number': - if (unit != null) { - return document.createTextNode(value.toLocaleString('sv-SE') + '\u202F' + unit); + if (options?.unit != null) { + return document.createTextNode(numberFormatters[0].format(value) + + unitSeparator + options.unit); } return document.createTextNode(value.toString()); default: @@ -4851,7 +5421,13 @@ const disposePopover = (function() { header.appendChild(btnExpand); header.appendChild(btnClose); + const measureButton = document.getElementById('measure-button') + .getElementsByTagName('button')[0]; MAP.on('singleclick', function(event) { + if (measureButton.getAttribute('aria-expanded') === 'true') { + /* skip popover while measuring */ + return; + } disposeFeatureOverlay(); /* dispose any pre-existing popover if not in detached mode */ @@ -4860,8 +5436,7 @@ const disposePopover = (function() { popover.dispose(); } - const size = event.map.getSize(); - if (size[0] < 576 || size[1] < 576) { + if (window.innerWidth < 200) { return; /* skip popover if the map is too small */ } @@ -4883,7 +5458,7 @@ const disposePopover = (function() { const layerGroup = layer.get('layerGroup'); const layerName = feature.getProperties().layer; mapSources[layerGroup] ??= layer.getSource(); - const def = layerName != null ? layers[layerGroup][layerName] : null; + const def = layerName != null ? layers[layerGroup]?.[layerName] : null; if (def?.fields == null) { /* skip layers which didn't opt-in for popover */ return false; @@ -5505,13 +6080,11 @@ const ageFilterSetActive = (function() { .forEach((lyr) => lyr.changed()); }; const setter = function(active) { - if (ageFilterSettings._active !== active) { - if (active) { - ageFilterSettings.setupMinMax(); - } - ageFilterSettings._active = active; - changed(); + if (active) { + ageFilterSettings.setupMinMax(); } + ageFilterSettings._active = active; + changed(); if (active && ageFilterSettings.type === 'relative') { if (timeoutID == null) { timeoutID = setTimeout(fun, getDelay(state)[0]); |
